Compare commits

..
174 Commits
Author SHA1 Message Date
Clintchiz bca9549f3a finish CSS delivery and generated type remediation
Quality / quality (ubuntu-latest) (push) Failing after 12m54s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-10 00:17:25 +05:30
Clintchiz 232d8e6734 complete performance and reliability follow-ups 2026-08-09 23:04:36 +05:30
Clintchiz 8f19a5eb2b complete UI CSS ownership remediation 2026-08-09 21:42:30 +05:30
Clintchiz 937ccb9e8e stabilize cache ignore integration test 2026-08-09 21:11:01 +05:30
Clintchiz 6859b5c4ff localize legacy UI component styles 2026-08-09 21:07:10 +05:30
Clintchiz 3f5ec8696f ignore dev caches in example tool configs 2026-08-09 20:22:55 +05:30
Clintchiz 9fd80b7cef ignore per-process caches across repository tools 2026-08-09 19:48:54 +05:30
Clintchiz 7d87200e31 fix layout SSR translations and remediation follow-ups 2026-08-09 18:49:16 +05:30
Clintchiz 51286d0fa3 complete framework remediation validation 2026-08-09 14:39:22 +05:30
Clintchiz 905cbce0c1 remove hazardous legacy scripts and gate repository writes 2026-08-09 14:24:35 +05:30
Clintchiz 09ebd44b6c fix compiler authoring traps and showcase generation order 2026-08-09 14:22:12 +05:30
Clintchiz 2e5c0cc953 feat(ui): build remaining component scaffolds 2026-08-09 14:18:43 +05:30
Clintchiz 1660044ed2 perf(csr): load component controllers on demand 2026-08-09 14:10:15 +05:30
Clintchiz 324928cfca refactor(ui): finish style-only component migration 2026-08-09 14:01:01 +05:30
Clintchiz 5c2c6a0a8f refactor(ui): replace Tailwind utilities with local styles 2026-08-09 13:58:13 +05:30
Clintchiz ff62918dbb refactor(ui): remove superseded component scaffolds 2026-08-09 13:49:55 +05:30
Clintchiz 54a93c1d14 refactor(ui): localize component styles 2026-08-09 13:47:01 +05:30
Clintchiz b6bd5cfcb7 refactor(ui): localize progress and loading styles 2026-08-09 13:42:09 +05:30
Clintchiz fdf6282aed perf(compiler): share client function state closures 2026-08-09 13:38:55 +05:30
Clintchiz ca8aabf640 fix(compiler): avoid deferred helper name collisions 2026-08-09 13:34:17 +05:30
Clintchiz a90d3683d3 fix(dev-server): normalize hot reload module paths 2026-08-09 13:32:00 +05:30
Clintchiz d85be6c456 test(perf): tighten reactive runtime size budget 2026-08-09 13:25:16 +05:30
Clintchiz 8ef6233ef3 fix(compiler): reject malformed structured props 2026-08-09 13:24:18 +05:30
Clintchiz 91e8b2ab05 test(dev-server): guard server-rendered translations 2026-08-09 13:22:18 +05:30
Clintchiz b2e83bc941 feat(compiler): commit deferred client state writes 2026-08-09 13:21:34 +05:30
Clintchiz 3e77a621f5 test(dev-server): guard nested component slot rendering 2026-08-09 13:18:32 +05:30
Clintchiz a1f671ed5d feat(framework): make component props reactive 2026-08-09 13:17:36 +05:30
Clintchiz 7c584c1d2e feat(csr): diagnose missing rendered theme tokens 2026-08-09 13:07:54 +05:30
Clintchiz 709a38feb4 feat(csr): diagnose missing output binding functions 2026-08-09 13:06:56 +05:30
Clintchiz 35cd28aad4 revert(compiler): preserve inline structured component props 2026-08-09 13:00:18 +05:30
Clintchiz de8d792e37 fix(compiler): diagnose inline object component props 2026-08-09 12:58:02 +05:30
Clintchiz 64bb0e366a perf(compiler): deduplicate client peer state synchronization 2026-08-09 12:57:37 +05:30
Clintchiz e4502f2437 fix(dev-server): isolate generated cache per process 2026-08-09 12:56:11 +05:30
Clintchiz 504d065003 feat(csr): add stripped dev output diagnostics 2026-08-09 11:25:48 +05:30
Clintchiz f09341fcfa test(scripts): make UI generator newline check deterministic 2026-08-09 11:24:20 +05:30
Clintchiz 535ad5af6d chore(scripts): remove destructive UI catalog generator 2026-08-09 11:23:59 +05:30
ClintchizandClaude Opus 5 247f360ae7 docs: rank the destructive generator as item 0
Quality / quality (ubuntu-latest) (push) Failing after 19m36s
Quality / quality (windows-latest) (push) Canceled after 0s
It was written up in 4.7 but never made the work order, which is exactly how it
stayed dangerous in the first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 11:18:45 +05:30
ClintchizandClaude Opus 5 790b81330a fix: repair main after an unreviewed commit, and record the cause
Quality / quality (ubuntu-latest) (push) Failing after 11m9s
Quality / quality (windows-latest) (push) Canceled after 0s
Three separate problems, all traceable to `git add -A` sweeping up a working
tree I had not inspected.

Commit 69020b25 ("docs: make the component sections executable") committed far
more than docs: 79 files of a half-scaffolded inter-app example, and four of
those files were truncated mid-statement. That broke `bun run typecheck` on
main. The example is reverted to its last green six-file form. The truncated
fragments and the fuller working copy are NOT in this commit -- if any of that
workspace was wanted, it needs to be reconstructed deliberately and committed on
its own, not as a side effect of a docs change.

Separately, `scripts/generate-ui-complete-catalog.mjs` was run while checking
which helper scripts still work. It rewrites components in place, so it
flattened six of them to stubs, deleted 24 more and lower-cased four filenames
before crashing. Contents were restored from HEAD, but the renames survived
that restore: Windows is case-insensitive, so `git status` reported clean while
Card, Container, Divider and Grid sat on disk under the wrong names. The index
now tracks the capitalised names, which is what the components declare and what
ui-redesign-contract.test.ts reads -- that test would have failed on any
case-sensitive checkout.

Documented both as 4.7 and 4.8 in the remediation plan, with the general rule:
no script that rewrites packages/ui/components/ may write in place. Also fixes
the heading level on 4.6, which was rendering outside section 4.

bun run check is green: 1,433 pass, 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 11:11:57 +05:30
ClintchizandClaude Opus 5 69020b2555 docs: make the component sections executable in one pass
Quality / quality (ubuntu-latest) (push) Failing after 10m7s
Quality / quality (windows-latest) (push) Canceled after 0s
Expands 3.1 and 3.2 so the work can be done without re-deriving anything.

3.1 now records what 0.8.6 already fixed, separated into the ten components
that were miswired and the five that gained outputs they had been firing
undeclared, with the caveat that Map's three were converted but never confirmed
in a browser. For the 22 that remain it adds the finding that changes the
decision: all nine are pure scaffolds with no state, functions or handlers, and
five of them duplicate a component that already works -- FileUpload against
FileInput and FileUploadProgress, Toast and ToastNotifications against Toaster,
AdvancedDatePicker against DatePicker, AdvancedRangeSlider against RangeSlider.
Superseding those is a migration entry rather than new code, and leaves Chart,
TreeView, Confetti and CopyMarkup as the only ones needing to be built.

3.2 corrects the scaffold count from 23 to 28; the earlier figure used a looser
rule. Nine of the 28 are the 3.1 components, so the two items must be planned
together, and several of the rest are primitives that need only their styles
moved out of ui.css rather than any behaviour.

Also corrects the dead-output component count from 11 to 9 in both documents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 10:34:22 +05:30
ClintchizandClaude Opus 5 9ed896d2b9 docs: correct the dead-output component count from 11 to 9
Quality / quality (ubuntu-latest) (push) Failing after 12m8s
Quality / quality (windows-latest) (push) Canceled after 0s
Counted from source: the 22 remaining outputs sit in 9 components, not 11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 10:14:28 +05:30
ClintchizandClaude Opus 5 8389d9674e docs: rank the two size items in the work order
Quality / quality (ubuntu-latest) (push) Failing after 13m48s
Quality / quality (windows-latest) (push) Canceled after 0s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 10:03:19 +05:30
ClintchizandClaude Opus 5 5112cc1a62 docs: measure the runtime and the generated client modules
Quality / quality (ubuntu-latest) (push) Failing after 13m21s
Quality / quality (windows-latest) (push) Canceled after 0s
Adds a per-subsystem measurement of reactive.js, made by minifying it
repeatedly with one subsystem removed rather than counting source bytes.

This corrects the earlier audit on both figures and on the conclusion drawn
from them. Component controllers are 23,722 bytes minified / 6,660 gzipped --
30.6% of transfer, not the "about 18%" previously claimed -- and splitting them
out saves 6.6 kB gzipped on a typical page, not "3-4 kB". Measured against the
example app, / and /login use none of the ten controllers and /layout uses one,
so most pages download and parse the lot for nothing.

The larger finding is that the runtime is not where the weight is. One page
parses 490,212 decoded bytes across 11 generated client modules while
transferring 21,026, and the largest module is 89.8% duplicated lines: the
state-restore prologue appears 162 times because client-codegen.ts inlines the
sync into every peer alias of every client function. Gzip hides it on the wire,
but parse cost follows decoded bytes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 10:02:36 +05:30
ClintchizandClaude Opus 5 30d1632252 docs: a remediation plan for the framework
Quality / quality (ubuntu-latest) (push) Failing after 12m14s
Quality / quality (windows-latest) (push) Canceled after 0s
Collects what this session exposed into one actionable document: the five bugs
fixed in 0.8.6 and the guard protecting each, the places the model is
incomplete, the smaller defects, and the delivery and dev-loop problems.

Every item states the issue, the evidence, the change and a test that fails
before it. Where a cause is not proven -- the dev server not picking up
packages/ui edits -- the item says so and makes diagnosis step one rather than
asserting a fix.

Two standing conventions are written down at the top because the rest is
written against them: a component owns its markup, behaviour and styles in its
own .wrn file, and ui.css carries global styles only; and a test that still
passes with the fix removed is measuring nothing, which is how the 0.8.5 focus
trap shipped with no coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 09:50:45 +05:30
ClintchizandClaude Opus 5 7a2b58652a chore(release): prepare 0.8.6
Quality / quality (ubuntu-latest) (push) Failing after 11m2s
Quality / quality (windows-latest) (push) Canceled after 0s
Bumps all 47 packages, the root manifest and the VS Code extension to 0.8.6,
and rebuilds the editor compiler, language server and extension bundles that
embed the version.

The release carries the output delivery fix: camelCase outputs now reach
parent bindings, and 18 components emit through output.* instead of
hand-built CustomEvents. See the 0.8.6 migration entry for what changes for
consumers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 01:54:44 +05:30
ClintchizandClaude Opus 5 5e65627305 fix(csr,ui): deliver component outputs to parent bindings
Quality / quality (ubuntu-latest) (push) Failing after 13m39s
Quality / quality (windows-latest) (push) Canceled after 0s
An output only reaches a parent @binding when the component calls
output.<name>(). Two separate faults meant most of the library never got
there, and both failed silently at each end.

HTML lowercases attribute names, so a parent's @sizeChange registered under
"sizechange" while the component emitted "sizeChange". The lookup missed, fell
through to a DOM dispatch, and the binding was never invoked. That made all 17
camelCase outputs undeliverable -- DataTable.pageChange and .rowClick,
Map.markerClick, ChatBubble.messageClick, LayoutSplitter.sizeChange and the
rest. invokeComponentOutput now falls back to a case-insensitive lookup, and a
csr test fails without it.

Separately, 18 components dispatched hand-built CustomEvents rather than
calling output.*. A bubbling event on the component's own root never reaches a
binding, because parent handlers live in a registry only the output proxy
reads. Card, Footer, Breadcrumb, Accordion, alert, Badge, AnnouncementBar,
AvatarGroup, ToggleCount and InputNumber now emit properly; Marquee, Map,
Timeline, List and SearchBox additionally declare the outputs they were
already firing. Dispatches on window are left alone -- that is how Toaster,
Modal and DataTable signal across component boundaries.

Verified in a browser both ways before and after: an AnnouncementBar
dispatching its own bubbling "dismiss" never reached a page-level @dismiss,
and reached it immediately once it called output.dismiss().

This corrects the audit, which called the LayoutSplitter failure "narrow and
unexplained" and read 32 dead outputs as 16 components needing a rebuild.
"Outputs work elsewhere" was an assumption; the components that worked
happened to use lowercase names and output.*. The dead-output ratchet drops
from 32 to 22, and a new test forbids the raw-CustomEvent pattern outright.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 01:40:39 +05:30
ClintchizandClaude Opus 5 84dc4e06a2 docs: audit the ui library and record the 0.8.6 migration
Quality / quality (ubuntu-latest) (push) Failing after 12m40s
Quality / quality (windows-latest) (push) Canceled after 0s
A measured pass rather than a bulk rewrite. Numbers come from the source and
from a browser.

The migration entry covers what has accumulated since 0.8.5 and would
otherwise reach upgraders unannounced: the Tabs output contract, the Sidebar
BEM rename, the layout components leaving Tailwind so their rendered class
lists changed, LayoutSplitter and CustomScrollbar changing props and outputs,
the ui.css families that were removed, and the theme tokens that now paint
where they previously resolved to nothing.

The audit records what is still wrong, with counts: 32 outputs across 16
components that nothing emits, 23 components still on the scaffold pattern, 66
without a local style block and therefore dependent on ui.css, and 10 still
using Tailwind. A test pins the dead-output count at 32 as a ceiling that only
moves down, so rebuilding a component tightens it and no new one can be added
quietly.

It also records what is not worth doing. Splitting the runtime saves 3 to 4 kB
gzipped on a first visit to a file cached for a year, and hydration costs
1.5 ms for 21 scopes across 4325 elements, so neither is a real problem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 17:16:57 +05:30
ClintchizandClaude Opus 5 c7ab605e85 feat(ui): migrate the page and section components, add the layout tour
Quality / quality (ubuntu-latest) (push) Failing after 12m21s
Quality / quality (windows-latest) (push) Canceled after 0s
Section, SectionHeader and PublicPageShell move onto wire-* classes with
local style blocks, variants as data attributes. SectionHeader loses 38
utility lines, and Section stops spending one line per variant and colour
pairing: tinted and solid now select on two attributes. PageHeader was already
on the convention and only needed the audit.

examples/basic-app/app/pages/layout.wrn composes the whole set into one page.

Building it surfaced a library-wide bug. Ten custom properties were referenced
by components and defined by nothing: --wire-color-focus, --wire-color-surface-soft,
--wire-color-on-danger, --wire-color-surface-subtle and the input-* family,
plus hover and contrast for every semantic colour except primary and
secondary. An undefined custom property does not warn, it resolves to nothing,
so focus rings drew with no colour and every soft surface rendered
transparent -- 27 components referenced surface-soft alone. They are derived
in the theme now, and a test checks every token a component references against
the rendered theme CSS rather than the source, since most are generated.

The semantic spread also had to move ahead of the primary and secondary
entries so the palette keeps winning for those two.

Known and unresolved: LayoutSplitter emits its sizeChange output and the
component does fire it, but a parent binding on the tag is not invoked. The
tour page therefore points at the handle aria-valuenow rather than wiring a
handler that would never update. Outputs work elsewhere, so this is narrower
than an outputs-are-broken problem and needs its own investigation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 15:50:33 +05:30
ClintchizandClaude Opus 5 8aa28205e0 refactor(ui): migrate the layout components off Tailwind utilities
Quality / quality (ubuntu-latest) (push) Failing after 12m47s
Quality / quality (windows-latest) (push) Canceled after 0s
Container, Columns, Grid, Divider, Image, Link, Typography and Kbd were built
from utility classes and class: conditionals. That works only where Tailwind
is present, and every variant cost a dozen conditional lines -- Divider spent
eleven of them saying which token to paint the rule.

They now carry wire-* classes with a local style block, and variants are data
attributes the style block selects on. Divider went from eleven conditionals
to five rules, and Typography lost thirteen.

Behaviour is preserved rather than improved on. Container keeps columns and
gap even though a container is not really a grid, because applications depend
on them, and its columns default stays 2: the redesign contract test caught
that changing it would silently reflow every Container already published.

Additive only: Grid gains minItemWidth for an auto-fit track, Divider gains
dashed and dotted variants, Image gains fit, and Link gains underline.

Verified in a browser rather than by eye, since the pane cannot screenshot:
track counts match the declared columns at desktop and collapse correctly
below each breakpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 11:33:59 +05:30
ClintchizandClaude Opus 5 b56cb8cba5 feat(ui): build LayoutSplitter and CustomScrollbar for real
Quality / quality (ubuntu-latest) (push) Failing after 6m8s
Quality / quality (windows-latest) (push) Canceled after 0s
Both advertised behaviour they did not have. LayoutSplitter declared
resizeStart, resize and resizeEnd with no pointer handling whatsoever, so a
caller wired up @resize and received nothing, for ever, with no error, and its
props were columns, gap and maxWidth copied from a grid scaffold.
CustomScrollbar was the same shape with a scroll output.

The splitter now resizes. Dragging lives in the reactive runtime behind
data-wrn-splitter, because a pointermove fires far too often to route through
a client function and a state write made in that callback is dropped; the
resolved size is held on the container as a --wrn-split custom property and
the component grids from it. The handle is a real separator: arrow keys step
it, Home and End go to the bounds rather than to nothing, and it carries
aria-valuenow, aria-valuemin and aria-valuemax. minSize fixes both bounds so
neither pane can be dragged away and left unrecoverable.

CustomScrollbar is CSS rather than script -- scrollbar-width and
scrollbar-color with webkit rules for the engines that still need them -- and
its fake scroll output is removed rather than left unimplemented, since a
caller can listen for a plain scroll event.

The test harness needed a fix too: mount did not bind the window CustomEvent,
so the runtime built events from the host global and happy-dom listeners never
matched them, which made anything dispatched look silently lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 11:22:45 +05:30
ClintchizandClaude Opus 5 b3a4d80df3 docs: layout and page-structure component group design
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 11:09:50 +05:30
ClintchizandClaude Opus 5 7bd4574b08 fix(example): theme the basic-app palette and surface the navigation tour
Quality / quality (ubuntu-latest) (push) Failing after 10m43s
Quality / quality (windows-latest) (push) Canceled after 0s
global.css defined its own fixed palette -- bg 0b1020, text e7ecff -- while
Wire UI surfaces follow the theme tokens. Switching to light turned the cards
light and left this text light with them, so the sign-in form rendered at a
contrast of about 1.1 and could not be read. The palette now derives from the
wire tokens, and the body wash is tinted from the primary token rather than a
fixed blue. Measured on the login form: light goes from 1.1 to 17.7, dark
stays at 18.2.

Anything an application hardcodes has to be themed as well, or it only ever
looks right in one mode.

The navigation tour is also reachable now: a Navigation entry in the site nav,
translated in both locales, and the page adopts the public layout so there is
a way back out of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 10:19:12 +05:30
ClintchizandClaude Opus 5 e32d83933e docs(example): one page wiring the whole navigation group together
Quality / quality (ubuntu-latest) (push) Failing after 13m3s
Quality / quality (windows-latest) (push) Canceled after 0s
examples/basic-app/app/pages/navigation.wrn puts all nine navigation
components in a single console shell instead of showing each alone: Navbar
with a nested dropdown, MegaMenu beside it, Breadcrumb, Sidebar as a rail that
becomes a Drawer, Tabs backed by a query parameter, a Stepper wizard,
Pagination, Scrollspy following the article, and Nav in the footer.

Three framework limits shaped the layout and are written into the page rather
than hidden:

  - object props are held in state and bound, because a brace at the start of
    an attribute is read as an interpolation
  - a shared function on a page is compiled standalone and cannot see page
    state by name, so state is passed as arguments
  - component props and slot content render once and do not track page state,
    so anything that has to react lives in page scope; Tabs reports the
    selection and the page owns what is shown

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 09:57:53 +05:30
ClintchizandClaude Opus 5 5ce2771718 fix(ui): release the scroll lock on closed drawers, add stepper wizard controls, slide tabs
Quality / quality (ubuntu-latest) (push) Failing after 12m29s
Quality / quality (windows-latest) (push) Canceled after 0s
The scroll lock was mine, and it broke every page carrying a Drawer or Modal.
Making dialog visibility testable, I replaced a size check with a data-show
check -- but a Drawer animates open, so its panel cannot be hidden with
data-show at all: display:none is not transitionable. Every closed Drawer
therefore looked open, took the body scroll lock and never released it, and
the page could not be scrolled. Both components publish data-open, which is
the signal that actually means open, and that is what is read now.

Stepper gains the wizard surface: showPanel renders each step body and shows
only the active one, the same contract Tabs uses, and controls adds Back,
Skip and Next, which becomes Finish on the last step. nextDisabled lets a form
hold the step; the component never validates anything itself, since the page
owns the form.

Stepper also gets a single root. The panels and controls were siblings of the
list, so the component had several roots and anything scoped to
data-ui-component missed most of it.

Tabs panels now slide in the direction of travel rather than fading upward.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 23:41:18 +05:30
ClintchizandClaude Opus 5 68c7b96a9f fix(showcase): render object props, bridge the mega menu gap, make scrollspy testable
Quality / quality (ubuntu-latest) (push) Failing after 12m38s
Quality / quality (windows-latest) (push) Canceled after 0s
Object props never worked in generated demos, and my two earlier attempts each
traded one failure for another:

  - a bare {...} attribute is read by the compiler as an interpolation, so it
    parsed JSON as JavaScript and the page 500ed
  - parenthesising it compiled, but prop coercion runs JSON.parse on the raw
    attribute, so ({...}) threw and every demo rendered empty and silent
  - entity-escaping the braces did not help either: the compiler hands the
    attribute over without decoding, so JSON.parse still failed

They are now hoisted into page state and bound, which is what the playground
has always done. The state initialiser uses JSON.parse rather than an object
literal because the parser reads a leading brace as the start of a block.

Navbar gains a real profile: a brand, links, a two-column dropdown panel and
calls to action, instead of the generic scaffold samples that made every demo
look identical and showed no dropdown at all.

MegaMenu closed while the pointer travelled to it. The panel sits below the
trigger and that offset belongs to neither element, so crossing it fired
mouseleave on the root. A descendant now covers the gap.

Scrollspy could not be exercised at all: its links pointed at ids that did not
exist on the page. The demo now ships real sections, in page flow because the
runtime observes against the viewport.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 23:12:37 +05:30
ClintchizandClaude Opus 5 6a9c48a207 perf(ui): remove dead runtime controllers and unused stylesheet families
Quality / quality (ubuntu-latest) (push) Failing after 6m17s
Quality / quality (windows-latest) (push) Canceled after 0s
Two orphaned controllers in the reactive runtime targeted markup nothing
emits any more: hydrateSidebarControllers looked for .wire-sidebar-shell and
friends, which the Sidebar rewrite replaced with BEM classes earlier today,
and hydrateDropdownControllers looked for [data-wrn-dropdown], which no
component or compiler output has ever produced.

ui.css loses the matching legacy sidebar rules, the wire-mega-menu family
left behind when the MegaMenu scaffold was replaced, and a set of
self-contained application-pattern families that nothing references.

Utility layers are deliberately kept even where an individual member is not
name-checked anywhere. wire-bg-primary is documented and tested while
wire-bg-secondary is not, but they are one public family and splitting them
would be incoherent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 21:46:42 +05:30
ClintchizandClaude Opus 5 9a03b7c8d4 merge: runtime observer consolidation and a shipped-size budget
Quality / quality (ubuntu-latest) (push) Failing after 11m0s
Quality / quality (windows-latest) (push) Canceled after 0s
One document observer with subscribers instead of four, runtime budgets
measured on minified output rather than raw source, Navbar styles moved into
the component, and two bugs fixed: object props broke showcase pages with a
500, and the runtime evaluated JSON sitting in a textarea.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 21:20:34 +05:30
ClintchizandClaude Opus 5 361129b6ac perf(build): budget the runtime on what ships, not on source bytes
The runtime budgets measured raw source, which counts comments -- and the
production build minifies, so comments cost a visitor nothing. The metric
therefore rewarded deleting explanatory comments over writing smaller code,
and could not tell a real feature from a wall of prose.

They now measure the minified output, which is what is actually served:
/__wrnexus/reactive.js is its own file, minified, with an immutable year-long
cache. The reactive runtime is 69684 minified against a 80000 budget, from
175246 raw -- roughly 21kB gzipped, fetched once.

Also fixes two real bugs found while testing the showcase:

  - object-valued props were serialised as a bare {...} attribute, which the
    compiler read as interpolation and tried to parse as JavaScript. That
    returned 500 for /components/navbar. Arrays start with [ and were never
    affected, which is why only object props broke. All 108 pages now render.

  - the runtime walked text nodes inside textarea, script and style, so a
    JSON sample in a textarea was evaluated away.

Navbar styles move out of ui.css into the component, matching the rest of the
navigation group. No declarations changed: 4519 before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 21:20:19 +05:30
ClintchizandClaude Opus 5 8069541cd9 refactor(csr): one document observer with subscribers
Overlay clamping, dialog focus, roving focus and scrollspy each ran their own
MutationObserver over the same stream of records. They now share one, with the
per-feature work registered as subscribers. Every subscriber already defers,
so the extra callbacks are cheap and the bookkeeping is paid for once.

The attribute filter stays explicit rather than observing everything: an
unfiltered observer would see the tabindex the roving code writes and loop on
its own output.

This is better structured but it is not a fix for the size budget -- it buys
82 bytes of headroom, not room to grow. Splitting the runtime so a page pays
only for the behaviour it uses is still the outstanding decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 19:26:57 +05:30
ClintchizandClaude Opus 5 4a82640c5b merge: navigation components phase 3
Quality / quality (ubuntu-latest) (push) Failing after 12m47s
Quality / quality (windows-latest) (push) Canceled after 0s
Scrollspy built, Navbar given roving focus, and the invalid empty aria-current
fixed across Navbar and Breadcrumb.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 19:22:10 +05:30
ClintchizandClaude Opus 5 52660cbb8e feat(ui): build Scrollspy, fix aria-current across the navigation group
Scrollspy replaces a scaffold that rendered bare anchors. The runtime observes
the sections the links point at and writes the marker straight onto the links:
an IntersectionObserver callback fires long after the client function that
registered it returned, so a state write there would be dropped.

Navbar and Breadcrumb both emitted aria-current="" for every inactive link.
That is not a valid value -- the attribute takes a token or must be absent --
so every link claimed a state it did not have. Breadcrumb had it too, despite
being the strongest component in the group.

Navbar also takes roving arrow-key focus across its menu bar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 19:18:25 +05:30
ClintchizandClaude Opus 5 ca2f9451ab merge: navigation components phase 1 and 2
Quality / quality (ubuntu-latest) (push) Failing after 12m45s
Quality / quality (windows-latest) (push) Canceled after 0s
Roving arrow-key focus in the runtime, then Nav, Pagination, Stepper,
MegaMenu, a Sidebar rebuilt on Drawer, and a Tabs rewritten off Tailwind onto
wire classes with real outputs and url-backed selection.

Also fixes the modal focus trap shipped in 0.8.5, which gated on
getBoundingClientRect and so never ran under test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 17:54:26 +05:30
ClintchizandClaude Opus 5 b3116de354 fix(ui): derive tab selection from the url instead of syncing to it
My diagnosis in the previous commit was wrong. The component root was not
being replaced by a reactive re-render: the client router owns popstate and
swaps the whole page shell on back and forward, which discards component
state entirely. Every mechanism that tried to push state into the component
from outside was therefore doomed -- clicking a tab, announcing an event,
tracking the last applied value.

In url mode the query parameter is now simply the source of truth, read where
the selection is computed. Whatever render happens next produces the right
tab, with no listener to lose and nothing to keep in step.

This deletes the runtime tab sync entirely -- 1590 bytes -- and fixes the
back/forward cases that were previously broken. Verified in the showcase:
click writes the url, two backs and two forwards each land on the right tab,
and a ?tab= deep link opens on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 17:54:13 +05:30
ClintchizandClaude Opus 5 f6993e6cdb fix(csr): drive tab url restore by announcement, anchor nav submenus
Two mechanisms tried and rejected while testing this against the live
showcase, both failing the same way on a second history step:

  - synthesising a click on the matching tab: a re-render replaces the tab
    buttons, and clicking a freshly replaced node that has not been bound
    does nothing at all
  - tracking the last applied value in the runtime: that state drifts out of
    step with the component and silently swallows real changes

The runtime is now stateless. It announces the value the URL names via a
wrnexus:tabs:restore event and the component applies it, comparing against
its own selection rather than a DOM attribute a re-render owns.

Nav submenus are anchored so the viewport clamp keeps them on screen.

Comments in the runtime template trimmed to stay inside the size budget
rather than raising the ceiling again.

Known limitation: a second consecutive back/forward does not update the
selection, because the re-render replaces the component root without
rebinding its declarative listeners. That is a framework defect, not a Tabs
one, and needs its own fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 17:47:41 +05:30
ClintchizandClaude Opus 5 f1d1081b67 feat(ui): build MegaMenu and rebuild Sidebar on Drawer
MegaMenu replaces a scaffold with a trigger and a wide panel of grouped link
columns. One level deep on purpose: a mega menu exists to show breadth flat so
everything is one click away, and nesting inside the panel buries content
behind hover-within-hover. Nav is the component for cascading submenus. The
panel is anchored so the runtime clamp keeps it inside the viewport.

Sidebar now composes Drawer for its off-canvas presentation instead of a
hand-rolled backdrop, inheriting the focus trap and scroll lock from one
place. Single items, labelled groups and branches nested to three levels, with
vertical roving focus.

Sidebar classes move to the BEM naming the rest of the library uses, which is
a breaking change; nesting via children still works, since that is what
shipped in 0.8.5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 17:24:03 +05:30
ClintchizandClaude Opus 5 b4d3cb3695 feat(ui): rewrite Tabs onto wire classes with URL sync and roving focus
Tabs was the only component in the library styled with Tailwind utilities, so
it could not be themed like the rest and assumed Tailwind was present. It also
fired raw CustomEvents instead of declaring outputs, and set a roving tabindex
with no keydown handler at all -- which left every inactive tab unreachable by
Tab while the arrows did nothing.

It now uses wire-* classes and a local style block, declares change and select
outputs, and opts into the roving runtime.

mode=url mirrors the selection into a query parameter via pushState. Back and
forward are handled in the runtime, which activates the matching tab rather
than assigning to component state: a popstate listener writing state would be
writing after the client function returned, and that write is dropped. The
round trip is marked so the component does not push a second history entry for
a navigation that came from history.

Also anchors Nav submenus so the viewport clamp can pull them back on screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 17:18:00 +05:30
ClintchizandClaude Opus 5 124da548b8 chore(ui): wire color and size into the new navigation components
Every bundled component must expose color and size; the rewrites dropped
them, which the library-wide invariant test caught. Rather than re-adding
them as dead props, each component now maps color onto an accent variable
that its active, current and focus affordances actually use, and size onto
the root font scale.

Regenerates the component reference, showcase and visual contract.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:57:10 +05:30
ClintchizandClaude Opus 5 f4960f2fc5 feat(ui): build the Nav component with submenus and roving focus
Replaces a scaffold that rendered bare anchors. Flat or nested to three
levels, icons, badges, disabled items, aria-current on the active link,
arrow-key roving focus, and a disclosure arrow that rotates on open.

Three levels rather than arbitrary depth because this template language has
no component recursion, so each level is written out.

On a phone the bar becomes a toggle and submenus stack inline rather than
floating: a hover-opened overlay cannot be reached on touch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:47:20 +05:30
ClintchizandClaude Opus 5 45ef035bae feat(ui): build the Stepper component
Replaces a scaffold that rendered bare anchors with ordered steps: complete,
current and upcoming status derived from the active index, horizontal or
vertical, optional icons, and indexed named slots (step-0, step-1, ...) for
authoring a step body by hand.

Also tightens the roving contract from the previous commit. A template writes
data-wrn-roving="" or data-wrn-roving-item="false" to mean not this time, but
a bare [attr] selector matches either, so a read-only stepper would still have
taken arrow-key focus. The container now requires a named axis, while a bare
item marker still counts as opted in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:44:12 +05:30
ClintchizandClaude Opus 5 b67a5e43eb feat(ui): build the Pagination component
Replaces a scaffold that rendered a bare list of anchors with real page
controls: compact arrows or windowed page numbers, a range summary, and a
change output carrying the requested page. Out-of-range pages clamp rather
than rendering nothing, because page arrives as an HTML attribute and callers
compute it from data that may have shrunk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:41:56 +05:30
ClintchizandClaude Opus 5 7ac6e08544 fix(csr): make dialog visibility testable and cover the focus trap
The focus trap and scroll lock shipped in 0.8.5 gated on
getBoundingClientRect, which the test DOM always reports as zero, so a dialog
never counted as open and none of that behaviour ran under test. focusableWithin
had the same measurement gate and would have found no items even once the
visibility check was fixed.

Both now use the hidden attribute and the data-show marker the components
already emit. Behaviour in a real browser is unchanged; the difference is that
it is now covered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:40:30 +05:30
ClintchizandClaude Opus 5 f94004648d feat(csr): runtime-owned roving arrow-key focus
A container marked data-wrn-roving owns its [data-wrn-roving-item]
descendants: one carries tabindex=0 so Tab reaches the group once, and the
arrow keys move within it, with Home/End, wrap-around and skip-disabled.

Written once here rather than five times across Tabs, Nav, MegaMenu, Sidebar
and Stepper, and because focus bookkeeping cannot live in component state --
a client function writing after it returns has that write dropped.

Item visibility is checked via hidden and data-show rather than measured
size: the test DOM reports every element as zero-sized, which is exactly what
left the dialog focus trap uncovered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:38:44 +05:30
ClintchizandClaude Opus 5 e8e1a2623b docs: navigation phase 1 implementation plan
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:35:42 +05:30
ClintchizandClaude Opus 5 ecb93c7116 docs: navigation component group design
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:28:30 +05:30
ClintchizandClaude Opus 5 f01a308287 chore(release): stage 0.8.5 package tarballs
Quality / quality (ubuntu-latest) (push) Failing after 12m26s
Quality / quality (windows-latest) (push) Canceled after 0s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:16:43 +05:30
ClintchizandClaude Opus 5 1fb1a8d2d0 chore(release): prepare 0.8.5
Quality / quality (ubuntu-latest) (push) Failing after 12m21s
Quality / quality (windows-latest) (push) Canceled after 0s
Bumps every @wrnexus package 0.8.4 -> 0.8.5 and adds the matching update
migration. The migration is documentation only: moving off <Table> to
<DataTable> and off the @wrnexus/ui main entry to @wrnexus/ui/registry are
source changes no codemod can make safely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:08:05 +05:30
ClintchizandClaude Opus 5 949cf78636 feat(ui): add DataTable and Toaster, drop the legacy Table, fix overlay dialogs
Quality / quality (ubuntu-latest) (push) Failing after 13m40s
Quality / quality (windows-latest) (push) Canceled after 0s
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 <noreply@anthropic.com>
2026-08-07 14:59:58 +05:30
Clintchiz 296728d51d merge: inter-app RPC 2026-08-05 21:40:30 +05:30
Clintchiz 98205daef6 fix(rpc): isolate integration test from cross-suite fetch pollution
packages/csr's actions.test.ts and reactive.test.ts both leave
globalThis.fetch mutated across bun test files (reactive.test.ts's
'cache invalidation refetches...' test replaces it and never restores
it). Since bun test runs files sequentially rather than importing all
of them up front, a module-level capture of fetch in this file would
already observe csr's leftover mock (csr sorts before rpc).

Route the real-socket assertion through a small node:http-backed fetch
implementation instead of relying on globalThis.fetch at all, keeping
the test's actual target - httpTransport()'s default resolveOrigin -
unaffected by any other suite's global mutation.
2026-08-05 20:57:08 +05:30
ClintchizandClaude Opus 5 3eec9fd8c6 fix(rpc): close the four final-review blockers on inter-app RPC
- Resolve RPC call origins via a new WRNEXUS_INTERNAL_ORIGINS map (loopback
  origins the gateway hands each child before spawning it), falling back to
  the public appOrigin only when it is absent. Calls previously always went
  to the public gateway origin, which the gateway unconditionally 404s on
  the RPC prefix by design — every real cross-app call failed.
- Stop loadServices() from running ahead of routing and stop memoizing a
  rejected load: one bad file under app/services/ no longer permanently
  breaks every route in the app. A failed load logs loudly, is retried on
  the next RPC request, and the RPC path gets a structured RPC_UNKNOWN
  instead of an unhandled throw.
- Reject a service whose contract.name does not match the filename it is
  mounted under, naming both, instead of silently mounting under the
  filename while the typed client calls by contract name.
- Let ServiceError accept an explicit retryable and have the client pass the
  wire value through, instead of recomputing (and silently flipping) it from
  the error code alone.
- Document the gateway/X-Forwarded-* deployment requirement in the RPC
  README.

Each of the three code blockers has a new/extended test that was verified to
fail when its fix was reverted (rpc/test/integration.test.ts,
dev-server/test/rpc-services-loading.test.ts).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 20:36:31 +05:30
ClintchizandClaude Opus 5 6aaf21aa06 feat(rpc): add the caller-side example and close remaining coverage gaps
Adds examples/auth-showcase/app/services/greeter-client.ts so the showcase
demonstrates both halves - the review noted the example was callee-only, so a
developer had no working reference for making a call.

Raises integration coverage to the planned 3 tests and adds the missing
rpc-endpoint cases. Also wires the prod build path for services.

304 tests pass across rpc/router/dev-server/cli; typecheck, lint, format and
check:public-api all clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 20:21:41 +05:30
ClintchizandClaude Opus 5 ce68803471 fix(rpc): close the service-collision fail-open and the fix-wave gaps
Critical:
- router: fail loudly (WRN-SERVICE-COLLISION) when two app/services files
  scan to the same service name, instead of silently letting directory-walk
  order pick a winner.

Important:
- server.ts: wrap a throwing input schema so its raw message cannot escape
  invoke(); returns RPC_INVALID and logs server-side instead.
- client.ts: race timeoutMs against transport.call so a stalled transport
  cannot hang the caller; rejects with a ServiceError(RPC_TRANSPORT).
- client.ts: the proxy returns undefined for undeclared properties (incl.
  then/catch/finally) instead of a function that throws, closing the
  await-client thenable trap.
- gateway.ts / rpc-dispatch.ts: import RPC_PATH_PREFIX / RPC_INTERNAL_HEADER
  from @wrnexus/rpc instead of hardcoding local copies.
- gateway.test.ts: cover the RPC-prefix edge block and internal-header
  stripping across casing variants.
- http.test.ts / client.test.ts: cover anonymous-call header omission, the
  internal marker, the retryable-status sweep, network/malformed/HTML
  failures, AbortSignal propagation, the timeout path, and timer cleanup.

Minor:
- transport.ts: Object.hasOwn for handler lookup; note the entry-only abort
  check.
- client.ts: wrap a missing/invalid WRNEXUS_RPC_SECRET as a ServiceError
  (RPC_IDENTITY) instead of a bare Error.
- rpc/package.json: drop the unused @wrnexus/authz dependency.
- server.ts: implement() now throws at construction time if a declared
  procedure has no own handler.

Verified: reverting the service-collision check and the client timeout race
each make their new test fail, then restore green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 20:10:20 +05:30
ClintchizandClaude Opus 5 7c4b484d0a fix(rpc): close prototype-chain permission bypass, add server/client/transport tests
C1 CRITICAL: implement() looked up procedures/handlers with plain property
indexing, so any Object.prototype member name (constructor, toString, etc.)
resolved truthy and skipped the permission gate entirely. Fixed with
Object.hasOwn checks in packages/rpc/src/server.ts. Defense-in-depth guard
added in packages/dev-server/src/rpc-dispatch.ts constraining URL path
segments to a safe charset before they reach service/procedure lookups.

Added missing direct test coverage for packages/rpc/src/transport.ts,
server.ts and client.ts (previously untested), including a prototype-name
sweep in both server.test.ts and dev-server's rpc-endpoint.test.ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 19:46:59 +05:30
ClintchizandClaude Opus 5 fcf4ed3039 docs: close a prototype-chain authorization bypass in the plan
server.ts looked procedures up with plain property indexing, so every
Object.prototype member resolved as truthy. A prototype member carries no
`permission`, so the permission gate was skipped entirely.

Verified: with a contract whose only procedure declares a permission and a
checkPermission that always denies, invoke("add") correctly returns
RPC_DENIED, while invoke("constructor") returns {"ok":true,"value":{"a":2}}
and the gate never runs.

Reachable over the wire as POST /__wrnexus/rpc/<service>/constructor by
anything that clears the internal-caller check - i.e. any workspace app.

Fixed at both layers: Object.hasOwn for the procedure and handler lookups,
and a character-class guard on the path segments before they are used as
lookup keys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 19:42:49 +05:30
ClintchizandClaude Opus 5 e01915823a feat(rpc): transport, server, client, http, mounting, docs (Tasks 5-11)
Brings the uncommitted body of work under version control so it cannot be
lost. Gates are green: 152 tests pass across rpc/router/dev-server,
typecheck, lint, format and check:public-api all clean.

NOT YET REVIEWED. None of Tasks 5-11 has had an independent task review, and
Task 4's second fix round was never re-reviewed either.

Known gaps against the plan, recorded here rather than discovered later:
- packages/rpc/test/{transport,server,client}.test.ts are ABSENT. The plan
  required a test file for each. server.ts holds the fail-closed identity and
  permission checks and currently has no direct coverage at all.
- rpc-endpoint.test.ts has 3 tests where the plan specified 9. Missing:
  unknown service, non-POST, malformed body, non-rpc passthrough, and the
  isInternalCaller sweep. This is the task where a reachable
  /__wrnexus/rpc/* makes every permission check in the workspace bypassable.
- http.test.ts has 3 of 7; integration.test.ts 2 of 3;
  services-discovery.test.ts 1 of 4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 19:38:04 +05:30
ClintchizandClaude Opus 5 9bc0f48514 fix(rpc): close the iat fail-open and tighten the identity guards
verifyJwt gates its maxAge check on iat being a number, so a token forged
without iat was honoured at any maxAgeSeconds - the same shape as the
audience and exp fail-opens closed in the previous round. A future-dated iat
did the same via a negative age. Both refused now.

The import side never checked aud was a single string, and verifyJwt compares
with includes(), so a multi-audience token verified at several apps. The
mint-side guard's invariant now holds where it is enforced.

ctx.tenant present with a null id minted an authenticated credential with no
tenant claim, which the callee reads as global. Absent ctx.tenant means
untenanted; a present tenant with an unusable id is an error.

Adds six tests pinning behaviours that mutation testing showed were free to
delete without any test noticing: no-exp, no-iat, the 300s default max age,
an array audience on import, a non-string tenant claim on import, and a null
tenant id at mint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:32:41 +05:30
ClintchizandClaude Opus 5 9f599e02e8 docs: close the iat fail-open and tighten the Task 4 identity guards
The round-1 fix required exp and passed maxAge, but verifyJwt gates its age
check on iat being a number - the identical shape to the two fail-opens that
round closed. A token minted without iat defeats the age bound at ANY
maxAgeSeconds, and a future-dated iat yields a negative age and does the
same. Both refused now, so maxAge means what ImportOptions says it means.

The mint side refused an array targetApp, but the import side never checked
that aud was a single string, and verifyJwt compares with includes(). So a
multi-audience token still verified at several apps - the invariant was true
only where it was not enforced. Now checked at the callee.

ctx.tenant present with a null id was treated as untenanted, silently
widening scope to global while still issuing an authenticated credential.
Absent ctx.tenant means global; a present tenant with an unusable id is an
error.

Also exports ImportOptions, which the append snippet omitted although the
Produces line names it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 14:36:05 +05:30
ClintchizandClaude Opus 5 83c99cc3e5 fix(rpc): close identity-token fail-open and validation gaps
- importSubjectContext now rejects a non-string/empty selfApp before
  verifying. verifyJwt skips the audience check entirely when audience
  is undefined, so an unvalidated selfApp (the natural shape of
  currentAppName(): string | undefined) accepted every token from every
  app for every audience.
- exportSubjectContext now rejects a non-string/empty targetApp, so an
  array can no longer mint one token valid at multiple apps.
- Both directions now reject a present-but-non-string tenant id instead
  of silently dropping it (was: callee reads missing tenantId as
  global/unscoped -> cross-tenant exposure).
- importSubjectContext now requires exp to be present and independently
  bounds accepted token age via a new maxAge/ImportOptions.maxAgeSeconds
  (default 300s), so a caller cannot mint a long-lived token via a huge
  ttlSeconds and have it honoured indefinitely.
- SubjectContext.callerApp doc now states it is self-asserted (the
  signing secret is workspace-wide) and must never be an authz input.
- index.ts also exports the new ImportOptions type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 14:26:14 +05:30
ClintchizandClaude Opus 5 1393a8a3b8 docs: close a fail-open and three gaps in the Task 4 identity plan
CRITICAL: importSubjectContext never validated selfApp, and verifyJwt skips
the audience check entirely when audience is undefined. So an undefined
selfApp disabled the only cross-app binding in the system and accepted every
token from every app for every audience. Not hypothetical - the natural feed
is helpers' currentAppName(), which returns string | undefined. The mint side
already hard-fails on a missing app name; the import side did not.

A non-string tenant id was silently dropped at both ends. A numeric tenant id
is the common DB-backed case, and a callee reading a missing tenantId as
"global" is a cross-tenant exposure. Now refused, symmetric with the subject
check.

Token lifetime was unbounded: verifyJwt only checks exp when present, so a
token minted without one never expired, and a caller passing a large
ttlSeconds produced a long-lived impersonation credential the callee
honoured. exp is now required and age is bounded by maxAge independently.

targetApp was unvalidated, so passing an array minted one token valid at
several apps - exactly what the audience binding exists to prevent.

Also documents callerApp as self-asserted rather than authenticated
provenance, since the signing secret is workspace-wide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 14:21:38 +05:30
ClintchizandClaude Opus 5 2257ee871e feat(rpc): add the signed subject-context token
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 14:12:42 +05:30
Clintchiz 40625e98ed fix(rpc): deep-freeze procedures in defineService, not just the map 2026-08-05 14:03:40 +05:30
ClintchizandClaude Opus 5 e0bd84247e docs: deep-freeze procedures in the Task 3 plan snippet
defineService froze the procedures map but not each procedure inside it, so a
ProcedureDef built by hand rather than through procedure.build() stayed
mutable: svc.procedures.foo.permission = 'hacked' silently succeeded. The
contract is shared between two apps as a single source of truth, and the
guarantee rested on every call site remembering to use the builder.

Same class as the authz catalog's frozenMap, which froze the Map's mutators
but not the values it handed out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 14:01:52 +05:30
ClintchizandClaude Opus 5 34d5bbc810 docs: add the missing cast to .input() in the Task 3 plan snippet
The builder's .input() did not typecheck as written (TS2345). The phantom
__input/__output markers make ProcedureDef invariant, which is exactly why
.output<T>() already carried a cast - .input() needed the analogous one and
did not have it.

Caught by the Task 3 implementer, who also verified via @ts-expect-error that
InferProcedureInput/InferProcedureOutput genuinely reject wrong shapes, so
the phantom markers are carrying real type information rather than silently
widening.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 13:54:23 +05:30
Clintchiz e16903b286 feat(rpc): add defineService and the immutable procedure builder 2026-08-05 13:53:14 +05:30
ClintchizandClaude Opus 5 21ea8a84a0 fix(rpc): bound retryable status range and add malformed-response code
- isRetryableStatus now fails closed for out-of-range values (600+, negative,
  NaN) by bounding the 5xx check on both sides (>= 500 && <= 599), instead of
  an unbounded >= 500 that classified garbage statuses like 1000 as retryable.
- 408 Request Timeout is now retryable, matching the RPC_TRANSPORT doc
  comment (connection, timeout, 5xx) — a timeout surfaced as 408 is no longer
  treated differently from the same timeout surfaced as 504.
- Add RPC_MALFORMED: the callee answered, but not with a ServiceResult (HTML
  error page, truncated body, unexpected shape). Distinct from RPC_TRANSPORT
  since something DID respond; non-retryable via the existing retryableFor,
  no new branch needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 13:45:37 +05:30
ClintchizandClaude Opus 5 dd8354477d docs: close three retryability gaps in the Task 2 plan snippet
isRetryableStatus used an unbounded status >= 500, so a garbage status like
1000 landed in the retryable bucket. This function is the sole gate the
client and HTTP transport trust for retry safety, and an out-of-range value
must fail closed. Bounded on both sides.

408 Request Timeout was non-retryable while the same file documented
transport as covering "connection, timeout, 5xx" - a genuine timeout
surfaced as 408 was classified differently from the identical timeout
surfaced as 504. Now retryable.

There was no code for "the callee answered but not with a ServiceResult" - a
proxy's HTML error page, a truncated body. Task 8 was already papering over
it by hand-setting retryable: false beside a transport code that
retryableFor says is always retryable, which is exactly how the two drift
apart. Added RPC_MALFORMED and made that path use failure() so retryability
is derived from the code rather than written next to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 13:43:39 +05:30
ClintchizandClaude Opus 5 796b19d923 feat(rpc): add service errors and retryability classification
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 13:37:52 +05:30
Clintchiz 3e1d7db537 fix(rpc): resolve lint warnings from review follow-up
- Drop the redundant eslint-disable on AnyProcedures; no-explicit-any
  is off repo-wide so the directive itself was the warning. Doc
  comment now explains why none is needed.
- Rename test's schema binding to _schema per the lint config's
  underscore-prefix rule for read-only-as-type bindings.
2026-08-05 09:59:23 +05:30
ClintchizandClaude Opus 5 a4c7d7b298 docs: drop a redundant eslint directive and note a Bun test quirk
no-explicit-any is off repo-wide in eslint.config.js, so the disable comment
the plan mandated is itself an unused-directive warning. The test's schema
binding also needs the _ prefix the lint config requires for a value read
only via typeof.

Separately: bun test strips type-only imports before resolution, so the
red-first step does not reproduce for type-only tests. Recorded so later
implementers do not chase it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 09:54:08 +05:30
Clintchiz e1fca3eddf feat(rpc): scaffold the package and shared contract types 2026-08-05 09:52:48 +05:30
ClintchizandClaude Opus 5 63e6148cdb docs: implementation plan for the inter-app communication system
Eleven TDD tasks covering phase 1: contract and immutable procedure builder,
error classification, the signed subject-context token, the Transport seam
with an in-process transport for tests, implement() with fail-closed identity
and permission checks, the typed client proxy, the HTTP transport, router
discovery of app/services, and the mounted endpoint with its two independent
external-access guards.

Phases 2-4 (retry and circuit breaking, app-to-app streaming, identity for
pubsub and queue) are documented as deferred with the reason each needs its
own design pass.

Task 10 is called out as the highest-risk: if /__wrnexus/rpc/* is reachable
from the public internet, every permission check in the workspace is
bypassable, so the plan requires the gateway block and the app-side check to
be verified as working independently of each other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 09:37:39 +05:30
ClintchizandClaude Opus 5 87d33a2ce1 docs: design for the inter-app communication system
Typed request/response between workspace apps over HTTP, behind a Transport
seam so gRPC stays additive rather than a rewrite. Contracts live in the
workspace's shared package and are imported by both sides, so types flow
through a normal import with no code generator.

Consumes the exportSubjectContext/importSubjectContext seam the permissions
system reserved, with one improvement on what that seam implied: the token
carries sub and tenant only, never roles. Every app shares the
PermissionStore, so the callee resolves roles itself - a stale or forged
roles claim becomes impossible by construction and there is no path to
injecting privileges through a claim. The token authenticates; it never
authorizes.

Records two properties that are easy to get wrong and expensive to discover:
/__wrnexus/rpc/* must be unreachable from the public internet, blocked at the
gateway AND verified at the app, or every permission check in the workspace
is bypassable; and only procedures explicitly marked idempotent may be
retried, because retrying a slow createInvoice is how a customer gets billed
twice.

Deliberately does not wrap pubsub or queue - they work, and an abstraction
over working code leaks and needs keeping in sync. They gain identity
propagation instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 09:27:43 +05:30
ClintchizandClaude Opus 5 2ab7b1e762 chore: sync the lockfile with the authz workspace dependencies
packages/authz gained @wrnexus/core and @wrnexus/db, and
examples/auth-showcase gained @wrnexus/authz, but no install ran afterwards
so bun.lock never recorded them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 08:41:22 +05:30
ClintchizandClaude Opus 5 2e93124080 merge: 0.8.4 security audit fixes and the permissions system
Two bodies of work, both reviewed before merge.

SECURITY AUDIT of 0.8.4. The repo's own gates were already green, so every
finding came from manual review and each was reproduced before being claimed:
safeFetch re-attached credentials after a cross-origin redirect; its
private-network guard was advisory only and defeated by DNS rebinding; three
IPv6 forms bypassed the private-address check; sanitizeUrl returned
protocol-relative input verbatim (open redirect); the gateway threw on
malformed Basic credentials, truncated passwords at the first colon, and
leaked password length by timing; RBAC namespace wildcards matched only the
first segment; the brace-expansion override was pinned to the exact
vulnerable version.

PERMISSIONS SYSTEM in @wrnexus/authz. Declaration catalog discovered from
app/authz, a pluggable PermissionStore with memory and sqlite adapters held
to one 24-test conformance suite, a resolution engine with deny-wins
precedence and fail-closed error handling, request middleware, an audit sink,
type codegen, a wrnexus authz CLI, and dev/prod boot wiring.

Behaviour changes needing release notes: authorizeDecision's 403 body no
longer carries reason or policy (opt back in with exposeReason); RBAC
wildcards now match at every depth, which widens access for anyone relying on
the old behaviour; Router gained a required authz field; subject.id must be a
non-empty string. See docs/plans/2026-08-05-authz-follow-ups.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 08:37:55 +05:30
ClintchizandClaude Opus 5 2c339bee15 docs: record the adjudicated non-blocking authz findings
Findings from the task and whole-branch reviews that were ruled non-blocking,
plus the behaviour changes that need release notes. None is an authorization
bypass. Recorded in the repo because the review workspace is scratch and git
history does not carry the reasoning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 02:20:39 +05:30
ClintchizandClaude Opus 5 41b6e2ed2b fix(authz): freeze catalog values after boot; correct compile-time-check claims
frozenMap only blocked the Map's own mutators, so
catalog.roles.get("editor").push("*") escalated a role to a full wildcard
past an error string claiming the catalog is frozen after boot; the same
applied to permission/attribute metadata objects and binding arrays.
mergeCatalogs now stores frozen copies of each, so the original declaring
module's objects are never mutated either.

Also corrects two docstrings (codegen.ts, the design doc) that claimed
`wrnexus authz generate`'s output makes a permission typo a type error —
can(), guardPermission(), and decideFor() all take a bare string and nothing
consumes the generated union automatically. Documents what it actually is:
a Permission/Role union to type your own helpers/constants against. Also
adds a README note on the subject.id contract (must be a non-empty string;
owner() compares with Object.is).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 02:10:24 +05:30
ClintchizandClaude Opus 5 3867e7c183 fix(authz): audit getResource denials; fail closed on a malformed denies shape
guardPermission's getResource catch returned 403 directly, never reaching
decideFor -> decide -> finish, so the audit sink never saw it — an attacker
probing ids that make the resource loader throw got a clean 403 stream
invisible to the audit trail. The audit sink is now stashed on the
per-request RequestAuthz object (authzMiddleware already receives it via
AuthzResolverOptions), and the catch records an "allowed: false" event with
an opaque reason before returning the 403.

Also: the explicit-deny check sat outside decide()'s try/catch, and
deniedBy() guarded on denies.length rather than Array.isArray(denies). A
store returning denies as a bare string let new Set(denies) iterate
characters instead of the permission, so the deny matched nothing and was
silently discarded; a store omitting denies entirely threw straight out of
decide(). Both are now validated and handled inside the try, denying via the
same "Authorization store unavailable" path as any other store failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 02:10:11 +05:30
ClintchizandClaude Opus 5 a7255fa1bd fix(dev-server): don't clobber a caller-set authz catalog; drop dead RuntimeDeps.authz
createProductionHandlers called setAuthzCatalog unconditionally, so a caller
using client.ts's documented escape hatch (setAuthzCatalog(catalog) before
importing anything that reads it) had that catalog silently wiped to empty
whenever opts.authz was omitted. Now only sets when opts.authz has entries to
contribute, or when nothing has been set yet; a non-empty opts.authz still
always sets and still throws on a genuine conflict.

Also removes RuntimeDeps.authz: nothing read it, and its doc comment
described a consumer that doesn't exist. The real wiring is
getAuthzCatalog()/setAuthzCatalog(), including the HMR hot-update path, which
is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 02:10:00 +05:30
Clintchiz fd5e2b7128 test(authz): end-to-end integration coverage, worked example, and docs
Task 15 of the authz permissions plan: proves db store + cache + catalog +
middleware + audit compose correctly, wires a real (non-dangling) example
into auth-showcase, and documents the declaration/registration/precedence
surface in the package README.
2026-08-05 01:22:13 +05:30
Clintchiz 57097c8204 fix(authz): fix prod boot-order (C1), dev HMR staleness (I2), add prod coverage (I4)
Fix round 2 for Task 14, addressing a critical review finding reproduced on
a real built server.

C1 (critical): the generated production entry set the authz catalog inside
createProductionServer's BODY, but ES modules evaluate every static import
(including app middleware, emitted as a static import) before the importing
module's body runs. Middleware reading getAuthzCatalog() at module scope —
the same eager shape authzMiddleware({ catalog, ... }) itself requires, and
the pattern app/middleware/logger.ts's `export default requestLogger({...})`
already uses — saw an unset catalog and crashed the whole process at import
time, after every other gate (typecheck/lint/tests/a plain `bun run build`)
stayed green.

Fix: packages/cli/src/build.ts now emits a small side-effecting
`.authz-setup.ts` module containing the static imports of every
app/authz/*.ts declaration plus a call to the new
applyAuthzManifestEarly(entries) (packages/dev-server/src/prod.ts), and
imports THAT MODULE FIRST in the generated entry — before pages, api,
realtime, middleware, components, and layouts. applyAuthzManifestEarly is
deliberately silent (no missing-default-export warnings, though a genuine
conflict still throws and fails the boot at import time); createProductionHandlers
keeps its own unconditional merge+set as an idempotent, always-warning second
pass, so an adapter that bypasses the generated entry and calls it directly
still gets a correctly merged, validated catalog, and so the function stays
independently testable.

I3: corrected packages/authz/src/client.ts's WRN-AUTHZ-SETUP message, which
claimed prod always sets the catalog before middleware runs — true again for
the generated entry after the C1 fix, but not for a custom entry that calls
createProductionHandlers directly.

I2: dev HMR editing app/authz/*.ts reloaded the page while the OLD catalog
stayed authoritative (watch.ts classifies any non-CSS change as "server";
hotUpdate had no authz/ branch) — a false security signal, since tightening
or removing a permission looked like it took effect but didn't until a
restart. Added the branch (packages/dev-server/src/index.ts), and gave
loadAppAuthzCatalog (authz-boot.ts) an injectable importer: a raw import()
would have silently no-op'd on the re-import (Bun caches local TS/JS modules
by filesystem path and ignores query strings), so the hot path routes through
loadModule (pipeline.ts) instead, which copies the edited file to a versioned
sibling specifically to defeat that cache.

I4: added direct createProductionHandlers/applyAuthzManifestEarly tests
(packages/dev-server/test/authz-prod.test.ts: conflict throws naming both
files, missing default export warns and skips, empty array yields an empty
catalog, a second call re-validates rather than trusting a stale singleton)
and the regression test that matters most
(packages/cli/test/authz-prod-coldstart.test.ts): a real `runBuild` + a real
`bun dist/server.js` boot, with a middleware module reading
getAuthzCatalog() at module scope, asserting it actually serves a request.

M5: startServer built its own router once, then loadAppAuthzCatalog built a
second one from scratch on every dev boot and every authz/ hot reload.
loadAppAuthzCatalog now accepts either an appDir (still used standalone, e.g.
by the test suite) or an already-built Router, and both call sites in
index.ts now pass the router they already have.

Every fix in this round was verified non-vacuous by sabotaging it and
confirming the corresponding test fails, then reverting.
2026-08-04 23:16:41 +05:30
Clintchiz 226217ecbf feat(authz): reach the merged catalog from boot via a process-wide singleton
Fix round 1 for Task 14 — closes the gap flagged in the last report:
loadAppAuthzCatalog existed but nothing called it.

- packages/authz/src/client.ts (new): setAuthzCatalog/getAuthzCatalog/
  hasAuthzCatalog, mirroring @wrnexus/db's client.ts. App middleware runs
  at module-eval time and needs the catalog then, so ctx cannot carry it;
  getAuthzCatalog() throws a setup error naming the fix, like getDb() does.
  Exported from packages/authz/src/index.ts.
- packages/dev-server/src/index.ts: startServer calls loadAppAuthzCatalog +
  setAuthzCatalog before middleware is resolved (schemasJs precedent),
  and populates the new RuntimeDeps.authz field.
- packages/dev-server/src/runtime.ts: RuntimeDeps gains authz?: AuthzCatalog.
- packages/cli/src/build.ts: emits static imports of each app/authz/*.ts
  file into the generated entry (components/layouts precedent) and passes
  { source, module } pairs through ProdOptions.authz — the catalog holds
  policy functions, so it cannot be JSON-baked like schemasJs.
- packages/dev-server/src/prod.ts: createProductionHandlers merges those
  declarations and calls setAuthzCatalog before the server accepts
  traffic, so a conflict fails the boot instead of surfacing on the first
  request. Runs for every deployment adapter, not only Bun.serve.

The framework never installs authzMiddleware itself; the app still
registers it with its own store.

Verified end-to-end: added a temporary app/authz declaration to
examples/basic-app, ran `bun run build`, inspected the generated entry's
static import + authz array, and booted dist/server.js to confirm the
merge/setAuthzCatalog call succeeds against real bundled code (reverted
before commit).
2026-08-04 22:40:42 +05:30
Clintchiz daea59cf5d feat(dev-server): load the authz catalog at boot
Adds loadAppAuthzCatalog(appDir) to @wrnexus/dev-server: discovers
app/authz/*.ts declarations via buildRouter, imports and merges them
into an AuthzCatalog, returning an empty catalog when the app has no
declarations. A declaration with no default export is skipped with a
warning; a genuine conflict between two declarations throws
WRN-AUTHZ-CONFLICT naming both source files.

Declared the missing @wrnexus/authz workspace dependency in
dev-server's package.json.
2026-08-04 22:03:28 +05:30
Clintchiz bc5437063d fix(cli): declare @wrnexus/authz dependency, exit cleanly on bad authz input
Round-1 review fixes for Task 13:

- packages/cli/package.json was missing @wrnexus/authz, and
  packages/authz/package.json was missing @wrnexus/core despite importing
  its types in index.ts/middleware.ts/advanced.ts. Both only worked
  in-repo because bare "@wrnexus/*" specifiers resolve through the root
  tsconfig.json paths map; a standalone install of @wrnexus/cli or
  @wrnexus/authz would fail at runtime.
- authz.ts's unknown/missing-subcommand and bad --dialect paths now
  console.error + process.exit(1), matching db.ts's convention, instead
  of throwing — index.ts's top-level catch previously printed those as a
  raw stack trace. Added a subprocess-level test that spawns the real CLI
  and asserts stderr has the usage line with no stack frame.
- nextMigrationNumber now extracts the leading-digit run the same way
  db/migrate.ts's nextNumber does, instead of a fixed slice(0, 4) that
  would have undercounted once a migration number passed 9999.
2026-08-04 21:51:38 +05:30
Clintchiz b9098382b3 feat(cli): add wrnexus authz list/generate/init
Introspects the merged authz catalog, emits app/authz/permissions.gen.ts
type unions, and scaffolds the assignment-table migration. init validates
--dialect explicitly (unrecognised values reject rather than silently
falling back to sqlite) and joins authzMigrationSql's up/down statement
lists with terminators instead of interpolating the arrays.

Test scaffolding for dynamically-imported app/authz declarations must
live inside the repo tree (not os.tmpdir()) for the "@wrnexus/*" bare
specifier to resolve via tsconfig paths; .gitignore excludes the scratch
dirs this produces.
2026-08-04 21:34:56 +05:30
ClintchizandClaude Opus 5 e5d0654d2a docs: join the DDL statement lists in the Task 13 init command
authzMigrationSql was changed in Task 11 to return statement arrays rather
than one blob, but Task 13's init still interpolated them straight into the
migration file, which would comma-join two CREATE TABLE statements into one
unparseable line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 21:26:10 +05:30
ClintchizandClaude Opus 5 3ef353de83 docs: use JSON.stringify for codegen escaping in the Task 12 plan
The plan's union helper hand-rolled escaping for backslash and double quote
only. Role names reach the emitter through the raw mergeCatalogs path, which
does not apply the registry's permission-id regex, so a value containing a
newline was emitted verbatim and the generated file failed to compile with
TS1002 Unterminated string literal.

Caught by the Task 12 implementer actually running tsc over the generated
output rather than eyeballing the string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 21:20:11 +05:30
Clintchiz 726b8a7d24 feat(authz): generate Permission and Role union types
Emits sorted TS unions from the merged catalog so a typo in
can(ctx, "post:wrtie") is a compile-time error. Uses JSON.stringify
for string-literal escaping (not manual backslash/quote replace) so
role names containing raw newlines still produce valid TypeScript;
role names are not regex-validated like permission ids, so this
matters for the raw mergeCatalogs path.
2026-08-04 21:19:04 +05:30
ClintchizandClaude Opus 5 91e5c6e0c5 fix(authz): guard scopeKey's tenantId type, add deterministic C1/C2 guard
N1: scopeKey guarded the empty-string VALUE but not the TYPE. A
non-string tenantId (null, 0, false, an object) flowed through
un-normalised, and the adapters disagreed about the result - db
rejects null on NOT NULL, memory accepts it as an unreachable row; 0
and false stringify differently and could collide. Now
`typeof tenantId !== "string" || tenantId === ""` is refused with the
same WRN-AUTHZ-SCOPE error. Added a conformance case covering
null/0/false/{}.

N2: nothing failed if grant() were re-wrapped in db.tx, reintroducing
the shared-connection rollback from C1/C2 - timing-based tests can't
reliably prove a transaction is never opened. Added
db-no-transaction.test.ts: a fake Db with a spied driver.transaction
and statement-recording all/exec, driving every PermissionStore method
and asserting zero transaction calls and no "BEGIN" in any recorded
statement. Verified it fails when grant() is temporarily re-wrapped in
db.tx, then restored.

Also documents two things in db.ts as comments only: the UNIQUE
constraints are now load-bearing for ON CONFLICT/ON DUPLICATE KEY
target inference, and MySQL's VALUES(effect) upsert syntax is
deprecated since 8.0.20 (no MySQL server in CI to catch its removal).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 21:06:11 +05:30
ClintchizandClaude Opus 5 2fbf059c00 docs: type-guard tenantId and add a no-transaction regression guard
Two gaps the Task 11 re-review left open.

scopeKey guarded the empty-string tenantId but not its type, so null, 0,
false or an object flowed through un-normalised and the adapters diverged -
the db rejects on NOT NULL while memory accepts an unreachable row. The whole
premise of the empty-string guard was a caller who controls the tenant id,
and that caller can just as easily hand over a null from a JSON body.

The vacuous concurrency test was removed for good reason, but that left
nothing failing if someone re-wraps grant() in db.tx and reintroduces the
shared-connection rollback. A spy over driver.transaction discriminates that
deterministically, with no timing dependency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 21:01:26 +05:30
ClintchizandClaude Opus 5 1cc0b97a72 fix(authz): replace vacuous concurrency test, validate effect in memory store
The fix-round-1 test "a concurrent write is not lost to another
method's failure" was vacuous: a single-process Promise.all cannot
reliably land a bare write inside another method's open transaction,
so it passed against both the fixed and the (previously) defective
grant() implementation. The shared-connection rollback hazard it was
meant to catch is real (confirmed separately by forcing the
transaction open before the write), but this specific test could
never reach that state and gave false assurance either way.

Replaced it with "a rejected write leaves unrelated state intact",
which asserts a grant() call with an invalid effect is refused without
disturbing the subject's existing roles/grants, plus a NOTE
documenting that the rollback hazard is now prevented structurally (no
transactions) rather than by a dedicated concurrency test.

memoryPermissionStore.grant() had no effect validation, so it failed
the new test; added a guard mirroring the db adapter's CHECK
constraint so both adapters agree on rejecting anything other than
"allow"/"deny".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:52:18 +05:30
ClintchizandClaude Opus 5 134c5fa4bc docs: replace a vacuous conformance test with an honest one
I added "a concurrent write is not lost to another method's failure" to the
conformance suite to guard the fail-open the Task 11 review demonstrated. The
implementer reported they could not make it fail against the reverted code,
across 600 stress iterations. They were right.

I reproduced the underlying defect directly - forcing the transaction to open
before the bare write gives "revoke resolved without error: true" with the
role still present - so the mechanism is real. But the test cannot reach it:
Promise.all in one process does not reliably land the bare write inside the
open transaction, and grant() never fails on its own. The test passed against
the defective implementation, which is exactly the false assurance this suite
exists to prevent.

Replaced with a property that is actually guaranteed and adapter-agnostic: a
rejected write leaves unrelated state intact. The rollback hazard itself is
prevented structurally, by the store using no transactions, and that is now
stated in a comment rather than pretended to be under test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:48:38 +05:30
ClintchizandClaude Opus 5 205f4e2d4c fix(authz): close fail-open db store defects from review round 1
C1/C2: grant() wrapped its delete+insert in db.tx on a shared,
unserialized sqlite connection, so a concurrent bare write from another
method (e.g. revokeRole) got swept into the open transaction and
discarded on rollback - a revoke could report success while the
privilege survived. Also broke concurrent grants on distinct keys
("cannot start a transaction within a transaction"). Replaced with
single-statement upserts (ON CONFLICT / ON DUPLICATE KEY UPDATE),
atomic without a transaction.

I1: assignRole's check-then-act SELECT lost 19/20 concurrent identical
calls to a UNIQUE violation; switched to ON CONFLICT DO NOTHING.

I2: an unrecognised `effect` value was dropped from both the grant and
deny buckets on read. Added a CHECK constraint and made anything not
literally "allow" count as a deny (fail closed).

I3: ensureAuthzTables defaulted to sqlite instead of the Db's own
dialect. I4: scopeKey now refuses an explicitly empty tenantId rather
than treating it as global (shared with the memory adapter). I5: added
migrations.test.ts asserting the generated DDL per dialect, including
MySQL's binary collation on identity columns. M1: DDL is now a
statement list instead of a blob split on a formatting-dependent
separator. M3: declared @wrnexus/db as a workspace dependency.

Extends the conformance suite with four concurrency/empty-scope tests
(23 total, up from 19) that all three adapters now pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:46:48 +05:30
ClintchizandClaude Opus 5 f19462dff0 docs: fix fail-open concurrency and effect handling in the Task 11 plan
The database store review found two Critical defects and several Important
ones, all reachable in production.

grant() was wrapped in db.tx for atomicity. The sqlite driver runs a bare
BEGIN on one shared connection with no serialization, so an open transaction
swallows any concurrent write from another method and discards it on
rollback. Demonstrated: revokeRole resolved with no error while the role
survived - a security-critical revoke reporting success with the privilege
retained. Concurrent grants also rejected outright with "cannot start a
transaction within a transaction". Replaced with single-statement upserts,
which are atomic without a transaction; assignRole likewise drops its
check-then-act SELECT for ON CONFLICT DO NOTHING, which was rejecting 19 of
20 concurrent identical calls.

effect had no CHECK constraint and assignmentsFor classified by exact
equality, so a mis-cased or corrupted value was dropped from BOTH buckets -
a deny row that silently stopped denying. Added the constraint and made
anything that is not literally "allow" count as a deny.

scopeKey now refuses an explicitly empty tenantId rather than treating it as
global, which otherwise let a caller who controls the tenant id read and
write global assignments.

Also: ensureAuthzTables takes the dialect from db.driver.dialect instead of
defaulting to sqlite; the DDL is a list of statements rather than a blob
split on a formatting-dependent separator; MySQL identity columns get a
binary collation so tenant "T1" cannot match "t1"; and postgres placeholders
are numbered.

Adds four conformance tests for the concurrency and empty-scope cases. The
suite was entirely sequential and structurally could not catch any of this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:33:45 +05:30
Clintchiz 3fa3fce5df feat(authz): add database-backed PermissionStore
Adds dbPermissionStore/ensureAuthzTables/authzMigrationSql, backed by
_wrn_authz_assignment and _wrn_authz_grant tables, plus a ./db subpath
export. Passes the identical 19-test store-conformance suite the memory
adapter passes, including tenant-scope isolation.
2026-08-04 20:22:31 +05:30
ClintchizandClaude Opus 5 218f5e2dd6 chore: add .gitattributes enforcing LF
The repo had none, and core.autocrlf=true is the usual Git-on-Windows
setting, so a clone, checkout, or stash pop silently rewrites every text file
to CRLF. That fails format:check against prettier's endOfLine: lf - it
already turned the gate red once mid-branch, after a stash round-trip
reintroduced CRLF into files that had been committed clean.

Verified: no tracked file currently carries a CR byte at HEAD.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:17:39 +05:30
ClintchizandClaude Opus 5 e136fbc56a fix(router): quietly skip permissions.gen.{ts,js} in authz scan
Task 10 fix round 1: the coordinator's plan doc (41fb82b9) recorded that
generated authz type files should be skipped before the isSafeIslandName
check, but the code change never landed. isSafeIslandName rejects the dot
in the stripped basename "permissions.gen", so every app running Task 12's
codegen would warn on every boot.

Add a quiet skip for *.gen.ts / *.gen.js immediately after the extension
guard, before the name check. Add tests: a .gen.ts file is skipped without
a console.warn (spied), and a .gen.js file is skipped the same way while a
legitimately named .js declaration is still discovered.

Also corrects the scanDir extraExtensions doc comment, which incorrectly
implied app/schemas passes it too (only app/authz does).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:09:16 +05:30
ClintchizandClaude Opus 5 41fb82b9e9 docs: skip generated type files in the Task 10 authz scan
The brief asserted permissions.gen.ts would be discovered as an entry named
permissions.gen and filtered by a later task. It is not: isSafeIslandName
rejects the dot in the stripped basename, so it takes the warn-and-skip path
and would print a warning on every boot of any app that ran the codegen,
while Task 14's name-based filter for it was dead code.

The scan now skips *.gen.ts / *.gen.js quietly, before the name check. Also
records the extraExtensions argument the implementer added to scanDir, which
keeps .js out of the route-scanning allow-list where it would otherwise leak
into generated route URLs via fileToRoute.

Caught by the Task 10 implementer testing the claim rather than trusting it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 19:55:33 +05:30
Clintchiz e7743cdbb5 feat(router): discover app/authz declarations
Scan app/authz/<name>.{ts,js} the same way app/schemas is scanned,
exposing Router.authz: ComponentRef[]. Also update the two other
literal Router construction sites (prod runtime, dev-server test
fixture) that now need the new required field.

scanDir gains an optional extraExtensions parameter (default []) so
the authz scan can accept .js files without widening the extension
allow-list used by route scanning (app/pages, app/api, app/realtime),
which would otherwise leak .js into generated route URLs via
fileToRoute.
2026-08-04 19:52:37 +05:30
Clintchiz 703baa1ead fix(authz): strengthen permissionMatches warning, complete export coverage
Move the "don't gate on permissionsFor() with permissionMatches" warning
onto permissionMatches itself so it's visible via autocomplete, not just
on AuthzResolver.permissionsFor. Round out exports.test.ts to cover
scopeKey, safeRecord, and AUTHZ_LOCALS_KEY, closing the gap where
dropping either export from index.ts would not fail the test.
2026-08-04 19:38:07 +05:30
ClintchizandClaude Opus 5 e05ddc7aa5 docs: warn against the permissionMatches + permissionsFor composition
permissionsFor carries a caveat that its Set cannot represent a narrow deny
under a broad grant, so callers must gate with decide(). Now that
permissionMatches is also public, the wrong composition is directly reachable
and looks idiomatic - and the warning lived only on the other half of it.
Adds the pointer to permissionMatches, and covers scopeKey and safeRecord in
the exports test, which the brief omitted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 19:36:24 +05:30
Clintchiz 6f3a53b9ff feat(authz): export registry, store, engine, and middleware surface
Appends the Task 1-8 modules (defineAuthz, catalog merge helpers,
permission stores, audit sinks, resolver engine, and authzMiddleware/
can/guards) to the public @wrnexus/authz surface, and regenerates the
public-api-0.8.json baseline to match.
2026-08-04 19:29:08 +05:30
ClintchizandClaude Opus 5 13859ce7dc docs: add deniedBy to the Task 9 export list
deniedBy was introduced in Task 6's fix round to make wildcard denies work,
but the plan's export block and its exports test were never updated, so
Task 9 would have shipped it module-private.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 19:25:40 +05:30
Clintchiz e15422ed8d fix(authz): stop authorizeDecision leaking policy names in 403 bodies 2026-08-04 19:17:48 +05:30
Clintchiz 3f1fcd0d2d fix(authz): stop encodeURI from double-encoding a percent-escaped redirectTo
Fix round 3 for Task 7 (N5, minor-to-important): fix round 2's
encodeURI(options.redirectTo) fixed the non-ASCII crash but broke the
most common real use of redirectTo -- a return-path query param that's
already percent-encoded (e.g. /login?next=%2Fdash) -- because encodeURI
also escapes "%", double-encoding it to %252Fdash. Replaced with
headerSafePath(), a codepoint loop that encodes only codepoints above
0x7f (matching isLocalPath's style: no regex, no source escapes) and
leaves "%" alone.

Added tests: an already-percent-encoded target round-trips unchanged;
a non-ASCII target still 303s without throwing and the location is
ASCII-only; a plain ASCII target passes through byte-identical.
2026-08-04 19:10:09 +05:30
ClintchizandClaude Opus 5 798f56734a docs: stop double-encoding redirectTo in the Task 7 plan snippet
The previous fix used encodeURI to keep a non-ASCII redirect target from
throwing inside new Response. But encodeURI also escapes "%", so an
already-percent-encoded target is corrupted: /login?next=%2Fdash becomes
/login?next=%252Fdash, which single-decodes to the literal "%2Fdash" rather
than the intended path. That is the most common real use of redirectTo -
"send them to login, then bounce back".

Replaced with headerSafePath, a codepoint loop that encodes only what cannot
be sent in a Latin-1 header and leaves existing escapes and reserved ASCII
untouched. My prescription, my defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 19:07:15 +05:30
Clintchiz 77b9e49bf2 fix(authz): fold subject into the memo key, fix symbol/-0 and redirect issues
Fix round 2 for Task 7 (plan amendment 9e3624e5):

- N1 (Important): the memo key carried scope and permission but not the
  subject, so a request that reassigns ctx.user mid-flight (impersonation,
  step-up auth, session revocation, or an authz-before-auth middleware
  ordering mistake) could be served the previous principal's cached
  verdict. subjectId (typeof + String, matching the existing scope/value
  encoding style) is now folded into every memo key.
- N2 (Minor): the primitive-value memo key used String(resource), which
  collapses distinct Symbol("row") values into one slot and maps -0 onto
  0's slot. Added a dedicated bySymbol identity memo (WeakMap-style, but a
  plain Map since symbols aren't valid WeakMap keys pre-registry symbols
  and the memo is request-scoped anyway) and special-cased Object.is(x,-0)
  to render as "-0".
- N3 (Minor): the rejected-redirect console.error interpolated
  redirectTo directly, exactly the value most likely to carry CR/LF in
  that branch. Switched to JSON.stringify(redirectTo) for the log line.
- N4 (Minor): a non-ASCII (but otherwise valid, local) redirectTo passed
  isLocalPath and then threw inside `new Response` building the Location
  header. Wrapped it in encodeURI().

Added 5 regression tests: subject swap re-evaluates, clearing ctx.user
denies, two same-description symbols get separate verdicts, 0 vs -0 get
separate verdicts, non-ASCII redirectTo 303s with an encoded location
instead of throwing. N1 revert-checked: temporarily restored the
two-element (no-subject) key and confirmed both subject-swap tests fail
against it before restoring the fix.
2026-08-04 18:59:56 +05:30
ClintchizandClaude Opus 5 9e3624e584 docs: put the subject in the memo key in the Task 7 plan snippet
The re-review closed all six earlier findings but surfaced the same bug class
one level over: the memo key carried the scope but not the subject, so
reassigning ctx.user mid-request served the previous principal's verdict.
Demonstrated - u1 allowed, then ctx.user = u2 still returned true, and
clearing ctx.user entirely revoked nothing. Triggered by impersonation or
"view as user" middleware, step-up auth, session revocation mid-request, or
simply registering an auth middleware after authzMiddleware.

Also: symbols now memo by identity (String() collapsed two distinct symbols
sharing a description into one slot), -0 stays distinct from 0, the
rejected-redirect log no longer echoes CR/LF verbatim into the log stream,
and a non-ASCII redirect target is encodeURI'd rather than throwing out of
the Response constructor and 500ing on a denial path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 18:54:30 +05:30
Clintchiz b7f3507b59 fix(authz): close memo cross-authorization and guard hardening gaps
Fix round 1 for Task 7, addressing review findings against the brief's
own memoKey design (now superseded per plan amendment cc8085bc):

- C1: memoKey's String(id) + JSON.stringify-with-catch cross-authorized
  distinct resources whenever their ids stringified the same (numeric
  vs string ids, object-shaped ids) or whenever JSON.stringify threw
  (circular references, BigInt fields, throwing getters all shared one
  "<unserialisable>" bucket, so the first verdict computed for any of
  them became the cached verdict for all of them in that request).
- C2: filterCan inherited the same bypass, returning rows the subject
  could not act on.
- Replaced serialisation-based memoization with identity-based
  memoization: object resources are memoised in a WeakMap keyed by the
  resource reference itself (never serialised), primitives/absent
  resources in a Map keyed by [scope, permission, typeof, String(value)]
  so 7 and "7" can never collide.
- I1: scope is now read from ctx.tenant at decision time (currentScope),
  not captured once at middleware-install time, so a tenant switch
  mid-request is honoured on the next check.
- I2/M1: guardPermission's redirectTo now only fires for non-JSON/API
  requests (replicated wantsJson check, since authz may only import
  core as types) and only for a validated local path (isLocalPath),
  closing an open-redirect and a JSON-caller-follows-303 gap.
- I3: getResource is now wrapped in try/catch; a throw denies with the
  standard opaque 403 body instead of propagating the loader's error
  (e.g. a SQL string) to the client.
- Added cache-control: private, no-store to both the 303 and 403
  responses.

Added 11 regression tests. C1/C2 revert-checked: temporarily restored
the old memoKey design and confirmed the four collision tests fail
against it before restoring the fix.
2026-08-04 18:41:18 +05:30
ClintchizandClaude Opus 5 cc8085bcfa docs: fix memo-key cross-authorization in the Task 7 plan snippet
The middleware's per-request memo keyed resources by String(resource.id) with
an unserialisable fallback that shared one bucket. Six demonstrated cases
cross-authorized: {id:1} vs the primitive 1; {id:7} vs {id:"7"}; object ids;
and every circular / BigInt / throwing-getter row collapsing together so the
first verdict in a request became the verdict for all of them. filterCan
returned 3 of 3 rows where 1 was permitted - it leaked, rather than denied.

Object resources now memo by identity through a WeakMap; primitives key on
JSON-encoded [scope, permission, typeof, value] so 7 and "7" stay distinct
and a tenant id containing the separator cannot collide.

Scope is also read at decision time rather than frozen when the middleware
runs, and is part of the memo key, so switching tenant mid-request no longer
returns the previous tenant's verdict.

guardPermission additionally: denies instead of 500ing when getResource
throws (and no longer leaks the loader's message), skips redirectTo for API
requests using the same rule requireAuth applies, refuses a non-local
redirect target, and sets cache-control: private, no-store.

Adds eleven regression tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 18:35:05 +05:30
Clintchiz 984c6236d3 feat(authz): add request middleware, can(), and guardPermission
Installs a per-request authz resolver via authzMiddleware and exposes
can()/decideFor()/guardPermission()/filterCan() as free functions (not
Context members, so @wrnexus/core stays free of an authz dependency).
All four route through resolver.decide(), never permissionsFor(), so
resource-scoped policy denials can't be bypassed via the coarse
permission set. Per-request results are memoised keyed on (permission,
resource) to avoid re-hitting the store within a request without
leaking one resource's verdict onto another.
2026-08-04 18:24:40 +05:30
ClintchizandClaude Opus 5 cd82bec414 fix(authz): fix perf, doc, and fail-open gaps found in second review
Re-review of Task 6's fix round 1 (plan amendment d6a2d054) found three
items in that diff plus one adjacent pre-existing issue that C1 made
reachable:

- Important (perf): permissionsFor() rebuilt the deny Set on every
  entry in the granted set (O(grants x denies) allocations on a
  per-request path). Hoisted to build the Set once. Measured
  4000x4000: 665.92ms before, 3.90ms after.
- Important (contract accuracy): permissionsFor() only half-agrees
  with decide() — a narrow deny under a broad grant (e.g. role editor's
  "post:*" plus a deny on "post:delete") can't be represented in a flat
  Set, so the set still contains "post:*" while decide() correctly
  refuses "post:delete". Documented as NOT authoritative on the
  AuthzResolver interface, and pinned with a regression test asserting
  the divergence is deliberate.
- Minor: subject.id === "" was audited as subjectId: "" instead of
  omitted, so consoleAuditSink printed a blank subject= rather than
  subject=anonymous. Reused the same non-empty-string guard as the
  decide() path.
- Important (adjacent, advanced.ts): owner() compared subject[key] to
  resource[key] with Object.is without checking either side was
  present, so two absent ids (Object.is(undefined, undefined) ===
  true) satisfied ownership. Unreachable before this task, but C1 now
  runs bound policies for anonymous/empty subjects, putting this on a
  live path. Fixed to deny whenever either side is undefined or null.

Every fix's regression test was verified by reverting the fix and
confirming the test fails against the pre-fix code before restoring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 18:15:34 +05:30
ClintchizandClaude Opus 5 d6a2d05407 docs: hoist the deny set and document permissionsFor's limits
Two issues the Task 6 re-review raised against the fix diff.

permissionsFor rebuilt the deny Set inside its loop over granted entries,
making it O(grants x denies) allocations on a per-request path. Measured
632ms at 4000x4000, ~100% of it in repeated Set construction. Hoisted.

permissionsFor also only half-delivers on "the obvious composition agrees
with decide()". A narrow deny beneath a broad grant is not representable in
a Set of strings - the set keeps post:* while decide() correctly refuses
post:delete - so callers that match against the set would offer actions the
server rejects. Documented the limit on the interface and pointed callers at
decide()/can()/filterCan() for per-action gating.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 18:09:34 +05:30
ClintchizandClaude Opus 5 ae37c9b57a fix(authz): close fail-open engine gaps found in review
Coordinator review of Task 6's resolution engine (plan amendment
86b3dc1e) found two critical and four important defects, all inherited
from the brief's original engine snippet:

- C1: anonymous callers on a public permission returned allow before
  running bound policies, so the least-trusted caller got the weakest
  evaluation. Policies now run for anonymous subjects too.
- C2: the policy verdict check was a truthiness test (`!verdict.allowed`),
  so a policy returning `{allowed: "yes"}` granted access. Now requires
  `verdict?.allowed === true` exactly, and no longer spreads the raw
  verdict into the decision (which leaked arbitrary policy fields).
- I1: a binding naming a policy the catalog doesn't have was silently
  `continue`d, granting whatever the policy was meant to guard. Now
  denies with "Policy unavailable".
- I3: denies were checked by exact string equality, so a wildcard deny
  (e.g. "post:*") was accepted and silently did nothing. Denies now go
  through the same depth-aware wildcard matching as grants, via the new
  exported `deniedBy()`.
- I2: `permissionsFor` now subtracts denied entries so it agrees with
  `decide()` — needed for Task 7's UI gating to compose correctly.
- I4: non-string/empty `subject.id` (0, "", 123, {}) no longer silently
  falls back to anonymous; it denies with "Invalid subject". `subject:
  null` (no subject at all) remains genuinely anonymous.

Added six regression tests, each verified by reverting its fix and
confirming the test fails against the old code before restoring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 17:58:28 +05:30
ClintchizandClaude Opus 5 86b3dc1e6a docs: close two auth bypasses and four fail-open paths in the Task 6 engine snippet
The plan's engine had a genuine authorization bypass and several fail-open
branches. Task 7 builds can() on this, so the source of truth is fixed before
that lands.

CRITICAL - anonymous callers bypassed every bound policy on a public:true
permission: the anonymous branch returned allow before the policy loop. A
permission marked "public, but not when embargoed" was fully open to
unauthenticated traffic, and the least-trusted caller got the weakest
evaluation. Policies now run on the anonymous path too; public relaxes the
identity requirement, never the policy requirement.

CRITICAL - the policy verdict check was truthiness-based, not an identity
check, so a policy returning {allowed: "yes"} or {allowed: 1} granted access.
It now compares against true.

A binding naming a policy the catalog lacks was skipped, granting whatever
the policy guarded; it now denies. Falsy and non-string subject ids fell
through to the anonymous path - {id: 0} became anonymous and {id: 123} reached
the store as a lookup key; only a non-empty string now identifies a subject.

Two design forks, ruled by the human: denies honour wildcards, so denying
"post:*" blocks post:delete instead of being accepted and doing nothing; and
permissionsFor subtracts denies, so composing it with permissionMatches
agrees with decide() rather than silently losing deny precedence.

Adds deniedBy() and six regression tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 17:52:32 +05:30
ClintchizandClaude Opus 5 c499f136fd docs: fix self-contradictory audit test in the Task 6 plan snippet
The 'denials are audited' test assigned role editor, which holds post:*, so
decide(post:delete) was legitimately an ALLOW under the wildcard rule the
same task specifies. The test then asserted one audited denial and got zero.
Switched to moderator (post:comment:*), which genuinely lacks post:delete.

Caught by the Task 6 implementer running the transcribed test against the
transcribed implementation. Plan-origin defect, fixed under standing
authority.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 17:41:11 +05:30
Clintchiz 6d8b6daba9 feat(authz): add resolution engine with deny-wins precedence and fail-closed errors 2026-08-04 17:40:19 +05:30
Clintchiz d7509421c7 fix(authz): widen logSafe to strip NEL and Unicode line separators
U+0085 (NEL), U+2028 (LINE SEPARATOR), and U+2029 (PARAGRAPH SEPARATOR)
are treated as line terminators by some log shippers and by JS's own
lexical grammar (and are not escaped by JSON.stringify by default), so
they could still be used to forge audit log entries even after the
initial C0/DEL fix. logSafe now strips all five categories.
2026-08-04 17:29:46 +05:30
ClintchizandClaude Opus 5 d609a41222 docs: widen logSafe to Unicode line separators in the Task 5 plan snippet
The re-review confirmed the log-injection fix works for C0 and DEL, but
U+0085 (NEL) and U+2028/U+2029 pass through. Those are line terminators to
some log shippers and to JavaScript's own lexical grammar, so they can still
split a record downstream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 17:26:02 +05:30
Clintchiz e710756baf fix(authz): sanitize control characters in console audit sink
Prevents audit log injection: subjectId, tenantId, and reason trace back
to request input, so an unsanitized newline could forge a second,
fully-formed audit line indistinguishable from a real entry. Adds
logSafe() to strip control characters before interpolation and logs the
previously-missing policy field.
2026-08-04 17:21:09 +05:30
ClintchizandClaude Opus 5 ba83038d8d docs: fix audit-log injection in the Task 5 plan snippet
consoleAuditSink interpolated subjectId, tenantId and reason straight into
the log line. A newline in any of them forges a second entry that reads as a
genuine audit record - the reviewer produced a fake
'[wrnexus:authz] allow admin:everything subject=root' line. Those values
trace back to request input.

Interpolated fields now go through logSafe(), which replaces control
characters. Adds the missing coverage the review flagged: consoleAuditSink
injection, malformed-sink handling, and memoryAuditSink.clear().

Plan-origin defect, fixed under standing authority to amend the plan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 17:19:05 +05:30
Clintchiz f033197850 feat(authz): add pluggable authorization audit sink 2026-08-04 17:10:03 +05:30
Clintchiz dc0771308a fix(authz): eliminate cache-key collision in cachedPermissionStore
The scope-prefix concatenation cacheKey used a bare U+FFFD separator with
no escaping, so an adversarial subject/tenant id containing that character
could collide with a different subject/tenant pair and leak cached roles
across tenants. Switch to JSON.stringify([scopeKey, subjectId]) for an
unambiguous key.

Also replace the untested key.endsWith() substring sweep used to
invalidate a subject across all tenants on a global write with an
explicit bySubject index, and add test coverage for both the collision
and the cross-tenant invalidation sweep.
2026-08-04 17:04:19 +05:30
ClintchizandClaude Opus 5 83f2951035 docs: fix cache-key collision in the Task 4 plan snippet
The plan's cachedPermissionStore used scopeKey + U+FFFD + subjectId as a
cache key with no escaping, so ('a', 'b<sep>c') and ('a<sep>b', 'c') collide
and one subject is served another's permissions. Subject and tenant ids are
unconstrained strings, so nothing prevented it.

Key is now JSON-encoded, and the global-write sweep tracks keys per subject
instead of substring-matching. Adds the two regression tests that were
missing: cross-tenant invalidation on a global write, and key collision.

Ruled by the human as plan-mandated; source of truth amended so a re-run of
the plan does not reintroduce the defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 17:00:35 +05:30
Clintchiz 4362d49770 feat(authz): add caching decorator for PermissionStore 2026-08-04 16:47:50 +05:30
Clintchiz a01b7bc99e fix(authz): cover grant/deny scope isolation and revoke scope-isolation in conformance suite 2026-08-04 16:43:08 +05:30
ClintchizandClaude Opus 5 9b6b970cae chore: exclude the SDD scratch workspace from prettier
.superpowers/ holds git-ignored controller artifacts (briefs, reports,
review packages). Prettier still walked it, so format:check — and with it
check:production — failed on scratch markdown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 16:38:24 +05:30
Clintchiz 1849213ce4 feat(authz): add PermissionStore contract with memory adapter and conformance suite 2026-08-04 16:36:57 +05:30
Clintchiz d694dda320 feat(authz): merge declaration modules into a frozen catalog 2026-08-04 16:30:41 +05:30
Clintchiz 212fdaa5b5 feat(authz): add defineAuthz declaration registry 2026-08-04 16:26:17 +05:30
ClintchizandClaude Opus 5 0ac648bc26 docs: resolve two pre-flight conflicts in the authz plan
- Global Constraints said the change was additive while Task 8 changed
  authorizeDecision's 403 body. Ruled: the security fix governs; the
  constraint now names it as the one approved exception.
- Task 6 defined permissionsFor and then re-implemented it inline in
  decide. Both now call a single loadEffective helper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 16:24:07 +05:30
ClintchizandClaude Opus 5 10da210b0a docs: implementation plan for the authz permissions system
Fifteen TDD tasks covering phases 1-3 of the approved design: registry,
catalog merge, PermissionStore with a shared conformance suite, caching
decorator, audit sink, resolution engine, request middleware and guards,
router discovery, database adapter, codegen, and the wrnexus authz CLI.

Phases 4 (.wrn view can()) and 5 (admin UI) are documented as deferred with
the reason each needs its own design pass.

Also folds in the authorizeDecision disclosure fix as Task 8, since the new
guards share its 403 shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 16:10:37 +05:30
ClintchizandClaude Opus 5 b209936f86 docs: design for the authz permissions system
Separates declaration (what permissions, roles, policies and attributes exist)
from assignment (who holds what), building on the decision primitives already
in advanced.ts rather than replacing them.

Covers the registry and app/authz discovery, the PermissionStore interface
with memory and db adapters, tenant-scoped assignments meeting the existing
TenantMembership, deny-wins precedence, fail-closed behaviour, the audit sink,
codegen and CLI introspection, and the seam for propagating subject context to
the inter-app communication system.

Records two decisions worth keeping: cross-app sharing needs no runtime
catalog distribution (declarations are static code in the shared package;
only assignments are shared, via the database), and can() stays off Context
to avoid a core -> authz dependency cycle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 15:57:22 +05:30
ClintchizandClaude Opus 5 c64434a131 fix(security): close SSRF, credential-leak, and auth bypass findings in 0.8.4
Audit of 0.8.4 found the repo's own gates green, so these came from manual
review; each is covered by a new regression test.

security/fetch.ts
- safeFetch re-attached Authorization/Cookie on a same-origin redirect that
  followed a cross-origin hop (a -> b -> b), handing credentials to the second
  host. Compare against the origin the caller trusted, not the previous hop.
- The private-network guard resolved the host, approved it, then let fetch
  resolve again, so a low-TTL record could answer public for the check and
  private for the connection. Pin the connection to the validated address,
  preserving Host and TLS serverName. Opt out with pinDns: false.
- 0:0:0:0:0:ffff:127.0.0.1, ::ffff:7f00:1 and fec0::1 were not treated as
  private. Add uncompressed IPv4-mapped forms, site-local IPv6, 198.18/15
  and 192.0.0/24.

security/url.ts
- sanitizeUrl returned "//evil.com" verbatim via the relative-path fast path,
  bypassing the host checks it had just run; in an href that navigates
  cross-origin. Resolve protocol-relative input instead.

dev-server/gateway.ts
- Malformed base64 in an Authorization header threw out of checkAuth on an
  unauthenticated path. Fail closed.
- split(":", 2) truncated passwords at the first colon, so a password
  containing ":" could never authenticate.
- The credential compare short-circuited on length mismatch, leaking length
  by timing. Extracted as verifyBasicAuth so it is testable.

authz/index.ts
- Namespace wildcards only matched the first segment, so "post:comment:*"
  did not grant "post:comment:delete". Match at every depth.

uploader/operations.ts
- Validate transcoder dimensions and bitrate rather than trusting the declared
  type, and reject ".." path segments.

package.json
- The brace-expansion override pinned 5.0.8, which is inside the advisory
  range >=4.0.0 <5.0.9. Bump to 5.0.9; bun audit is now clean.

Verified: check:production passes (typecheck, lint, 1033 tests, format,
ASVS, public-API baseline, editor checks).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 15:57:22 +05:30
Clintchiz 72e4d3eceb release: WRNexusJS 0.8.0
Quality / quality (ubuntu-latest) (push) Failing after 12m19s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-04 12:19:09 +05:30
Clintchiz 4cebacadfe release: WRNexusJS 0.8.3
Quality / quality (ubuntu-latest) (push) Failing after 12m9s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-03 19:47:30 +05:30
Clintchiz e8f630f12d fix: format generated docs before release verification
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-03 02:32:11 +05:30
Clintchiz 4550a11460 release: WRNexusJS 0.8.2
Quality / quality (ubuntu-latest) (push) Failing after 22s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-03 02:14:54 +05:30
Clintchiz 3c6b659f36 release: WRNexusJS 0.8.1
Quality / quality (ubuntu-latest) (push) Failing after 13m28s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-03 01:34:56 +05:30
Clintchiz 1a1d2e9d08 perf: accelerate production request hot paths
Quality / quality (ubuntu-latest) (push) Failing after 12m23s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-03 01:08:43 +05:30
Clintchiz fed1d5d3f4 perf: omit unused UI CSS and minify final bundles
Quality / quality (ubuntu-latest) (push) Failing after 22s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-03 01:03:33 +05:30
Clintchiz b61020babd fix: update PWA workers without reloading pages
Quality / quality (ubuntu-latest) (push) Failing after 12m52s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-03 00:54:29 +05:30
Clintchiz 379f80cbd0 fix: force PWA worker updates
Quality / quality (ubuntu-latest) (push) Failing after 12m26s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-03 00:46:52 +05:30
Clintchiz 649e3d9127 fix: constrain PWA caching and extend font CSP
Quality / quality (ubuntu-latest) (push) Failing after 22s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-03 00:38:23 +05:30
Clintchiz b3e9b99e13 fix: harden generated identifiers and types
Quality / quality (ubuntu-latest) (push) Failing after 12m50s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-03 00:02:37 +05:30
Clintchiz 586a6db8ff release: WRNexusJS 0.8.0
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-02 23:18:51 +05:30
1150 changed files with 309965 additions and 44337 deletions
+29
View File
@@ -0,0 +1,29 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "basic-app",
"runtimeExecutable": "bun",
"runtimeArgs": [
"run",
"packages/cli/src/index.ts",
"dev",
"examples/basic-app",
"--port=3520"
],
"port": 3520
},
{
"name": "component-showcase",
"runtimeExecutable": "bun",
"runtimeArgs": [
"run",
"packages/cli/src/index.ts",
"dev",
"examples/component-showcase",
"--port=3400"
],
"port": 3400
}
]
}
+16
View File
@@ -0,0 +1,16 @@
# Enforce LF in the working tree regardless of a contributor's core.autocrlf.
# Without this, Git on Windows smudges every text file to CRLF on clone, stash
# pop, or checkout, which fails `bun run format:check` (prettier endOfLine: lf).
* text=auto eol=lf
# Binary assets Git must not touch.
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.ico binary
*.pdf binary
*.woff binary
*.woff2 binary
*.db binary
+50
View File
@@ -0,0 +1,50 @@
name: Quality
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
quality:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
cache-dependency-path: editors/vscode/package-lock.json
- name: Install framework dependencies
run: bun install --frozen-lockfile
- name: Install editor dependencies
run: npm ci --prefix editors/vscode
- name: Typecheck
run: bun run typecheck
- name: Lint
run: bun run lint
- name: Formatting
run: bun run format:check
- name: Package and application tests
run: bun run test:all
- name: Package contracts
run: bun run check:public-api && bun run check:ui-visual && bun run audit:packages && bun run test:package-kits && bun run validate:staging
- name: Stage and test publishable packages
if: matrix.os == 'ubuntu-latest'
run: bun run stage:packages && bun run test:staged-consumers
- name: Framework validation
run: bun run validate:0.8
- name: Dependency audit
run: bun audit
- name: Editor dependency audit
run: npm audit --prefix editors/vscode --audit-level=high
+6
View File
@@ -1,6 +1,7 @@
node_modules/ node_modules/
dist/ dist/
.wrnexus/ .wrnexus/
.wrnexus-*/
*.log *.log
*.db *.db
*.db-shm *.db-shm
@@ -19,3 +20,8 @@ bun.lockb
# Local focused typecheck helpers must never enter the repository. # Local focused typecheck helpers must never enter the repository.
focus-shims.d.ts focus-shims.d.ts
tsconfig.focus.json tsconfig.focus.json
# Scratch dirs for tests that must dynamically import scaffolded files using
# "@wrnexus/*" bare specifiers (resolved via the root tsconfig.json `paths`,
# which requires the scaffold to live inside the repo tree).
**/test/.tmp-*/
+3
View File
@@ -0,0 +1,3 @@
@wrnexus:registry=https://registry.npmjs.org/
audit=true
fund=false
+10
View File
@@ -1,6 +1,8 @@
node_modules/ node_modules/
dist/ dist/
**/dist/ **/dist/
**/.wrnexus/**
**/.wrnexus-*/**
.publish/ .publish/
**/.wirefw/ **/.wirefw/
coverage/ coverage/
@@ -10,9 +12,14 @@ bun.lockb
# Generated code (queries.gen.ts, routes.gen.ts, etc.) # Generated code (queries.gen.ts, routes.gen.ts, etc.)
**/*.gen.ts **/*.gen.ts
**/*.generated.d.ts
# Bundled .wire compiler for the VS Code extension (generated) # Bundled .wire compiler for the VS Code extension (generated)
editors/vscode/src/compiler.cjs editors/vscode/src/compiler.cjs
editors/vscode/src/language-server.cjs
editors/vscode/src/extension.bundle.cjs
docs/public-api-0.8.json
docs/ui-visual-contract-0.8.json
*.svg *.svg
**/.vscodeignore **/.vscodeignore
@@ -21,3 +28,6 @@ focus-shims.d.ts
**/focus-shims.d.ts **/focus-shims.d.ts
tsconfig.focus.json tsconfig.focus.json
**/tsconfig.focus.json **/tsconfig.focus.json
# SDD scratch workspace (git-ignored controller artifacts)
.superpowers/
+32
View File
@@ -1,5 +1,9 @@
# @wrnexus/ai # @wrnexus/ai
Provider-neutral AI orchestration for OpenAI, Anthropic, Google and local OpenAI-compatible models,
with streaming, structured output, tools, embeddings, vector search/RAG, conversation persistence,
templates, guardrails, usage events, fallback, rate limits and evaluation reports.
> A tiny, zero-dependency Claude (Anthropic) client for WrNexus apps — generate and stream text with Claude from any server-side code. > A tiny, zero-dependency Claude (Anthropic) client for WrNexus apps — generate and stream text with Claude from any server-side code.
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework. Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
@@ -120,6 +124,34 @@ try {
} }
``` ```
### Multi-provider client
`createAIClient` adds named-provider selection and fallback, capability discovery,
validated JSON output, validated tool execution, abort-aware exponential retries,
and per-provider circuit breakers. Attempt events intentionally contain metadata
only: prompts, credentials, and raw model responses are never passed to telemetry.
```ts
import { anthropicProvider, createAIClient } from "@wrnexus/ai";
const ai = createAIClient({
providers: [anthropicProvider()],
retry: { attempts: 3, baseDelayMs: 100, maxDelayMs: 2_000 },
circuitBreaker: { failureThreshold: 5, resetAfterMs: 30_000 },
});
const result = await ai.generateObject<{ title: string }>("Return a JSON title", {
validate: (value): value is { title: string } =>
typeof value === "object" && value !== null && "title" in value,
});
```
Providers can return normalized `usage` (`inputTokens`, `outputTokens`,
`totalTokens`, and `costUsd`) and `toolCalls`. Use `executeTools` with a named,
validated tool registry; unknown tools and invalid arguments are rejected before
application code runs. `deterministicAIProvider` supplies ordered or computed
offline responses for tests and examples without API keys or network calls.
## Usage ## Usage
### Return generated JSON from an API route ### Return generated JSON from an API route
+23 -2
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/ai", "name": "@wrnexus/ai",
"version": "0.7.0", "version": "0.8.6",
"type": "module", "type": "module",
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.", "description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/ai"
},
"homepage": "https://wrnexusjs.dev/packages/ai",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"ai"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -18,9 +34,14 @@
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
},
"./platform": {
"types": "./dist/platform.d.ts",
"import": "./dist/platform.js"
} }
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+153
View File
@@ -149,3 +149,156 @@ app.put(
- **Bun-only** — like the rest of WrNexus, this package targets the Bun runtime; Node is not supported. - **Bun-only** — like the rest of WrNexus, this package targets the Bun runtime; Node is not supported.
- Works with [`@wrnexus/core`](../core) — the guards return `Middleware` and read the subject from `ctx.user` on the request `Context`. Both types are imported from `@wrnexus/core`. - Works with [`@wrnexus/core`](../core) — the guards return `Middleware` and read the subject from `ctx.user` on the request `Context`. Both types are imported from `@wrnexus/core`.
- Policy combinators (`any`, `all`) and `authorize` are async-aware, so policies may return a `Promise<boolean>` (e.g. for a database ownership check). - Policy combinators (`any`, `all`) and `authorize` are async-aware, so policies may return a `Promise<boolean>` (e.g. for a database ownership check).
## Declaring permissions
The RBAC/PBAC/ABAC surface above is the low-level toolkit. On top of it sits a
declarative **registry + catalog + store + engine**: permissions, roles, and
policies are declared once in code, merged into a frozen catalog at boot, and
resolved per-request against a pluggable `PermissionStore` that holds who has
what.
Put declarations in `app/authz/<name>.ts`; they are discovered automatically
and merged (conflicting declarations of the same permission/role/policy across
files fail the boot loudly, naming both source files).
```ts
import { defineAuthz, owner } from "@wrnexus/authz";
export default defineAuthz({
permissions: {
"post:read": { title: "View posts", public: true },
"post:delete": { title: "Delete posts", risk: "high" },
},
// "post:*" is a namespace wildcard grant, valid inside a role's list — it is
// not itself a registered permission, so it can only ever grant permissions
// that ARE declared above (e.g. "post:read", "post:delete").
roles: { editor: ["post:*"], admin: ["role:editor"] },
policies: { ownsPost: owner("id", "authorId") },
bindings: { "post:delete": ["ownsPost"] },
});
```
`public: true` means anonymous callers may hold the permission — but any
policy bound to it still runs, and can still veto the anonymous caller (e.g. a
`notBanned` policy on a public `post:preview` permission).
## Checking permissions
Register `authzMiddleware` once, in `app/middleware/`, with the merged
catalog and a `PermissionStore`. Like every other `app/middleware/*.ts` file,
the registration is an eager, module-scope call — the same shape as
`authzMiddleware({ catalog, store })` requires — so it must run after the
catalog has been populated. Both the dev server and `wrnexus build`'s
generated production entry guarantee `getAuthzCatalog()` is populated before
any app middleware module evaluates. Name the file so it sorts after whatever
middleware sets `ctx.user` (middleware runs in alphabetical filename order —
`authz.ts` after `auth.ts`, for instance).
```ts
// app/middleware/authz.ts
import { authzMiddleware, getAuthzCatalog } from "@wrnexus/authz";
import { dbPermissionStore } from "@wrnexus/authz/db";
import { getDb } from "@wrnexus/db";
export default authzMiddleware({ catalog: getAuthzCatalog(), store: dbPermissionStore(getDb()) });
```
> **`subject.id` must be a non-empty string.** The engine denies (and logs to
> stderr) whenever `ctx.user.id` is present but not a non-empty string — this
> includes the common case of an integer primary key. Coerce it before it
> reaches `ctx.user`, e.g. `user.id = String(row.id)`, or every request for
> that user denies with "Invalid subject" instead of resolving normally.
> `owner()` (the built-in ownership policy) compares subject and resource ids
> with `Object.is`, so both sides must be the same type too — `owner()` on a
> numeric `resource.authorId` against a stringified `subject.id` never
> matches even when they represent "the same" id.
There is no per-route `middleware` export — `app/middleware/*.ts` is the only
place middleware is registered. To gate part of the app, branch on the
request the same way any other conditional middleware does (compare
`app/middleware/captcha-login.ts` in the auth showcase, which branches on
method + path the same way):
```ts
// app/middleware/protect-posts.ts
import type { Context, Next } from "@wrnexus/core";
import { guardPermission } from "@wrnexus/authz";
const guardPostWrite = guardPermission("post:write");
export default function protectPosts(ctx: Context, next: Next) {
return ctx.url.pathname.startsWith("/api/posts") && ctx.req.method !== "GET"
? guardPostWrite(ctx, next)
: next();
}
```
Or check inline inside a route handler with the free function `can()`:
```ts
// app/api/posts/[id].ts
import type { Context } from "@wrnexus/core";
import { can } from "@wrnexus/authz";
export const DELETE = async (ctx: Context) => {
const post = { id: "1", authorId: "alice" }; // load your own resource here
if (!(await can(ctx, "post:delete", post))) {
return Response.json({ ok: false, error: "Forbidden" }, { status: 403 });
}
return Response.json({ ok: true });
};
```
`can()` is a free function taking `ctx`, not `ctx.can``@wrnexus/core` must
not depend on `@wrnexus/authz`, so the per-request resolver lives in
`ctx.locals` instead, reached through `can()` / `decideFor()` /
`guardPermission()` / `filterCan()`. Calling any of them before
`authzMiddleware` has run for that request throws a `WRN-AUTHZ-SETUP` error
naming the missing registration, rather than silently denying.
See `examples/auth-showcase/app/authz/showcase.ts` and
`examples/auth-showcase/app/middleware/authz.ts` for a complete, runnable
version of this wiring.
## Precedence
1. An explicit deny wins over everything, including `*` — and honours the
same namespace-wildcard matching as grants (denying `post:*` blocks
`post:comment:delete`, not just `post:*` itself).
2. A bound policy can veto a permission a role grants, and runs even for a
`public: true` permission — including for an anonymous caller.
3. Otherwise the permission must be held via a role or an explicit grant.
4. Default deny.
Every failure — an unknown permission (outside strict/dev mode), a store
outage, a thrown policy — denies rather than throwing through to the caller.
`permissionsFor()` (on the resolver returned by `createAuthzResolver`) is a
coarse hint for hiding UI (e.g. a menu section), **never authoritative**. A
`Set<string>` cannot represent "granted `post:*` except `post:delete`", so a
narrow deny beneath a broad grant is invisible to it — the set still contains
`post:*` while `can()` / `decide()` correctly refuse `post:delete`. Gate real
actions with `can()`, `decideFor()`, or `filterCan()`; never by matching
against `permissionsFor()`'s result.
## CLI
```bash
wrnexus authz list # every registered permission, role, and policy
wrnexus authz generate # app/authz/permissions.gen.ts type unions
wrnexus authz init # scaffold the assignment-table migration
```
`wrnexus authz generate`'s output is a plain `Permission | Role` string-literal
union — `can()`, `guardPermission()`, and `decideFor()` all take a bare
`string` and nothing reads this file automatically, so import it to type your
own helpers/constants against the registered catalog, e.g.:
```ts
import type { Permission } from "app/authz/permissions.gen.ts";
function guard(permission: Permission) {
return guardPermission(permission);
}
```
+27 -2
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/authz", "name": "@wrnexus/authz",
"version": "0.7.0", "version": "0.8.6",
"type": "module", "type": "module",
"description": "@wrnexus/authz — part of the WrNexus framework.", "description": "@wrnexus/authz — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/authz"
},
"homepage": "https://wrnexusjs.dev/packages/authz",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"authz"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -18,9 +34,18 @@
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
},
"./db": {
"types": "./dist/db.d.ts",
"import": "./dist/db.js"
} }
}, },
"dependencies": {
"@wrnexus/core": "^0.8.6",
"@wrnexus/db": "^0.8.6"
},
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+54 -2
View File
@@ -1,5 +1,18 @@
# @wrnexus/cli # @wrnexus/cli
Production parity commands:
```bash
wrnexus build .
wrnexus preview . --port=3000
wrnexus dev . --production-runtime
```
`preview` refuses to start without `dist/server.js` and executes that exact
artifact with the production profile. Production-runtime development rebuilds
the same minified artifact after app, public, or configuration changes and
keeps the last good server running when a rebuild fails.
> The `wrnexus` command-line tool that scaffolds, runs, builds, tests, and manages WrNexus apps. > The `wrnexus` command-line tool that scaffolds, runs, builds, tests, and manages WrNexus apps.
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework. Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
@@ -26,6 +39,30 @@ bunx wrnexus dev
## Commands ## Commands
### Local production services
`wrnexus dev . --services` starts the application and the bounded local database,
cache, mail, SMS, webhook, storage, queue, cron, authentication and metrics simulator.
It generates a localhost/`*.localhost` development certificate under
`.wrnexus/certificates/` and serves both the application and service console over HTTPS.
Trust that certificate locally to remove the browser warning. Use `--services-http` only
when an external development proxy already terminates TLS.
### Exact production runtime with live updates
`wrnexus dev . --production-runtime` rebuilds and executes `dist/server.js` with
production resolution, serialization, caching, headers and assets. The supervisor keeps
the last good process when a build fails. On a successful rebuild the opt-in production
HMR socket reconnects, requests the new document and morphs it into the browser; ordinary
`wrnexus preview` and deployed production servers never include that client.
### API platform
`wrnexus api generate [app-dir]` (or `api docs`) derives operations from file routes and
emits `generated/api/openapi.json`, safe static documentation, Postman collection, curl examples,
and TypeScript, JavaScript, Java, Go and Python SDKs. Generate one client with
`wrnexus sdk generate <language> [app-dir]`.
Every command accepts an optional `[app-dir]` (defaults to `.`). Commands that read config or `.env` also accept `--profile=<name>` (see [Profiles](#profiles)). Every command accepts an optional `[app-dir]` (defaults to `.`). Commands that read config or `.env` also accept `--profile=<name>` (see [Profiles](#profiles)).
| Command | Purpose | | Command | Purpose |
@@ -46,10 +83,17 @@ Every command accepts an optional `[app-dir]` (defaults to `.`). Commands that r
| `wrnexus db <cmd>` | Database migrations and tooling (see [db](#wrnexus-db)). | | `wrnexus db <cmd>` | Database migrations and tooling (see [db](#wrnexus-db)). |
| `wrnexus test [app-dir] [--watch]` | Run the app's tests via `bun test` (defaults to the `test` profile). | | `wrnexus test [app-dir] [--watch]` | Run the app's tests via `bun test` (defaults to the `test` profile). |
| `wrnexus profiles [app-dir]` | List config profiles and their `.env` files, marking the active one. | | `wrnexus profiles [app-dir]` | List config profiles and their `.env` files, marking the active one. |
| `wrnexus compatibility check` | Check whether behavior defaults are explicitly pinned and current. |
| `wrnexus compatibility explain` | Explain configured, effective, and current compatibility behavior. |
| `wrnexus compatibility upgrade` | Back up config and explicitly opt into reviewed current behavior. |
| `wrnexus help` | Print usage. | | `wrnexus help` | Print usage. |
`wrnexus g` is an alias for `wrnexus generate`. `wrnexus g` is an alias for `wrnexus generate`.
Compatibility upgrades never happen implicitly. New applications pin
`compatibilityDate` and `frameworkBehaviour`; existing applications use
`wrnexus compatibility explain` before the backed-up, idempotent upgrade command.
### `wrnexus dev` ### `wrnexus dev`
Supervises a child dev-server process (from `@wrnexus/dev-server`). The child owns file watching and HMR: CSS and client-island edits update the live page over a WebSocket with no restart; when a server module changes, the child exits with a restart code and the supervisor respawns it (the browser reconnects and morphs in the new HTML). On startup it regenerates typed DB queries and typed routes (best effort). Use `--port=` to change the port (default `3000`). Supervises a child dev-server process (from `@wrnexus/dev-server`). The child owns file watching and HMR: CSS and client-island edits update the live page over a WebSocket with no restart; when a server module changes, the child exits with a restart code and the supervisor respawns it (the browser reconnects and morphs in the new HTML). On startup it regenerates typed DB queries and typed routes (best effort). Use `--port=` to change the port (default `3000`).
@@ -76,7 +120,9 @@ bun dist/server.js # PORT env var optional
### `wrnexus create` ### `wrnexus create`
Scaffolds a new app from an inline (dependency-free) template — `package.json`, `.gitignore`, config, and starter `app/` files. Use `npm run dev` during development, `npm run build && npm start` for production, or `npm run production` to build and start in one command. The generated production server currently requires Bun even when npm is used to manage packages and scripts. Scaffolds a complete v0.8 app from an inline template. The generated project includes strict TypeScript, ESLint and Prettier, editor recommendations, environment templates, database migrations, locales, schemas, tests, API/middleware/realtime examples, Tailwind and Iconify, PWA/mobile defaults, and the framework package kits. Its `wrnexus.config.ts` documents the current imports, types, stores, performance, observability, tenancy, build, navigation, theme, i18n, database, storage, realtime, security, and profile configuration.
Use `bun run dev` during development, `bun run check` for the complete typecheck/lint/test/format gate, `bun run build && bun run start` for production, or `bun run production` to build and start in one command.
### `wrnexus update` ### `wrnexus update`
@@ -162,7 +208,7 @@ wrnexus db status --db=analytics
### `wrnexus workspace` and `wrnexus gateway` ### `wrnexus workspace` and `wrnexus gateway`
`workspace <name>` scaffolds a monorepo: several WrNexus apps under `apps/*` and shared libraries under `packages/*`, plus a `wrnexus.workspace.ts` that maps each app to the domains it serves. `gateway` runs every app behind one port and routes by `Host` header, with optional per-app auth and gateway-wide security (trusted hosts, rate limit, security headers, access log). `workspace <name>` scaffolds a monorepo: complete v0.8 apps under `apps/*`, shared libraries under `packages/*`, root TypeScript/lint/format/editor/environment tooling, and a `wrnexus.workspace.ts` that maps each app to the domains it serves. `gateway` runs every app behind one port and routes by `Host` header, with optional per-app auth and gateway-wide security (trusted hosts, rate limit, security headers, access log). Newly added workspace apps use the same current scaffold.
```bash ```bash
wrnexus workspace acme wrnexus workspace acme
@@ -241,6 +287,12 @@ wrnexus update --latest
wrnexus doctor wrnexus doctor
``` ```
Use `wrnexus doctor --fix [app-dir]` to apply conservative repairs before the
health check: create missing `app/pages` and a default config, align skewed
`@wrnexus/*` dependency ranges, record the current migration marker, and format
only syntax-valid `.wrn` files. Invalid JSON or WRN sources are reported/skipped
instead of overwritten; repeat runs are idempotent.
## Profiles ## Profiles
Pass `--profile=<name>` to `dev`, `build`, `db` (or set `WRNEXUS_PROFILE`) to select a config profile. The CLI publishes `WRNEXUS_PROFILE` so config loaders and the dev child pick it up, and loads that profile's `.env` cascade (`.env`, `.env.local`, `.env.<profile>`, `.env.<profile>.local`) into `process.env`. Pass `--profile=<name>` to `dev`, `build`, `db` (or set `WRNEXUS_PROFILE`) to select a config profile. The CLI publishes `WRNEXUS_PROFILE` so config loaders and the dev child pick it up, and loads that profile's `.env` cascade (`.env`, `.env.local`, `.env.<profile>`, `.env.<profile>.local`) into `process.env`.
+37 -14
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/cli", "name": "@wrnexus/cli",
"version": "0.7.0", "version": "0.8.6",
"type": "module", "type": "module",
"description": "@wrnexus/cli — part of the WrNexus framework.", "description": "@wrnexus/cli — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/cli"
},
"homepage": "https://wrnexusjs.dev/packages/cli",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"cli"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -28,20 +44,27 @@
"wrnexus": "./dist/index.js" "wrnexus": "./dist/index.js"
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "^0.7.0", "@wrnexus/core": "^0.8.6",
"@wrnexus/router": "^0.7.0", "@wrnexus/router": "^0.8.6",
"@wrnexus/csr": "^0.7.0", "@wrnexus/csr": "^0.8.6",
"@wrnexus/compiler": "^0.7.0", "@wrnexus/compiler": "^0.8.6",
"@wrnexus/styles": "^0.7.0", "@wrnexus/styles": "^0.8.6",
"@wrnexus/dev-server": "^0.7.0", "@wrnexus/dev-server": "^0.8.6",
"@wrnexus/ui": "^0.7.0", "@wrnexus/ui": "^0.8.6",
"@wrnexus/validation": "^0.7.0", "@wrnexus/validation": "^0.8.6",
"@wrnexus/i18n": "^0.7.0", "@wrnexus/i18n": "^0.8.6",
"@wrnexus/db": "^0.7.0", "@wrnexus/mcp": "^0.8.6",
"@wrnexus/plugin": "^0.7.0", "@wrnexus/playground": "^0.8.6",
"@wrnexus/syntax": "^0.7.0" "@wrnexus/db": "^0.8.6",
"@wrnexus/authz": "^0.8.6",
"@wrnexus/plugin": "^0.8.6",
"@wrnexus/syntax": "^0.8.6",
"@wrnexus/typecheck": "^0.8.6",
"@wrnexus/security": "^0.8.6",
"selfsigned": "^5.5.0"
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+35
View File
@@ -1,9 +1,44 @@
# @wrnexus/compiler # @wrnexus/compiler
## Partial-static rendering
Pages can select `render = "partial-static"` and divide their view with `<Static>` and
`<Dynamic>` boundaries. The compiler emits a build-only shell renderer that never evaluates
dynamic-boundary children. `wrnexus build` expands static component mounts into
`dist/partial-shells.json`, records byte/region evidence in `build-report.json`, and embeds
the shell in the production route manifest. At request time the production runtime retains
request-aware layouts, locale/theme metadata and security nonces while streaming dynamic
regions into stable placeholders.
> Compiler for the `.wrn` language — tokenizes, parses, and lowers `.wrn` page and component files to TypeScript. > Compiler for the `.wrn` language — tokenizes, parses, and lowers `.wrn` page and component files to TypeScript.
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework. Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
Production adapters use `analyzeRuntimeImports` before bundling. Edge, worker,
service-worker, and browser targets reject Node filesystem, TCP, and process
modules with `WRN-RUNTIME-CAPABILITY`. Package manifests can declare supported
`wrnexus.runtimes` and required `wrnexus.requires` capabilities; discovery fails
when the selected deployment cannot satisfy them.
## Server actions
```wrn
action createUser using CreateUserSchema {
const user = await users.create(input)
invalidate("users")
return user
}
view {
<form @submit="createUser">...</form>
}
```
The compiler produces a schema-aware server registry, a fully inferred action
client, and progressively enhanced form metadata. The shared runtime performs
validation, authentication/permission checks, CSRF verification, serialization,
invalidation reporting, and browser lifecycle events.
## Overview ## Overview
`@wrnexus/compiler` turns `.wrn` source into TypeScript that targets the framework's runtime primitives. A `.wrn` file declares either a `page` (a route) or a `component` (a reusable, prop-driven fragment) with blocks for `state`, `view` (plain HTML), `seo`, `style`, `functions`, `api`, `ssr`/`client` data bindings, and `realtime` websocket handlers. The pipeline is `source → Lexer → parse() → PageAst → generate() → TypeScript`. It is a build/server-side library — the WrNexus dev loader calls it to compile `.wrn` files on the fly, surfacing `ParseError` as a readable error page. `@wrnexus/compiler` turns `.wrn` source into TypeScript that targets the framework's runtime primitives. A `.wrn` file declares either a `page` (a route) or a `component` (a reusable, prop-driven fragment) with blocks for `state`, `view` (plain HTML), `seo`, `style`, `functions`, `api`, `ssr`/`client` data bindings, and `realtime` websocket handlers. The pipeline is `source → Lexer → parse() → PageAst → generate() → TypeScript`. It is a build/server-side library — the WrNexus dev loader calls it to compile `.wrn` files on the fly, surfacing `ParseError` as a readable error page.
+23 -4
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/compiler", "name": "@wrnexus/compiler",
"version": "0.7.0", "version": "0.8.6",
"type": "module", "type": "module",
"description": "@wrnexus/compiler — part of the WrNexus framework.", "description": "@wrnexus/compiler — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/compiler"
},
"homepage": "https://wrnexusjs.dev/packages/compiler",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"compiler"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -21,10 +37,13 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/syntax": "^0.7.0", "@wrnexus/csr": "^0.8.6",
"@wrnexus/store": "^0.7.0" "@wrnexus/syntax": "^0.8.6",
"@wrnexus/store": "^0.8.6",
"@wrnexus/validation": "^0.8.6"
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+31
View File
@@ -110,6 +110,37 @@ instances. The default store is process-local memory.
(default `console.log`), `requestIdKey` (default `"requestId"`), `now`. (default `console.log`), `requestIdKey` (default `"requestId"`), `now`.
`RequestRecord` = `{ time, id, method, path, status, durationMs }`. `RequestRecord` = `{ time, id, method, path, status, durationMs }`.
### Resilience — `@wrnexus/core`
`resilientCall` standardizes cancellation-aware timeouts, controlled retries,
fixed or exponential backoff, fallback responses, circuit breaking, and bounded
concurrency. Reuse a declarative circuit/bulkhead options object, or an explicit
`CircuitBreaker`/`Bulkhead` instance, wherever calls must share health and
capacity state.
```ts
import { resilientCall } from "@wrnexus/core";
const paymentCircuit = { failures: 5, resetAfter: "30s" } as const;
const status = await resilientCall({
timeout: "5s",
retries: 3,
retryDelay: "100ms",
backoff: "exponential",
circuitBreaker: paymentCircuit,
bulkhead: { concurrency: 20, queue: 100 },
run: (signal) => paymentProvider.checkStatus({ signal }),
fallback: () => ({ state: "unavailable" }),
});
```
`CircuitBreaker.snapshot()` reports `closed`, `open`, or `half-open`, failure
and success counts, and the remaining retry delay for health endpoints and
development tooling. Fail-fast conditions use stable `WRN-RESILIENCE-*` codes.
Core's existing `HealthRegistry`, `withIdempotency`, and pluggable stores/locks
cover health reporting, idempotent requests, and distributed coordination.
### Caching — `@wrnexus/core` ### Caching — `@wrnexus/core`
| Export | Kind | Notes | | Export | Kind | Notes |
+19 -2
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/core", "name": "@wrnexus/core",
"version": "0.7.0", "version": "0.8.6",
"type": "module", "type": "module",
"description": "@wrnexus/core — part of the WrNexus framework.", "description": "@wrnexus/core — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/core"
},
"homepage": "https://wrnexusjs.dev/packages/core",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"core"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -29,6 +45,7 @@
} }
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+44 -10
View File
@@ -1,5 +1,34 @@
# @wrnexus/csr # @wrnexus/csr
## Navigation state preservation
Pages can opt into restoration across client navigation:
```wrn
page Users {
navigation {
preserve = ["filters", "pagination", "scroll", "tabs", "expanded"]
}
}
```
Form-like categories restore named inputs, selects, and textareas. Password,
file, hidden, CSRF/token/secret/credential fields, and elements marked
`data-no-preserve` are never saved. For tab, expanded, or component UI state,
mark stable elements with `data-wrn-preserve="key"`; their value and ARIA
selected/expanded state are restored. State is scoped to pathname plus query.
## Typed server actions
`createActionClient<Input, Output>(route, name)` supports programmatic calls.
Schema-backed WRN actions also export `__wrnexusActionClients`, whose input and
output are inferred automatically. Enhanced forms expose
`data-wrn-action-state="pending|success|error"` and dispatch bubbling
`wrnexus:action:optimistic`, `:pending`, `:success`, and `:error` events.
Success details contain returned data and invalidated cache tags; error details
contain field errors. Without JavaScript, the same form posts to its page and
receives a 303 redirect or accessible validation response.
> The browser-side client runtime for WrNexus — generic, self-contained JS that hydrates server-rendered pages with reactivity, client-side navigation, and realtime rooms. > The browser-side client runtime for WrNexus — generic, self-contained JS that hydrates server-rendered pages with reactivity, client-side navigation, and realtime rooms.
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework. Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
@@ -49,16 +78,21 @@ getRealtimeRuntime(): string // → REALTIME_RUNTIME
Applied to any subtree containing `data-scope`. Expressions are parsed by a tiny eval-free evaluator, so a strict CSP with no `unsafe-eval` works. Applied to any subtree containing `data-scope`. Expressions are parsed by a tiny eval-free evaluator, so a strict CSP with no `unsafe-eval` works.
| Directive | Purpose | | Directive | Purpose |
| -------------------------------------------------------- | ----------------------------------------------------------------------- | | ---------------------------------- | ---------------------------------------------------- |
| `data-scope="count: 0, name: 'x'"` | Declare reactive state on a subtree | | `data-scope="count: 0, name: 'x'"` | Declare reactive state on a subtree |
| `data-on-<event>="count++"` | Run a statement in scope on a DOM event | | `data-on-<event>="count++"` | Run a statement in scope on a DOM event |
| `data-text="expr"` | Bind an element's `textContent` to an expression | | `data-text="expr"` | Bind an element's `textContent` to an expression |
| `data-show="expr"` | Toggle visibility (`display`) on truthiness | | `data-show="expr"` | Toggle visibility while preserving interactive state |
| `data-for="item in list"` (opt. index and `key item.id`) | Per-item rendering; stable keys preserve DOM identity during reorder |
| `data-key="item.id"` | Alternative key declaration for `data-for` templates | Compiled conditional rendering and dynamic component cases omit inactive elements from the live
| `{{expr}}` or `{expr}` | Interpolation inside text nodes and attribute values | DOM. `data-show` is a visibility directive for stateful controls and keeps its element mounted.
| `data-wrnexus-csr="id"` | Target for a generated CSR fetch binding (fetches `/__wrnexus/csr?...`) | Neither mechanism is authorization: never place secrets in client-rendered branches. Authorize on
the server and return only data the current request may access.
| `data-for="item in list"` (opt. index and `key item.id`) | Per-item rendering; stable keys preserve DOM identity during reorder |
| `data-key="item.id"` | Alternative key declaration for `data-for` templates |
| `{{expr}}` or `{expr}` | Interpolation inside text nodes and attribute values |
| `data-wrnexus-csr="id"` | Target for a generated CSR fetch binding (fetches `/__wrnexus/csr?...`) |
Supported expression features: literals, identifiers, member access (`a.b`, `a[b]`), function/method calls, arrays, objects, arithmetic, comparison, equality, logical (`&& ||`), unary (`! - +`), and ternary. Statements support `++`/`--`, assignment operators (`= += -= *= /= %=`), and bare expression/method calls. Rendering is dependency-tracked: a signal change only re-runs the renderers that actually read it. Supported expression features: literals, identifiers, member access (`a.b`, `a[b]`), function/method calls, arrays, objects, arithmetic, comparison, equality, logical (`&& ||`), unary (`! - +`), and ternary. Statements support `++`/`--`, assignment operators (`= += -= *= /= %=`), and bare expression/method calls. Rendering is dependency-tracked: a signal change only re-runs the renderers that actually read it.
+20 -3
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/csr", "name": "@wrnexus/csr",
"version": "0.7.0", "version": "0.8.6",
"type": "module", "type": "module",
"description": "@wrnexus/csr — part of the WrNexus framework.", "description": "@wrnexus/csr — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/csr"
},
"homepage": "https://wrnexusjs.dev/packages/csr",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"csr"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -21,9 +37,10 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "^0.7.0" "@wrnexus/core": "^0.8.6"
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+75 -4
View File
@@ -1,5 +1,14 @@
# @wrnexus/db # @wrnexus/db
## Rollout-safe migrations
Run `wrnexus db check` in CI before deployment. The analyzer reports stable
diagnostics for drops, renames, type changes, new/enforced required columns,
and potentially blocking index creation, with an expand/backfill/switch/contract
recommendation. `wrnexus db migrate` blocks critical issues in pending
migrations. `--allow-breaking` is an explicit operator override; already-applied
migrations do not block later releases.
> The database layer for WrNexus: TS models as the single source of truth for DDL, validation, and result typing, plus a driver-based `Db` client, migrations, and a sqlc-style query generator. > The database layer for WrNexus: TS models as the single source of truth for DDL, validation, and result typing, plus a driver-based `Db` client, migrations, and a sqlc-style query generator.
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework. Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
@@ -85,7 +94,8 @@ A `Driver` (implemented by adapters) exposes `dialect`, `query(sql, params?)`,
- `exec(sql, params?)``Promise<ExecResult>` (`{ changes, lastInsertId? }`). - `exec(sql, params?)``Promise<ExecResult>` (`{ changes, lastInsertId? }`).
- `tx(fn)` — run `fn(db)` in a transaction; rolls back on throw. Nested `tx` reuses the current transaction. - `tx(fn)` — run `fn(db)` in a transaction; rolls back on throw. Nested `tx` reuses the current transaction.
- `createTable(model)` — runs the model's `CREATE TABLE IF NOT EXISTS` DDL. - `createTable(model)` — runs the model's `CREATE TABLE IF NOT EXISTS` DDL.
- `close()`. - `close()` — idempotently rejects new top-level work, drains active queries and
transactions, then closes the underlying pool.
Every query is parameterized (positional params). `createTableSql(model, dialect, ifNotExists?)` Every query is parameterized (positional params). `createTableSql(model, dialect, ifNotExists?)`
renders `CREATE TABLE` directly; `Dialect` is `"sqlite" | "postgres" | "mysql"`. renders `CREATE TABLE` directly; `Dialect` is `"sqlite" | "postgres" | "mysql"`.
@@ -98,7 +108,9 @@ A process-wide registry the runtime configures at startup from `wrnexus.config.t
- `setDb(db)` / `setDb(name, db)` — set the default or a named connection. - `setDb(db)` / `setDb(name, db)` — set the default or a named connection.
- `registerDb(name, db)` — alias of `setDb(name, db)`. - `registerDb(name, db)` — alias of `setDb(name, db)`.
- `getDb(name = "default")` — the default or a named `Db` (throws if unconfigured). - `getDb(name = "default")` — the default or a named `Db` (throws if unconfigured).
- `hasDb(name?)`, `databaseNames()`, `closeDatabases()`. - `hasDb(name?)`, `databaseNames()`, `closeDatabases()`. Registry shutdown clears
registrations first, attempts every open database, and reports close failures
together with `AggregateError` instead of leaking later pools.
```ts ```ts
const users = await getDb().all("SELECT * FROM users"); const users = await getDb().all("SELECT * FROM users");
@@ -121,8 +133,8 @@ Migrations are `.sql` files (in e.g. `app/db/migrations`), each split into
- `parseMigration(name, content)``Migration` (`{ name, up, down }`). - `parseMigration(name, content)``Migration` (`{ name, up, down }`).
- `loadMigrations(dir)` — parse all `.sql` files, sorted by filename. - `loadMigrations(dir)` — parse all `.sql` files, sorted by filename.
- `appliedMigrations(db)` — applied names, oldest first. - `appliedMigrations(db)` — applied names, oldest first.
- `migrate(db, dir)` — apply all pending (each in a transaction); returns applied names. - `migrate(db, dir, options?)` — apply all pending (each in a transaction); returns applied names.
- `rollback(db, dir)` — roll back the most recent; returns its name or `null`. - `rollback(db, dir, options?)` — roll back the most recent; returns its name or `null`.
- `status(db, dir)``{ name, applied }[]` for every migration file. - `status(db, dir)``{ name, applied }[]` for every migration file.
- `scaffoldMigration(dir, name, dialect, models?)` — write a new numbered migration; with `models` it generates `CREATE`/`DROP` for every table (referenced tables first via topological sort). Returns the file path. - `scaffoldMigration(dir, name, dialect, models?)` — write a new numbered migration; with `models` it generates `CREATE`/`DROP` for every table (referenced tables first via topological sort). Returns the file path.
@@ -199,6 +211,21 @@ const pageTwo = await paginate(
); );
``` ```
For deployments, `{ dryRun: true }` reports pending names without applying
their SQL, `signal` cancels safely between migrations, and the default
database-backed lock prevents concurrent deploy runners. A live lock produces
`WRN-DB-MIGRATION-LOCKED`; crash-stale locks expire after `lockTimeoutMs` (five
minutes by default). Disable it with `lock: false` only when an external deploy
coordinator already guarantees exclusivity.
```ts
const pending = await migrate(db, "app/db/migrations", { dryRun: true });
await migrate(db, "app/db/migrations", {
signal: shutdownController.signal,
lockTimeoutMs: 10 * 60_000,
});
```
MongoDB (document API): MongoDB (document API):
```ts ```ts
@@ -226,3 +253,47 @@ SQL driver — use `@wrnexus/db/mongo` directly.
`wrnexus.config.ts`. `wrnexus.config.ts`.
- The `mongodb` npm package is an optional, lazily-imported peer — install it - The `mongodb` npm package is an optional, lazily-imported peer — install it
only if you use `@wrnexus/db/mongo`. The core package stays dependency-free. only if you use `@wrnexus/db/mongo`. The core package stays dependency-free.
## Repository and transaction helpers
Repositories accept an immutable equality `scope`, normally `{ column: "tenant_id", value:
ctx.tenant.id }`. The scope is injected into every read, count, update and delete, while create
overwrites any caller-supplied tenant value. This makes accidental cross-tenant CRUD through the
repository API fail closed.
```ts
import { createRepository, retryTransaction, databaseHealth, batch } from "@wrnexus/db";
const users = createRepository<User>(db, {
table: "users",
allowedColumns: ["email", "name", "active"],
});
const user = await users.require(42);
await users.update(42, { active: true });
```
Repository identifiers are validated, writes may be restricted to an allowlist, and values always use query parameters. Infrastructure packages remain helper-only and do not add UI dependencies to server code.
## 0.8 repository and transaction helpers
```ts
import { createRepository, databaseHealth, firstOrThrow, retryTransaction } from "@wrnexus/db";
const usersRepo = createRepository<User>(db, {
table: "users",
allowedColumns: ["email", "name", "active"],
maxListLimit: 250,
});
const users = await usersRepo.all({
orderBy: "name",
direction: "asc",
limit: 50,
offset: 0,
});
```
Repository SQL identifiers are validated and values remain parameterized. Placeholder generation is dialect-aware: PostgreSQL uses `$1`, `$2`, and SQLite/MySQL use `?`. List limits are bounded.
`retryTransaction()` retries recognized serialization, deadlock, and database-lock errors by default. Supply `shouldRetry` for application-specific retryable errors; ordinary validation or business errors are not retried automatically.
+20 -3
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/db", "name": "@wrnexus/db",
"version": "0.7.0", "version": "0.8.6",
"type": "module", "type": "module",
"description": "@wrnexus/db — part of the WrNexus framework.", "description": "Typed database drivers, migrations, instrumentation, repositories, pagination, and transaction helpers.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/db"
},
"homepage": "https://wrnexusjs.dev/packages/db",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"db"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -45,6 +61,7 @@
} }
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+9 -1
View File
@@ -66,6 +66,12 @@ interface RunningServer {
In development, `startServer` also connects `app/db/migrations` (and `app/db/<name>/migrations`) and auto-applies migrations, then starts an in-process file watcher. CSS edits hot-swap live. Page, component, layout, API, middleware, realtime, schema, locale, and public-asset edits invalidate only their cached modules, rescan routes where necessary, and morph fresh HTML through the existing HMR WebSocket. The server process and active gateway stay running. In development, `startServer` also connects `app/db/migrations` (and `app/db/<name>/migrations`) and auto-applies migrations, then starts an in-process file watcher. CSS edits hot-swap live. Page, component, layout, API, middleware, realtime, schema, locale, and public-asset edits invalidate only their cached modules, rescan routes where necessary, and morph fresh HTML through the existing HMR WebSocket. The server process and active gateway stay running.
`getWrnCompileMetrics()` exposes cumulative content-addressed compiler cache
`hits`, `misses`, successful `compilations`, `errors`, `totalDurationMs`, and
`lastDurationMs` for the DevToolbar or custom diagnostics. Tests and embedded
servers can call `resetWrnCompileMetrics()` to establish a fresh measurement
window.
### `createHandlers(deps)` ### `createHandlers(deps)`
The core runtime shared by dev and prod. It handles CORS preflight, `/healthz` and `/__wrnexus/health`, request-body size limits (413), HMR socket upgrades (`/__wrnexus/hmr`), realtime WebSocket upgrades (`defineRoom` default export or a raw `websocket` export), the middleware pipeline, API routes (`/api/*`), framework assets (`/__wrnexus/*`), public assets, and full SSR page rendering (component mounts, layouts, slots, i18n markers, per-page script selection, ETag/304, gzip). The core runtime shared by dev and prod. It handles CORS preflight, `/healthz` and `/__wrnexus/health`, request-body size limits (413), HMR socket upgrades (`/__wrnexus/hmr`), realtime WebSocket upgrades (`defineRoom` default export or a raw `websocket` export), the middleware pipeline, API routes (`/api/*`), framework assets (`/__wrnexus/*`), public assets, and full SSR page rendering (component mounts, layouts, slots, i18n markers, per-page script selection, ETag/304, gzip).
@@ -296,7 +302,9 @@ Pages get only the scripts they use: `nav.js` always, `reactive.js` when a page
- **Bun-only.** Uses `Bun.serve` (HTTP + WebSocket), `Bun.file`, and `Bun.gzipSync`. The full app also relies on `bun:sqlite` / `Bun.SQL` via `@wrnexus/db`. - **Bun-only.** Uses `Bun.serve` (HTTP + WebSocket), `Bun.file`, and `Bun.gzipSync`. The full app also relies on `bun:sqlite` / `Bun.SQL` via `@wrnexus/db`.
- Orchestrates the whole framework: `@wrnexus/core` (context, security, realtime registry), `@wrnexus/router`, `@wrnexus/ssr` (`renderDocument`), `@wrnexus/csr` (client runtimes), `@wrnexus/compiler` (`.wrn` → TS), `@wrnexus/styles`, `@wrnexus/ui`, `@wrnexus/validation`, `@wrnexus/i18n`, `@wrnexus/db`, and `@wrnexus/pubsub` (Redis-backed cross-process realtime). - Orchestrates the whole framework: `@wrnexus/core` (context, security, realtime registry), `@wrnexus/router`, `@wrnexus/ssr` (`renderDocument`), `@wrnexus/csr` (client runtimes), `@wrnexus/compiler` (`.wrn` → TS), `@wrnexus/styles`, `@wrnexus/ui`, `@wrnexus/validation`, `@wrnexus/i18n`, `@wrnexus/db`, and `@wrnexus/pubsub` (Redis-backed cross-process realtime).
- `.wrn` files are compiled to TypeScript into a hidden sibling `.wrnexus/` cache dir and dynamically imported; the module cache means each edited server module needs a fresh process (dev) — hence the restart-on-change model. - `.wrn` files compile into a content-addressed hidden `.wrnexus/` cache. Targeted
invalidation gives changed modules a fresh import identity without restarting
the development server.
- Responses are gzipped when the client accepts it and the body is a buffered, compressible payload ≥ 1 KB; streaming/SSE responses opt out via `Cache-Control: no-transform`. - Responses are gzipped when the client accepts it and the body is a buffered, compressible payload ≥ 1 KB; streaming/SSE responses opt out via `Cache-Control: no-transform`.
</content> </content>
+40 -19
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/dev-server", "name": "@wrnexus/dev-server",
"version": "0.7.0", "version": "0.8.6",
"type": "module", "type": "module",
"description": "@wrnexus/dev-server — part of the WrNexus framework.", "description": "@wrnexus/dev-server — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/dev-server"
},
"homepage": "https://wrnexusjs.dev/packages/dev-server",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"dev-server"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -25,25 +41,30 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "^0.7.0", "@wrnexus/authz": "^0.8.6",
"@wrnexus/dev-toolbar": "^0.7.0", "@wrnexus/rpc": "^0.8.6",
"@wrnexus/router": "^0.7.0", "@wrnexus/core": "^0.8.6",
"@wrnexus/ssr": "^0.7.0", "@wrnexus/dev-toolbar": "^0.8.6",
"@wrnexus/csr": "^0.7.0", "@wrnexus/router": "^0.8.6",
"@wrnexus/compiler": "^0.7.0", "@wrnexus/ssr": "^0.8.6",
"@wrnexus/styles": "^0.7.0", "@wrnexus/csr": "^0.8.6",
"@wrnexus/ui": "^0.7.0", "@wrnexus/compiler": "^0.8.6",
"@wrnexus/validation": "^0.7.0", "@wrnexus/styles": "^0.8.6",
"@wrnexus/i18n": "^0.7.0", "@wrnexus/ui": "^0.8.6",
"@wrnexus/db": "^0.7.0", "@wrnexus/validation": "^0.8.6",
"@wrnexus/pubsub": "^0.7.0", "@wrnexus/i18n": "^0.8.6",
"@wrnexus/uploader": "^0.7.0", "@wrnexus/db": "^0.8.6",
"@wrnexus/plugin": "^0.7.0", "@wrnexus/pubsub": "^0.8.6",
"@wrnexus/store": "^0.7.0", "@wrnexus/uploader": "^0.8.6",
"@wrnexus/security": "^0.7.0", "@wrnexus/plugin": "^0.8.6",
"@wrnexus/observability": "^0.7.0" "@wrnexus/store": "^0.8.6",
"@wrnexus/security": "^0.8.6",
"@wrnexus/observability": "^0.8.6",
"@wrnexus/cache": "^0.8.6",
"@wrnexus/pwa": "^0.8.6"
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+7
View File
@@ -7,6 +7,9 @@ Development-only page quality toolbar for WRNexusJS.
- Runtime, resource and unhandled promise error capture - Runtime, resource and unhandled promise error capture
- Accessibility, SEO, image, media, color, HTML, form, link, responsive and security checks - Accessibility, SEO, image, media, color, HTML, form, link, responsive and security checks
- Performance and network observations - Performance and network observations
- First-class application tabs for runtime, stores, cache, accessibility, SEO, performance,
security, images, links, and JavaScript
- Plugin-contributed applications with badges, descriptions, issue feeds, and structured data
- Element highlighting and issue filtering - Element highlighting and issue filtering
- Server-side issue collector - Server-side issue collector
- Development-only asset strings for direct serving by `@wrnexus/dev-server` - Development-only asset strings for direct serving by `@wrnexus/dev-server`
@@ -21,3 +24,7 @@ Serve `DEV_TOOLBAR_RUNTIME` at `/__wrnexus/dev-toolbar.js` and `DEV_TOOLBAR_CSS`
``` ```
The browser runtime exposes `window.__wrnexusDevToolbar`. The browser runtime exposes `window.__wrnexusDevToolbar`.
Plugin panels returned through `devToolbarPanels()` are automatically added to the application
strip. Their issue category is filterable, and structured `data` is rendered as escaped diagnostic
content so a plugin never needs to inject toolbar HTML.
+19 -2
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/dev-toolbar", "name": "@wrnexus/dev-toolbar",
"version": "0.7.0", "version": "0.8.6",
"type": "module", "type": "module",
"description": "@wrnexus/dev-toolbar — part of the WrNexus framework.", "description": "@wrnexus/dev-toolbar — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/dev-toolbar"
},
"homepage": "https://wrnexusjs.dev/packages/dev-toolbar",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"dev-toolbar"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -37,6 +53,7 @@
} }
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+50 -63
View File
@@ -1,80 +1,67 @@
# @wrnexus/encryption # @wrnexus/encryption
> Dependency-free crypto helpers for WrNexus: authenticated symmetric encryption (AES-256-GCM), hashing, and HMAC signing. Authenticated encryption, hashing, HMAC, key rotation, and optional encrypted HTTP exchanges for WRNexusJS.
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework. ## Core helpers
## Overview - `generateKey()` — random 256-bit AES key encoded as base64.
- `deriveKey(password, salt)` — PBKDF2-derived AES key.
- `encrypt(plaintext, key)` / `decrypt(payload, key)` — AES-256-GCM.
- `sha256(data)` — SHA-256 digest.
- `hmacSign(data, secret)` / `hmacVerify(...)` — HMAC-SHA256.
- `createKeyring(keys)` — active/previous key management.
- `seal()` / `open()` — versioned ciphertext with key ID.
This package provides small, focused cryptographic primitives for server-side use: encrypting secrets/tokens/database fields at rest with AES-256-GCM, deriving keys from passwords via PBKDF2, computing SHA-256 digests, and signing/verifying payloads with HMAC-SHA256. It is built entirely on the standard **Web Crypto API** (`crypto.subtle`) plus `btoa`/`atob` and `TextEncoder`/`TextDecoder` — no third-party dependencies. Reach for it whenever you need to protect sensitive values or verify webhook signatures. All functions are `async` (Web Crypto is promise-based). ## Encrypted HTTP envelope
## Installation
```bash
bun add @wrnexus/encryption
```
> Private package — the machine must be authenticated to the `wrnexus` npm org
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
## API
All keys are exchanged as **base64 strings** and all digests/signatures as **hex strings**.
| Export | Signature | Description |
| ------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `generateKey` | `() => Promise<string>` | Generate a random 256-bit AES key, base64-encoded. Store it as a secret. |
| `deriveKey` | `(password: string, salt: string) => Promise<string>` | Derive a base64 AES-256 key from a password + salt using PBKDF2 (100,000 iterations, SHA-256). |
| `encrypt` | `(plaintext: string, key: string) => Promise<string>` | AES-256-GCM encrypt a string. Returns base64 of `iv(12 bytes) ‖ ciphertext+tag`. A fresh random IV is used each call. |
| `decrypt` | `(payload: string, key: string) => Promise<string>` | Decrypt a value produced by `encrypt`. Throws if the key is wrong or the data was tampered with. |
| `sha256` | `(data: string) => Promise<string>` | SHA-256 hex digest of a string (e.g. content hashing, dedup keys). |
| `hmacSign` | `(data: string, secret: string) => Promise<string>` | HMAC-SHA256 hex signature of `data` with `secret` (e.g. signing webhooks). |
| `hmacVerify` | `(data: string, secret: string, signature: string) => Promise<boolean>` | Constant-time verify of an HMAC-SHA256 hex signature. |
Notes:
- `generateKey` produces a 32-byte (256-bit) key via `crypto.getRandomValues`.
- `encrypt`/`decrypt` require a base64-encoded 256-bit key; anything else throws `"Encryption key must be a base64 256-bit key"`.
- `decrypt` throws `"Invalid ciphertext"` if the payload is shorter than the 12-byte IV, and the underlying Web Crypto call throws on any authentication (tag) mismatch.
- `hmacVerify` compares in constant time (length check plus XOR accumulation) to avoid timing leaks.
## Usage
Symmetric encryption of a secret at rest:
```ts ```ts
import { generateKey, encrypt, decrypt } from "@wrnexus/encryption"; import {
createEncryptedRequest,
createKeyring,
createMemoryReplayStore,
decryptEncryptedResponse,
encryptedExchange,
} from "@wrnexus/encryption";
const key = await generateKey(); // store this safely (env/secret manager) const keyring = createKeyring([{ id: "2026-08", secret: process.env.API_BODY_KEY!, active: true }]);
const box = await encrypt("card #1234", key); // opaque base64 string, safe to persist const replayStore = createMemoryReplayStore();
const plain = await decrypt(box, key); // "card #1234"
// Server middleware.
app.use(
encryptedExchange({
keyring,
replayStore,
maxAgeMs: 60_000,
maxBodyBytes: 1_048_576,
}),
);
// Controlled service/native client.
const request = await createEncryptedRequest(
"https://api.example.com/private/report",
{ reportId: "report-1" },
{ method: "POST", keyring },
);
const response = await fetch(request);
const result = await decryptEncryptedResponse(response, request, { keyring });
``` ```
Deriving a key from a user password instead of a random key: The envelope binds authenticated ciphertext to:
```ts - HTTP method
import { deriveKey, encrypt } from "@wrnexus/encryption"; - URL path and query
- request ID
- timestamp and expiry window
- encryption key ID
- optional replay-store consumption
const key = await deriveKey("correct horse battery staple", "per-user-salt"); `encryptedBody()` decrypts request bodies only. `encryptedExchange()` also encrypts successful downstream responses while allowing application exceptions to propagate normally. `encryptedFetch()` provides a convenient controlled-client call.
const box = await encrypt("secret note", key);
```
Hashing and webhook signature verification: ## Security boundary
```ts Encrypted HTTP bodies **do not replace TLS/HTTPS**. Always use HTTPS.
import { sha256, hmacSign, hmacVerify } from "@wrnexus/encryption";
const digest = await sha256("some content"); // 64-char hex string This layer is appropriate for service-to-service traffic, native/mobile applications, controlled agents, and selected fields protected with server-managed keys. It cannot conceal data from an end user when browser JavaScript receives the decryption key. Never ship a long-lived server encryption key to a browser.
const signature = await hmacSign(rawBody, webhookSecret); Use a shared replay store such as Redis in multi-instance deployments. The memory replay store is process-local.
const ok = await hmacVerify(rawBody, webhookSecret, incomingSignatureHeader);
if (!ok) throw new Error("Invalid webhook signature");
```
## Requirements / Notes
- **Bun-only.** Relies on the Web Crypto API (`crypto.subtle`, `crypto.getRandomValues`) and the global `btoa`/`atob`, `TextEncoder`/`TextDecoder` — all available in Bun's runtime.
- **No dependencies.** The package has an empty dependency set; nothing is bundled beyond standard runtime APIs.
- Algorithms: AES-256-GCM (encryption), PBKDF2 with 100k SHA-256 iterations (key derivation), SHA-256 (digest), HMAC-SHA256 (signing).
- Keep generated/derived keys and HMAC secrets out of source control; treat them as first-class secrets.
+23 -3
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/encryption", "name": "@wrnexus/encryption",
"version": "0.7.0", "version": "0.8.6",
"type": "module", "type": "module",
"description": "@wrnexus/encryption — part of the WrNexus framework.", "description": "Authenticated encryption, key rotation, hashing, and optional application-layer encrypted HTTP envelopes.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/encryption"
},
"homepage": "https://wrnexusjs.dev/packages/encryption",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"encryption"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -20,7 +36,11 @@
"import": "./dist/index.js" "import": "./dist/index.js"
} }
}, },
"dependencies": {
"@wrnexus/core": "^0.8.6"
},
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+20 -3
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/helpers", "name": "@wrnexus/helpers",
"version": "0.7.0", "version": "0.8.6",
"type": "module", "type": "module",
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.", "description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/helpers"
},
"homepage": "https://wrnexusjs.dev/packages/helpers",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"helpers"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -21,9 +37,10 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "^0.7.0" "@wrnexus/core": "^0.8.6"
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+60 -134
View File
@@ -1,167 +1,93 @@
# @wrnexus/i18n # @wrnexus/i18n
> Per-request translations plus locale-aware number, date, and currency formatting for WrNexus apps. Recursive locale loading, fallback resolution, SSR/browser translations, locale formatting, and language UI blocks for WRNexusJS.
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework. ## Locale files
## Overview Both layouts can be used together:
`@wrnexus/i18n` loads locale files from `app/locales/<lang>.json`, resolves the ```text
active language for each request (cookie → `Accept-Language` → default), and app/locales/en.json
builds a `t(key, params)` translator used both in server code and in `.wrn` app/locales/en/common.json
views. It also ships Intl-based formatting helpers and a tiny client runtime that app/locales/en/auth.json
wires up a language switcher. Translation lookup, language resolution, and HTML app/locales/mr/common.json
marker rewriting run server-side; only the small `I18N_RUNTIME` snippet runs in
the browser.
## Installation
```bash
bun add @wrnexus/i18n
``` ```
> Private package — the machine must be authenticated to the `wrnexus` npm org Namespaced files become keys such as `common.save` and `auth.signIn`.
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
## API
### Loading & resolving
| Export | Signature | Description |
| ------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `loadLocales` | `(dir: string) => Record<string, Messages>` | Reads every `<lang>.json` in `dir` into a `{ lang: messages }` map. Missing dir → `{}`; a bad file is warned and skipped. |
| `resolveI18n` | `(messages: Record<string, Messages>, config?: I18nConfig) => ResolvedI18n` | Merges loaded messages + config into a resolved bundle (default lang, supported langs, messages). |
| `resolveLang` | `(i18n: ResolvedI18n, cookieValue: string \| undefined, acceptLanguage: string \| null) => string` | Picks the active language: matching cookie → best `Accept-Language` tag (falls back to base tag, e.g. `en-US``en`) → `i18n.default`. |
| `makeT` | `(i18n: ResolvedI18n, lang: string) => TFunction` | Builds a translator resolving current language → default → the key itself, with `{param}` interpolation. |
### Types & constants
| Export | Kind | Notes |
| -------------- | ----------- | ------------------------------------------------------------------------------ |
| `Messages` | `type` | `Record<string, unknown>` — a locale's messages (supports nested/dotted keys). |
| `I18nConfig` | `interface` | `{ default?: string; locales?: string[] }`. |
| `ResolvedI18n` | `interface` | `{ default: string; langs: string[]; messages: Record<string, Messages> }`. |
| `LANG_COOKIE` | `const` | `"wire-lang"` — the cookie the language is read from / written to. |
| `I18N_JS_HREF` | `const` | `"/__wrnexus/i18n.js"` — URL the client runtime is served at. |
### HTML & client runtime
| Export | Signature | Description |
| ---------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `translateHtml` | `(html: string, t: TFunction) => string` | Rewrites markers in rendered HTML: `t:<attr>="key"``<attr>="<translation>"` (attribute-escaped) and `<tag data-t="key">…</tag>` → element text becomes the translation (HTML-escaped). No-op unless a marker is present. |
| `renderI18nData` | `(i18n: ResolvedI18n, lang: string) => string` | JS snippet setting `window.__wireI18n = { lang, langs, default }` for the client switcher. |
| `I18N_RUNTIME` | `const string` | Browser IIFE that binds `[data-wire-lang-set="es"]` clicks and `select[data-wire-lang]` changes to set the `wire-lang` cookie and reload. Exposes `window.__wireLang.set(lang)`. |
### Formatting helpers (re-exported from `./format.ts`)
| Export | Signature | Example |
| -------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
| `formatNumber` | `(value: number, lang: string, options?: Intl.NumberFormatOptions) => string` | `1234.5 → "1,234.5"` |
| `formatCurrency` | `(value: number, currency: string, lang: string) => string` | `9.99, "USD" → "$9.99"` |
| `formatDate` | `(value: Date \| number \| string, lang: string, options?: Intl.DateTimeFormatOptions) => string` | defaults to `{ dateStyle: "medium" }` |
| `formatRelativeTime` | `(value: number, unit: Intl.RelativeTimeFormatUnit, lang: string) => string` | `-3, "day" → "3 days ago"` (`numeric: "auto"`) |
| `plural` | `(count: number, forms: Partial<Record<Intl.LDMLPluralRule, string>>, lang: string) => string` | picks CLDR form; `#` is replaced by `count` |
## Usage
### Server: load, resolve, translate
```ts ```ts
import { import { loadLocales, makeT, resolveI18n, resolveLang } from "@wrnexus/i18n";
loadLocales,
resolveI18n,
resolveLang,
makeT,
translateHtml,
LANG_COOKIE,
} from "@wrnexus/i18n";
// app/locales/en.json, app/locales/es.json const i18n = resolveI18n(loadLocales("app/locales", { strict: true }), {
const messages = loadLocales("app/locales"); default: "en",
const i18n = resolveI18n(messages, { default: "en", locales: ["en", "es"] }); locales: ["en", "mr", "hi"],
fallbacks: { "mr-IN": ["mr", "en"] },
cookie: { name: "wire-lang", sameSite: "Lax", secure: true },
});
// Per request: const lang = resolveLang(i18n, cookieValue, request.headers.get("accept-language"));
const lang = resolveLang(i18n, req.cookies?.[LANG_COOKIE], req.headers.get("accept-language"));
const t = makeT(i18n, lang); const t = makeT(i18n, lang);
t("common.hello", { name: "Ajay" });
t("nav.home"); // dotted key → "Home"
t("greeting", { name: "Ada" }); // "Hello, {name}" → "Hello, Ada"
// After rendering a .wrn view, resolve translation markers in the HTML:
const finalHtml = translateHtml(renderedHtml, t);
``` ```
`app/locales/en.json`: ## Resolution behavior
```json - normalized BCP-47-style locale names
{ - cookie preference
"nav": { "home": "Home" }, - weighted `Accept-Language`
"greeting": "Hello, {name}" - wildcard language ranges
} - regional base fallback
``` - explicit fallback chains
- configured default language
- automatic RTL for Arabic, Hebrew, Persian, Urdu, and related languages
### Views: translation markers Locale JSON is size-limited and rejects prototype-pollution keys. Recursive namespace collisions are resolved safely.
## Views and runtime
```html ```html
<h1 data-t="nav.home">Home</h1> <h1 data-t="dashboard.title">Dashboard</h1>
<input t:placeholder="search.placeholder" /> <input t:placeholder="search.placeholder" />
``` ```
`translateHtml` replaces the element text for `data-t` and the attribute value for Text and translated attributes are resolved during SSR. Active/fallback messages are serialized safely for the language runtime, which rebinds `data-t` markers after client navigation.
any `t:<attr>` (e.g. `t:placeholder`, `t:aria-label`).
### Client: language switcher Enable `i18nPlugin()` to use:
```ts - `<LanguageSwitcher />`
import { renderI18nData, I18N_RUNTIME, I18N_JS_HREF } from "@wrnexus/i18n"; - `<LocaleStatus />`
// In the document <head>: `LanguageSwitcher` renders a native `select[data-wire-lang]`. The packaged runtime validates the
const head = ` selection against the configured locales, writes the configured language cookie, updates the
<script>${renderI18nData(i18n, lang)}</script> document `lang`/`dir` attributes, emits `wrnexus:language-change`, and reloads so the next SSR
<script src="${I18N_JS_HREF}"></script> request uses the same cookie. No application-owned browser script is required.
`;
// Serve I18N_RUNTIME at I18N_JS_HREF; then in markup: ## Formatting
// <button data-wire-lang-set="es">Español</button>
// <select data-wire-lang>…</select>
```
### Formatting - `formatNumber`
- `formatCurrency`
- `formatDate`
- `formatRelativeTime`
- `plural`
- `createLocaleFormatter`
- `translationCoverage`
Localization tooling can extract statically discoverable `t("key")`,
`i18n.t("key")`, and `data-i18n="key"` usage, compare every locale with a
reference, and create layout-stressing pseudo-locales:
```ts ```ts
import { import {
formatNumber, auditLocaleKeys,
formatCurrency, createPseudoLocale,
formatDate, extractTranslationKeysFromFiles,
formatRelativeTime,
plural,
} from "@wrnexus/i18n"; } from "@wrnexus/i18n";
formatNumber(1234.5, lang); // "1,234.5" const used = extractTranslationKeysFromFiles(sourceFiles);
formatCurrency(9.99, "USD", lang); // "$9.99" const coverage = auditLocaleKeys(messages, "en");
formatDate(Date.now(), lang); // "Jul 4, 2026" const enXA = createPseudoLocale(messages.en);
formatRelativeTime(-3, "day", lang); // "3 days ago" const arXB = createPseudoLocale(messages.en, { rtl: true });
plural(2, { one: "# item", other: "# items" }, lang); // "2 items"
``` ```
## Configuration Pseudo-localization preserves interpolation placeholders and markup tags. RTL
pseudo output uses Unicode direction controls, while runtime direction detection
`resolveI18n` accepts an `I18nConfig`: continues to derive `rtl` from Arabic and other RTL language subtags.
- `default` — fallback language; used when nothing else matches. Ignored if it has
no loaded messages, in which case the first supported language is used.
- `locales` — explicit supported-language list; defaults to the loaded locale names.
Language resolution order at request time (`resolveLang`): a supported `wire-lang`
cookie value → the first matching `Accept-Language` tag (or its base subtag) → the
resolved default.
## Requirements / Notes
- **Bun-only.** Locale loading uses `node:fs` (`existsSync`, `readdirSync`,
`readFileSync`) and `node:path`; formatting relies on the platform `Intl` APIs.
- Works with [`@wrnexus/core`](../core) — `TFunction` (the `t(key, params)` type)
comes from core, and the resolved translator is exposed as `ctx.t` / `ctx.lang`
in request handling.
- Nested message objects are supported: keys are looked up whole first, then split
on `.` to walk the object tree.
+37 -5
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/i18n", "name": "@wrnexus/i18n",
"version": "0.7.0", "version": "0.8.6",
"type": "module", "type": "module",
"description": "@wrnexus/i18n — part of the WrNexus framework.", "description": "Locale loading, fallback resolution, SSR/browser translations, formatters, and WRNexusJS language components.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/i18n"
},
"homepage": "https://wrnexusjs.dev/packages/i18n",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"i18n"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -18,12 +34,28 @@
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
} },
"./plugin": {
"types": "./dist/plugin.d.ts",
"import": "./dist/plugin.js"
},
"./components/*": "./components/*"
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "^0.7.0" "@wrnexus/core": "^0.8.6",
"@wrnexus/plugin": "^0.8.6",
"@wrnexus/ui": "^0.8.6"
},
"wrnexus": {
"plugin": {
"plugin": "./dist/plugin.js",
"export": "default",
"factory": true
}
}, },
"files": [ "files": [
"dist" "dist",
"README.md",
"components"
] ]
} }
+59
View File
@@ -130,3 +130,62 @@ app.use(jwtAuth({ secret: process.env.JWT_SECRET!, required: false }));
- Integrates with [`@wrnexus/core`](../core) for `Context`, `Middleware`, and - Integrates with [`@wrnexus/core`](../core) for `Context`, `Middleware`, and
`ctx.user`; it complements the framework's cookie/session auth with a `ctx.user`; it complements the framework's cookie/session auth with a
stateless bearer-token flow for API and mobile clients. stateless bearer-token flow for API and mobile clients.
## Access, refresh, scope, and cookie helpers
```ts
import {
createAccessToken,
createRefreshToken,
verifyAccessToken,
verifyRefreshToken,
extractBearerToken,
requireScopes,
jwtCookie,
} from "@wrnexus/jwt";
```
The helpers add explicit `type: "access" | "refresh"` claims, scope checks, refresh-token family metadata, no-store token responses, and secure cookie defaults. `__Host-` cookies are rejected unless they use `Path=/` and `Secure`; `SameSite=None` is rejected without `Secure`.
## 0.8 helper kit
```ts
import {
createTokenPair,
verifyAccessToken,
verifyRefreshToken,
extractBearerToken,
readJwtCookie,
jwtCookie,
clearJwtCookie,
requireScopes,
} from "@wrnexus/jwt";
const pair = await createTokenPair(user.id, {
accessSecret: process.env.JWT_ACCESS_SECRET!,
refreshSecret: process.env.JWT_REFRESH_SECRET!,
scopes: ["profile:read"],
family: sessionFamily,
});
```
The helper kit validates `__Host-` cookie invariants, cookie names and paths, `SameSite=None` security, typed access/refresh token types, scope requirements, and no-store token responses.
In addition to local HS256 secrets/keyrings, the package verifies standards-based
RS256 tokens through bounded remote JWKS caches:
```ts
import { createRemoteJwks, verifyJwtWithJwks } from "@wrnexus/jwt";
const jwks = createRemoteJwks("https://issuer.example/.well-known/jwks.json");
const claims = await verifyJwtWithJwks(token, jwks, {
issuer: "https://issuer.example",
audience: "my-api",
maxAge: 300,
});
```
JWKS URLs must use HTTPS. Responses have key-count/byte limits, accept only
RS256 signing RSA keys, deduplicate concurrent refreshes, cache imported public
keys, and force an immediate refresh for an unknown `kid` so issuer rotation
does not wait for cache expiry. Never use decoded-but-unverified claims for an
authorization decision.
+23 -3
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/jwt", "name": "@wrnexus/jwt",
"version": "0.7.0", "version": "0.8.6",
"type": "module", "type": "module",
"description": "@wrnexus/jwt — part of the WrNexus framework.", "description": "HS256 JSON Web Tokens, key rotation, access/refresh helpers, scopes, cookies, and auth middleware.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/jwt"
},
"homepage": "https://wrnexusjs.dev/packages/jwt",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"jwt"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -20,7 +36,11 @@
"import": "./dist/index.js" "import": "./dist/index.js"
} }
}, },
"dependencies": {
"@wrnexus/core": "^0.8.6"
},
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+8
View File
@@ -76,6 +76,14 @@ const status = network ? await network.getStatus() : { connected: true, connecti
Unavailable required plugins throw `MobileUnavailableError` with an actionable message. Unavailable required plugins throw `MobileUnavailableError` with an actionable message.
The package also provides portable application-facing primitives:
- `listenDeepLinks` normalizes initial and live links with an allowed-scheme list.
- `PushNotifications` performs permission gating and validates registrations.
- `SecureStorage` namespaces and validates keys over an application-supplied encrypted
Keychain/Keystore adapter; it does not mislabel browser `localStorage` as secure.
- `OfflineQueue` persists bounded sync batches through a pluggable durable store.
## Requirements / Notes ## Requirements / Notes
- Capacitor plugin imports must remain in browser-owned modules. - Capacitor plugin imports must remain in browser-owned modules.
+20 -3
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/mobile", "name": "@wrnexus/mobile",
"version": "0.7.0", "version": "0.8.6",
"type": "module", "type": "module",
"description": "@wrnexus/mobile — part of the WrNexus framework.", "description": "@wrnexus/mobile — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/mobile"
},
"homepage": "https://wrnexusjs.dev/packages/mobile",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"mobile"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -21,9 +37,10 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/native": "^0.7.0" "@wrnexus/native": "^0.8.6"
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+3
View File
@@ -79,6 +79,9 @@ const position = await native.run(
Built-ins include `camera`, `clipboard.write`, `share`, `geolocation`, `network`, Built-ins include `camera`, `clipboard.write`, `share`, `geolocation`, `network`,
`haptics`, storage, filesystem, notifications, and device information. `haptics`, storage, filesystem, notifications, and device information.
`defineNativeManifest` declares required capabilities and typed permissions, while
`PermissionManager` normalizes permission query/request flows across platform adapters.
## Requirements / Notes ## Requirements / Notes
Use `supports()` before showing optional controls. Mobile capabilities require their Use `supports()` before showing optional controls. Mobile capabilities require their
+19 -2
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/native", "name": "@wrnexus/native",
"version": "0.7.0", "version": "0.8.6",
"type": "module", "type": "module",
"description": "@wrnexus/native — part of the WrNexus framework.", "description": "@wrnexus/native — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/native"
},
"homepage": "https://wrnexusjs.dev/packages/native",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"native"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -29,6 +45,7 @@
} }
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+21
View File
@@ -194,3 +194,24 @@ const gitlab = defineProvider({
`verifier` between `startAuth` and `completeAuth` (session or signed cookie). `verifier` between `startAuth` and `completeAuth` (session or signed cookie).
- Pairs with [`@wrnexus/core`](../core) — feed the normalized `OAuthProfile` into - Pairs with [`@wrnexus/core`](../core) — feed the normalized `OAuthProfile` into
`logIn` to establish a session. `logIn` to establish a session.
OIDC integrations can combine strict discovery with the rotating JWKS verifier:
```ts
import { createRemoteJwks } from "@wrnexus/jwt";
import { discoverOidc, verifyOidcIdToken } from "@wrnexus/oauth";
const metadata = await discoverOidc("https://issuer.example");
const jwks = createRemoteJwks(metadata.jwks_uri);
const claims = await verifyOidcIdToken(idToken, {
issuer: metadata.issuer,
clientId: "client-id",
jwks,
nonce: expectedNonce,
accessToken,
});
```
Discovery requires an exact normalized issuer and HTTPS endpoints without URL
credentials/fragments. ID-token verification checks the RS256 signature,
expiry/not-before, issuer, audience, required OIDC claims, nonce, multi-audience
`azp`, optional token age, and optional `at_hash` binding.
+22 -2
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/oauth", "name": "@wrnexus/oauth",
"version": "0.7.0", "version": "0.8.6",
"type": "module", "type": "module",
"description": "@wrnexus/oauth — part of the WrNexus framework.", "description": "@wrnexus/oauth — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/oauth"
},
"homepage": "https://wrnexusjs.dev/packages/oauth",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"oauth"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -20,7 +36,11 @@
"import": "./dist/index.js" "import": "./dist/index.js"
} }
}, },
"dependencies": {
"@wrnexus/jwt": "^0.8.6"
},
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+53
View File
@@ -1,7 +1,60 @@
# @wrnexus/plugin # @wrnexus/plugin
## Least-privilege package permissions
Package manifests declare every framework capability they register:
```json
{
"wrnexus": {
"permissions": ["routes", "migrations"],
"routes": [{ "kind": "api", "path": "/api/example", "entry": "./route.ts" }]
}
}
```
Applications can enable fail-closed grants:
```ts
export default {
pluginPermissions: {
enforce: true,
grants: { "example-plugin": ["routes"] },
},
};
```
Discovery rejects used-but-undeclared capabilities with
`WRN-PLUGIN-PERMISSION-UNDECLARED` and ungranted capabilities with
`WRN-PLUGIN-PERMISSION-DENIED`. Permissions cover components, browser runtime,
assets, styles, routes, middleware, migrations, config, transforms,
diagnostics/tooling, and server/build hooks.
## Compatibility matrices
Manifests can add `compatibility: { bunMin: "1.3.0", os: ["linux",
"darwin"] }` alongside `runtimes` and `requires`. Use
`testPluginCompatibility(manifest, targets)` in a package test to exercise the
complete support matrix. Runtime discovery enforces the same Bun minimum, OS,
runtime, and capability declarations used by the test kit.
Deterministic WRNexusJS plugin contracts for configuration, AST/code transforms, Deterministic WRNexusJS plugin contracts for configuration, AST/code transforms,
diagnostics, development servers, production builds, and DevToolbar extensions. diagnostics, development servers, production builds, and DevToolbar extensions.
Use `definePlugin()` and declare `enforce`, `before`, or `after` when ordering matters. Use `definePlugin()` and declare `enforce`, `before`, or `after` when ordering matters.
Duplicate names and dependency cycles are rejected. Duplicate names and dependency cycles are rejected.
## Complete lifecycle and contributions
Plugins may implement `setup`, `configure`, `configResolved`, `transformAst`,
`transformCode`, `diagnostics`, `routes`, `configureServer`, `buildStart`,
`buildEnd`, `render`, `deploy`, `shutdown`, and `hmrUpdate`. The runner preserves
resolved plugin order for every hook and executes `setup` exactly once.
In addition to components, routes, middleware, assets, styles, runtimes, and
migrations, plugins can contribute `directives`, `cliCommands`,
`virtualModules`, `deploymentAdapters`, `configSchemas`, `documentation`, and
`typeDefinitions`. Names are collision checked. Configuration schemas run after
configuration resolution, CLI commands are callable as normal `wrnexus`
commands, directives participate in AST transformation, and production builds
materialize virtual modules and invoke matching contributed adapters.
+20 -3
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/plugin", "name": "@wrnexus/plugin",
"version": "0.7.0", "version": "0.8.6",
"type": "module", "type": "module",
"description": "@wrnexus/plugin — part of the WrNexus framework.", "description": "@wrnexus/plugin — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/plugin"
},
"homepage": "https://wrnexusjs.dev/packages/plugin",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"plugin"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -33,9 +49,10 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/syntax": "^0.7.0" "@wrnexus/syntax": "^0.8.6"
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+9 -4
View File
@@ -31,13 +31,15 @@ Creates a bus over a driver. Defaults to `memoryDriver()` (in-process).
interface PubSub { interface PubSub {
publish<T = unknown>(topic: string, message: T): Promise<void>; publish<T = unknown>(topic: string, message: T): Promise<void>;
subscribe<T = unknown>(pattern: string, handler: Handler<T>): () => void; subscribe<T = unknown>(pattern: string, handler: Handler<T>): () => void;
close(): Promise<void>;
} }
type Handler<T = unknown> = (message: T, topic: string) => void | Promise<void>; type Handler<T = unknown> = (message: T, topic: string) => void | Promise<void>;
``` ```
- `publish(topic, message)` — resolves once the driver has dispatched the message. - `publish(topic, message)` — resolves once the driver and in-memory async handlers finish.
- `subscribe(pattern, handler)` — returns an unsubscribe function. - `subscribe(pattern, handler)` — returns an unsubscribe function.
- `close()` — idempotently rejects new work, clears local subscriptions, and closes the driver.
### Pattern matching ### Pattern matching
@@ -67,7 +69,7 @@ then `redis://localhost:6379`. The URL may carry a password and a database index
(e.g. `redis://:secret@host:6379/2`). (e.g. `redis://:secret@host:6379/2`).
```ts ```ts
function redisDriver(url?: string): PubSubDriver & { close(): void }; function redisDriver(url?: string, options?: RedisDriverOptions): PubSubDriver & { close(): void };
``` ```
- Exact topics use Redis `SUBSCRIBE`; wildcard patterns (`ns:*`, `*`) use - Exact topics use Redis `SUBSCRIBE`; wildcard patterns (`ns:*`, `*`) use
@@ -75,6 +77,9 @@ function redisDriver(url?: string): PubSubDriver & { close(): void };
- Messages are JSON-stringified on publish and `JSON.parse`d on receipt; a payload - Messages are JSON-stringified on publish and `JSON.parse`d on receipt; a payload
that isn't valid JSON is delivered as the raw string. that isn't valid JSON is delivered as the raw string.
- `close()` tears down both the subscriber and publisher connections. - `close()` tears down both the subscriber and publisher connections.
- Lost sockets reconnect with bounded exponential backoff and active subscriptions
are replayed. `maxPending` bounds unavailable-connection writes (default 1000);
`reconnectDelayMs` and `reconnectMaxDelayMs` tune recovery (100ms/5000ms).
### RESP codec (internal) ### RESP codec (internal)
@@ -115,8 +120,8 @@ bus.subscribe("order:*", (msg, topic) => {
await bus.publish("order:created", { id: 7 }); await bus.publish("order:created", { id: 7 });
// on shutdown // on shutdown (also closes the driver)
driver.close(); await bus.close();
``` ```
## Requirements / Notes ## Requirements / Notes
+23 -2
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/pubsub", "name": "@wrnexus/pubsub",
"version": "0.7.0", "version": "0.8.6",
"type": "module", "type": "module",
"description": "@wrnexus/pubsub — part of the WrNexus framework.", "description": "@wrnexus/pubsub — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/pubsub"
},
"homepage": "https://wrnexusjs.dev/packages/pubsub",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"pubsub"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -19,12 +35,17 @@
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
}, },
"./brokers": {
"types": "./dist/brokers.d.ts",
"import": "./dist/brokers.js"
},
"./redis": { "./redis": {
"types": "./dist/redis.d.ts", "types": "./dist/redis.d.ts",
"import": "./dist/redis.js" "import": "./dist/redis.js"
} }
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+61 -14
View File
@@ -43,33 +43,45 @@ function createQueue(options?: QueueOptions): Queue;
| `backoffMs` | `number` | `1000` | Base retry backoff in ms; doubles per attempt. | | `backoffMs` | `number` | `1000` | Base retry backoff in ms; doubles per attempt. |
| `pollMs` | `number` | `250` | Poll interval used once `start()` is called (ms). | | `pollMs` | `number` | `250` | Poll interval used once `start()` is called (ms). |
| `onFailed` | `(job: Job, error: unknown) => void` | — | Called when a job exhausts its attempts. | | `onFailed` | `(job: Job, error: unknown) => void` | — | Called when a job exhausts its attempts. |
| `concurrency` | `number` | unlimited | Maximum jobs claimed by one `drain()` call. |
| `capacity` | `number` | unlimited | Maximum queued plus active jobs before adds reject. |
| `now` | `() => number` | `Date.now` | Clock injection for deterministic tests. | | `now` | `() => number` | `Date.now` | Clock injection for deterministic tests. |
### `Queue` ### `Queue`
The object returned by `createQueue`. The object returned by `createQueue`.
| Method | Signature | Description | | Method | Signature | Description |
| --------- | -------------------------------------------------------------- | -------------------------------------------------------------- | | ---------- | -------------------------------------------------------------- | -------------------------------------------------------------- |
| `add` | `add<T>(name, data: T, options?: AddOptions): Promise<Job<T>>` | Enqueue a job under a worker name. Returns the created job. | | `add` | `add<T>(name, data: T, options?: AddOptions): Promise<Job<T>>` | Enqueue a job under a worker name. Returns the created job. |
| `process` | `process<T>(name, handler: JobHandler<T>): void` | Register the worker that runs jobs of the given name. | | `process` | `process<T>(name, handler: JobHandler<T>): void` | Register the worker that runs jobs of the given name. |
| `drain` | `drain(now?: number): Promise<number>` | Run every job whose `runAt ≤ now`, once. Returns how many ran. | | `drain` | `drain(now?: number): Promise<number>` | Run every job whose `runAt ≤ now`, once. Returns how many ran. |
| `start` | `start(): void` | Begin polling every `pollMs`. No-op if already started. | | `start` | `start(): void` | Begin polling every `pollMs`. No-op if already started. |
| `stop` | `stop(): void` | Stop the poll timer. | | `stop` | `stop(): void` | Stop the poll timer. |
| `size` | `size(): number` | Number of jobs currently queued. | | `shutdown` | `shutdown({ force? }): Promise<void>` | Stop accepting jobs and await active work; force aborts it. |
| `size` | `size(): number` | Number of jobs currently queued. |
| `get/list` | `get(id)` / `list(name?)` | Inspect defensive copies of pending jobs. |
| `cancel` | `cancel(id): boolean` | Remove queued work or abort an active handler. |
| `failed` | `failed(): Job[]` | Inspect exhausted jobs in the dead-letter collection. |
| `retry` | `retry(id): Promise<boolean>` | Reset and requeue a dead-lettered job. |
#### `AddOptions` #### `AddOptions`
| Option | Type | Description | | Option | Type | Description |
| ------------- | -------- | -------------------------------------------------------------------------- | | ---------------- | -------- | -------------------------------------------------------------------------- |
| `delayMs` | `number` | Delay before the job becomes runnable (ms). | | `delayMs` | `number` | Delay before the job becomes runnable (ms). |
| `maxAttempts` | `number` | Max attempts before dead-lettering. Defaults to the queue's `maxAttempts`. | | `maxAttempts` | `number` | Max attempts before dead-lettering. Defaults to the queue's `maxAttempts`. |
| `repeat` | `number` | Re-enqueue this job this many ms after each successful run (recurring). | | `repeat` | `number` | Re-enqueue this job this many ms after each successful run (recurring). |
| `priority` | `number` | Higher values are selected first among due jobs. |
| `idempotencyKey` | `string` | Return the matching pending job instead of enqueueing a duplicate. |
#### `JobHandler<T>` #### `JobHandler<T>`
```ts ```ts
type JobHandler<T = unknown> = (job: Job<T>) => void | Promise<void>; type JobHandler<T = unknown> = (
job: Job<T>,
context: { signal: AbortSignal },
) => void | Promise<void>;
``` ```
#### `Job<T>` #### `Job<T>`
@@ -106,6 +118,18 @@ await queue.add("email", { to: "a@b.com" }, { delayMs: 5000, maxAttempts: 3 });
queue.start(); // begin polling; queue.stop() to halt queue.start(); // begin polling; queue.stop() to halt
``` ```
Use `context.signal` in network/database calls so forced shutdown and active
cancellation finish promptly. For process termination, prefer
`await queue.shutdown()`; use `{ force: true }` only after your grace period.
### Durable queue
`createDurableQueue({ store })` retains jobs until their handler succeeds and
supports atomic leases when a driver implements `QueueStore.claim`. It exposes
the same cancellation/shutdown behavior plus `list`, `failed`, and `retry`.
The included `memoryQueueStore()` is useful for tests; production Redis/SQL
drivers should make `claim()` atomic to prevent two workers executing one job.
### Recurring jobs ### Recurring jobs
Pass `repeat` to re-enqueue a job a fixed interval after each successful run: Pass `repeat` to re-enqueue a job a fixed interval after each successful run:
@@ -145,6 +169,29 @@ clock = 5000;
const ran = await queue.drain(); // => 1 const ran = await queue.drain(); // => 1
``` ```
### Durable workflows and approvals
`createWorkflowEngine(store)` executes dependency-ordered steps and persists every transition,
result, progress update, failure, cancellation, and approval record. Approval steps pause safely
and can resume after a process restart because the snapshot lives in the supplied `WorkflowStore`.
```ts
const workflow = defineDurableWorkflow({
name: "publish-report",
steps: [
{ name: "build", run: buildReport },
{ name: "approve", dependsOn: ["build"], approval: true, run: (report) => report },
{ name: "publish", dependsOn: ["approve"], run: publishReport },
],
});
const run = await engine.start(workflow, input);
await engine.approve(workflow, run.id, "approve", currentUser.id);
```
Use `memoryWorkflowStore()` for tests. Production stores implement the small `get`, `put`, and
`list` contract using the same transactional database or durable service as the application.
## Retry & backoff behavior ## Retry & backoff behavior
- On a thrown handler error, the job is retried while `attempts < maxAttempts`. - On a thrown handler error, the job is retried while `attempts < maxAttempts`.
+22 -2
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/queue", "name": "@wrnexus/queue",
"version": "0.7.0", "version": "0.8.6",
"type": "module", "type": "module",
"description": "@wrnexus/queue — part of the WrNexus framework.", "description": "@wrnexus/queue — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/queue"
},
"homepage": "https://wrnexusjs.dev/packages/queue",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"queue"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -20,7 +36,11 @@
"import": "./dist/index.js" "import": "./dist/index.js"
} }
}, },
"dependencies": {
"@wrnexus/core": "^0.8.6"
},
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+19 -2
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/reactive", "name": "@wrnexus/reactive",
"version": "0.7.0", "version": "0.8.6",
"type": "module", "type": "module",
"description": "@wrnexus/reactive — part of the WrNexus framework.", "description": "@wrnexus/reactive — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/reactive"
},
"homepage": "https://wrnexusjs.dev/packages/reactive",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"reactive"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -21,6 +37,7 @@
} }
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+21 -4
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/router", "name": "@wrnexus/router",
"version": "0.7.0", "version": "0.8.6",
"type": "module", "type": "module",
"description": "@wrnexus/router — part of the WrNexus framework.", "description": "@wrnexus/router — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/router"
},
"homepage": "https://wrnexusjs.dev/packages/router",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"router"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -21,10 +37,11 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/compiler": "^0.7.0", "@wrnexus/compiler": "^0.8.6",
"@wrnexus/core": "^0.7.0" "@wrnexus/core": "^0.8.6"
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+22 -5
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/ssr", "name": "@wrnexus/ssr",
"version": "0.7.0", "version": "0.8.6",
"type": "module", "type": "module",
"description": "@wrnexus/ssr — part of the WrNexus framework.", "description": "@wrnexus/ssr — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/ssr"
},
"homepage": "https://wrnexusjs.dev/packages/ssr",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"ssr"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -29,11 +45,12 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "^0.7.0", "@wrnexus/core": "^0.8.6",
"@wrnexus/store": "^0.7.0", "@wrnexus/store": "^0.8.6",
"@wrnexus/security": "^0.7.0" "@wrnexus/security": "^0.8.6"
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+21
View File
@@ -1,5 +1,26 @@
# @wrnexus/styles # @wrnexus/styles
## Reusable layers and presets
Compose local or package foundations in order; later layers override earlier
ones and the application has final base-config precedence:
```ts
export default defineConfig({
extends: ["@workroot/wrnexus-enterprise", "./layers/company"],
profiles: { production: { port: 8080 } },
});
```
A directory layer exports `wrnexus.layer.ts` (JavaScript/MJS are supported).
A package can provide that conventional file or declare
`wrnexus.layer` in its `package.json`. Layers may extend other layers and carry
the complete app configuration, including plugins that contribute layouts,
components, routes, middleware, and migrations. `plugins` and `head` compose;
other arrays intentionally replace earlier values. Cycles and missing/invalid
entries fail with stable `WRN-CONFIG-LAYER-*` diagnostics. `wrnexus config
--explain` lists every resolved layer source.
> Global CSS bundling, the `--wire-*` design-token theme system, and the `wrnexus.config.ts` app-config loader for WrNexus apps. > Global CSS bundling, the `--wire-*` design-token theme system, and the `wrnexus.config.ts` app-config loader for WrNexus apps.
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework. Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
+22 -5
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/styles", "name": "@wrnexus/styles",
"version": "0.7.0", "version": "0.8.6",
"type": "module", "type": "module",
"description": "@wrnexus/styles — part of the WrNexus framework.", "description": "@wrnexus/styles — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/styles"
},
"homepage": "https://wrnexusjs.dev/packages/styles",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"styles"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -21,11 +37,12 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/uploader": "^0.7.0", "@wrnexus/uploader": "^0.8.6",
"@wrnexus/core": "^0.7.0", "@wrnexus/core": "^0.8.6",
"@wrnexus/plugin": "^0.7.0" "@wrnexus/plugin": "^0.8.6"
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+23 -2
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/syntax", "name": "@wrnexus/syntax",
"version": "0.7.0", "version": "0.8.6",
"type": "module", "type": "module",
"description": "@wrnexus/syntax — part of the WrNexus framework.", "description": "@wrnexus/syntax — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/syntax"
},
"homepage": "https://wrnexusjs.dev/packages/syntax",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"syntax"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -38,9 +54,14 @@
"./spec": { "./spec": {
"types": "./dist/spec.d.ts", "types": "./dist/spec.d.ts",
"import": "./dist/spec.js" "import": "./dist/spec.js"
},
"./formatter": {
"types": "./dist/formatter.d.ts",
"import": "./dist/formatter.js"
} }
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+16
View File
@@ -103,6 +103,22 @@ Remember to `await app.close()` when done.
## Usage ## Usage
The CLI supports focused suites by file or directory convention:
```bash
wrnexus test unit # *.unit.test.ts or test/unit/**
wrnexus test component # *.component.test.ts or test/component/**
wrnexus test api # *.api.test.ts or test/api/**
wrnexus test accessibility # *.a11y.test.ts / *.accessibility.test.ts
wrnexus test performance # *.performance.test.ts / *.benchmark.test.ts
wrnexus test browser # Playwright project when configured
wrnexus test visual # Playwright tests tagged @visual
```
Pass the application directory after the level, for example
`wrnexus test component examples/basic-app`. A focused command fails clearly when no matching
suite exists instead of silently running unrelated tests.
```ts ```ts
import { test, expect, renderComponent, mountHtml, createHarness } from "@wrnexus/test"; import { test, expect, renderComponent, mountHtml, createHarness } from "@wrnexus/test";
+19 -2
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/test", "name": "@wrnexus/test",
"version": "0.7.0", "version": "0.8.6",
"type": "module", "type": "module",
"description": "@wrnexus/test — part of the WrNexus framework.", "description": "@wrnexus/test — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/test"
},
"homepage": "https://wrnexusjs.dev/packages/test",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"test"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -21,6 +37,7 @@
} }
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+19 -2
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/tracking", "name": "@wrnexus/tracking",
"version": "0.7.0", "version": "0.8.6",
"type": "module", "type": "module",
"description": "@wrnexus/tracking — part of the WrNexus framework.", "description": "@wrnexus/tracking — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/tracking"
},
"homepage": "https://wrnexusjs.dev/packages/tracking",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"tracking"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -21,6 +37,7 @@
} }
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+49 -49
View File
@@ -229,7 +229,7 @@ Present structured responsive linked or status items with icons, descriptions, a
- Mount: `data-component="List"` - Mount: `data-component="List"`
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "List"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""` - Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "List"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
- Slots: `default` - Slots: `default`
- Outputs: None - Outputs: `select({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
### ListGroup ### ListGroup
@@ -247,7 +247,7 @@ Continuously present responsive labels, partners, notices, or capabilities with
- Mount: `data-component="Marquee"` - Mount: `data-component="Marquee"`
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Marquee"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""` - Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Marquee"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
- Slots: `default` - Slots: `default`
- Outputs: None - Outputs: `pause({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `resume({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
### Progress ### Progress
@@ -301,7 +301,7 @@ Present responsive chronological activity, milestones, or workflow status with r
- Mount: `data-component="Timeline"` - Mount: `data-component="Timeline"`
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Timeline"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""` - Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Timeline"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
- Slots: `default` - Slots: `default`
- Outputs: None - Outputs: `select({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
### Toast ### Toast
@@ -359,6 +359,15 @@ Reusable preference switcher component.
- Slots: None - Slots: None
- Outputs: `theme({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `color({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `language({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])` - Outputs: `theme({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `color({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `language({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`
### Toaster
Reusable toaster component.
- Mount: `data-component="Toaster"`
- Props: `color: string = "info"`, `size: string = "default"`, `position: string = "bottom-right"`, `duration: number = 4500`, `max: number = 4`, `pauseOnHover: boolean = true`, `showIcon: boolean = true`, `successIcon: string = ""`, `dangerIcon: string = ""`, `warningIcon: string = ""`, `infoIcon: string = ""`, `closable: boolean = true`, `showProgress: boolean = true`, `closeLabel: string = "Dismiss notification"`, `class: string = ""`
- Slots: None
- Outputs: `show({ id: number; message: string; tone: string })`, `dismiss({ id: number; reason: string })`, `action({ id: number; sourceEvent: Event })`
## Data ## Data
### MetricCard ### MetricCard
@@ -460,7 +469,7 @@ Provide an accessible responsive search field with labels, validation states, si
- Mount: `data-component="SearchBox"` - Mount: `data-component="SearchBox"`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Search Box"`, `name: string = ""`, `value: string = ""`, `placeholder: string = ""`, `type: string = "search"`, `min: string = ""`, `max: string = ""`, `step: string = ""`, `disabled: boolean = false`, `required: boolean = false`, `class: string = ""` - Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Search Box"`, `name: string = ""`, `value: string = ""`, `placeholder: string = ""`, `type: string = "search"`, `min: string = ""`, `max: string = ""`, `step: string = ""`, `disabled: boolean = false`, `required: boolean = false`, `class: string = ""`
- Slots: None - Slots: None
- Outputs: None - Outputs: `search({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `clear({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
### Select ### Select
@@ -554,15 +563,6 @@ Theme-aware, responsive data map component.
- Slots: `default` - Slots: `default`
- Outputs: `select({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `change({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)` - Outputs: `select({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `change({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`
### DataTable
Theme-aware, responsive data table component.
- Mount: `data-component="DataTable"`
- Props: `size: string = "default"`, `color: string = "primary"`, `caption: string = "Data Table"`, `columns: unknown[] = []`, `rows: unknown[] = []`, `striped: boolean = true`, `class: string = ""`
- Slots: `default`
- Outputs: `sort({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `select({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `change({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `rowClick({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `pageChange({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`
### DragAndDrop ### DragAndDrop
Theme-aware, responsive drag and drop component. Theme-aware, responsive drag and drop component.
@@ -588,7 +588,7 @@ Present responsive location information and markers with map-ready metadata and
- Mount: `data-component="Map"` - Mount: `data-component="Map"`
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Map"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""` - Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Map"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
- Slots: `default` - Slots: `default`
- Outputs: None - Outputs: `markerClick({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `select({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `zoom({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
### ToastNotifications ### ToastNotifications
@@ -624,7 +624,7 @@ Create responsive balanced content columns with configurable count, gap, density
Constrain and align page content with responsive gutters and compact, wide, or full width options. Constrain and align page content with responsive gutters and compact, wide, or full width options.
- Mount: `data-component="Container"` - Mount: `data-component="Container"`
- Props: `size: string = "default"`, `color: string = "primary"`, `columns: number = 2`, `gap: string = "md"`, `maxWidth: string = "xl"`, `class: string = ""` - Props: `size: string = "default"`, `color: string = "primary"`, `columns: number = 2`, `gap: string = "md"`, `maxWidth: string = "xl"`, `centered: boolean = true`, `class: string = ""`
- Slots: `default` - Slots: `default`
- Outputs: None - Outputs: None
@@ -633,16 +633,16 @@ Constrain and align page content with responsive gutters and compact, wide, or f
Theme-aware, responsive custom scrollbar component. Theme-aware, responsive custom scrollbar component.
- Mount: `data-component="CustomScrollbar"` - Mount: `data-component="CustomScrollbar"`
- Props: `size: string = "default"`, `color: string = "primary"`, `columns: number = 2`, `gap: string = "md"`, `maxWidth: string = "xl"`, `class: string = ""` - Props: `color: string = "primary"`, `size: string = "default"`, `axis: string = "vertical"`, `thickness: number = 8`, `maxHeight: string = "20rem"`, `radius: string = "999px"`, `class: string = ""`
- Slots: `default` - Slots: `default`
- Outputs: `scroll({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])` - Outputs: None
### Divider ### Divider
Separate related horizontal or vertical content with optional labels, sizes, and semantic colors. Separate related horizontal or vertical content with optional labels, sizes, and semantic colors.
- Mount: `data-component="Divider"` - Mount: `data-component="Divider"`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = ""`, `orientation: string = "horizontal"`, `class: string = ""` - Props: `size: string = "default"`, `color: string = "primary"`, `label: string = ""`, `orientation: string = "horizontal"`, `variant: string = "solid"`, `class: string = ""`
- Slots: None - Slots: None
- Outputs: None - Outputs: None
@@ -669,7 +669,7 @@ Render structured responsive footer navigation, pre and post content, copyright
Arrange arbitrary content in a responsive configurable CSS grid with stable columns, gaps, alignment, and width. Arrange arbitrary content in a responsive configurable CSS grid with stable columns, gaps, alignment, and width.
- Mount: `data-component="Grid"` - Mount: `data-component="Grid"`
- Props: `size: string = "default"`, `color: string = "primary"`, `columns: number = 2`, `gap: string = "md"`, `maxWidth: string = "xl"`, `class: string = ""` - Props: `size: string = "default"`, `color: string = "primary"`, `columns: number = 2`, `gap: string = "md"`, `maxWidth: string = "xl"`, `minItemWidth: string = ""`, `class: string = ""`
- Slots: `default` - Slots: `default`
- Outputs: None - Outputs: None
@@ -678,7 +678,7 @@ Arrange arbitrary content in a responsive configurable CSS grid with stable colu
Render a responsive image with explicit dimensions, loading behavior, alternative text, sizing, and rounded treatment. Render a responsive image with explicit dimensions, loading behavior, alternative text, sizing, and rounded treatment.
- Mount: `data-component="Image"` - Mount: `data-component="Image"`
- Props: `size: string = "default"`, `color: string = "primary"`, `src: string = ""`, `alt: string = ""`, `width: string = ""`, `height: string = ""`, `loading: string = "lazy"`, `rounded: boolean = false`, `class: string = ""` - Props: `size: string = "default"`, `color: string = "primary"`, `src: string = ""`, `alt: string = ""`, `width: string = ""`, `height: string = ""`, `loading: string = "lazy"`, `fit: string = "cover"`, `rounded: boolean = false`, `class: string = ""`
- Slots: `default` - Slots: `default`
- Outputs: None - Outputs: None
@@ -687,7 +687,7 @@ Render a responsive image with explicit dimensions, loading behavior, alternativ
Theme-aware, responsive kbd component. Theme-aware, responsive kbd component.
- Mount: `data-component="Kbd"` - Mount: `data-component="Kbd"`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "K"`, `class: string = ""` - Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "K"`, `class: string = ""`
- Slots: `default` - Slots: `default`
- Outputs: None - Outputs: None
@@ -696,16 +696,16 @@ Theme-aware, responsive kbd component.
Theme-aware, responsive layout splitter component. Theme-aware, responsive layout splitter component.
- Mount: `data-component="LayoutSplitter"` - Mount: `data-component="LayoutSplitter"`
- Props: `size: string = "default"`, `color: string = "primary"`, `columns: number = 2`, `gap: string = "md"`, `maxWidth: string = "xl"`, `class: string = ""` - Props: `color: string = "primary"`, `size: number = 50`, `orientation: string = "horizontal"`, `minSize: number = 15`, `step: number = 5`, `label: string = "Resize panels"`, `class: string = ""`
- Slots: `default` - Slots: `start`, `end`, `default`
- Outputs: `resizeStart({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `resize({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `resizeEnd({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])` - Outputs: `sizeChange({ size: number })`
### Link ### Link
Render an accessible internal or external link with target, relation, size, color, and public focus or click events. Render an accessible internal or external link with target, relation, size, color, and public focus or click events.
- Mount: `data-component="Link"` - Mount: `data-component="Link"`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Link"`, `href: string = "#"`, `target: string = ""`, `rel: string = ""`, `external: boolean = false`, `class: string = ""` - Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Link"`, `href: string = "#"`, `target: string = ""`, `rel: string = ""`, `underline: string = "hover"`, `external: boolean = false`, `class: string = ""`
- Slots: `default` - Slots: `default`
- Outputs: None - Outputs: None
@@ -862,18 +862,18 @@ Show responsive hierarchical navigation with home support, separators, current-p
Theme-aware, responsive mega menu component. Theme-aware, responsive mega menu component.
- Mount: `data-component="MegaMenu"` - Mount: `data-component="MegaMenu"`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Mega Menu"`, `items: unknown[] = []`, `active: string = ""`, `orientation: string = "horizontal"`, `class: string = ""` - Props: `color: string = "primary"`, `size: string = "default"`, `label: string = "Menu"`, `icon: string = ""`, `columns: unknown[] = []`, `footer: string = ""`, `defaultOpen: boolean = false`, `class: string = ""`
- Slots: `default` - Slots: `default`
- Outputs: `open({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `close({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `select({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)` - Outputs: `open({ sourceEvent: Event })`, `close({ reason: string })`, `select({ item: object; value: string })`
### Nav ### Nav
Theme-aware, responsive nav component. Theme-aware, responsive nav component.
- Mount: `data-component="Nav"` - Mount: `data-component="Nav"`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Nav"`, `items: unknown[] = []`, `active: string = ""`, `orientation: string = "horizontal"`, `class: string = ""` - Props: `color: string = "primary"`, `size: string = "default"`, `items: unknown[] = []`, `active: string = ""`, `orientation: string = "horizontal"`, `label: string = "Main"`, `collapsible: boolean = true`, `toggleLabel: string = "Menu"`, `class: string = ""`
- Slots: `default` - Slots: `default`
- Outputs: `select({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `change({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)` - Outputs: `select({ item: object; value: string })`
### Navbar ### Navbar
@@ -889,45 +889,45 @@ Theme-aware, responsive navbar component.
Theme-aware, responsive pagination component. Theme-aware, responsive pagination component.
- Mount: `data-component="Pagination"` - Mount: `data-component="Pagination"`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Pagination"`, `items: unknown[] = []`, `active: string = ""`, `orientation: string = "horizontal"`, `class: string = ""` - Props: `color: string = "primary"`, `size: string = "default"`, `page: number = 1`, `pageSize: number = 10`, `total: number = 0`, `variant: string = "compact"`, `siblingCount: number = 1`, `showSummary: boolean = true`, `label: string = "Pagination"`, `previousLabel: string = "Previous"`, `nextLabel: string = "Next"`, `class: string = ""`
- Slots: `default` - Slots: `default`
- Outputs: `change({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `previous({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `next({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })` - Outputs: `change({ page: number; pageSize: number })`, `previous({ page: number })`, `next({ page: number })`
### Scrollspy ### Scrollspy
Theme-aware, responsive scrollspy component. Theme-aware, responsive scrollspy component.
- Mount: `data-component="Scrollspy"` - Mount: `data-component="Scrollspy"`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Scrollspy"`, `items: unknown[] = []`, `active: string = ""`, `orientation: string = "horizontal"`, `class: string = ""` - Props: `color: string = "primary"`, `size: string = "default"`, `items: unknown[] = []`, `active: string = ""`, `label: string = "On this page"`, `heading: string = ""`, `class: string = ""`
- Slots: `default` - Slots: `default`
- Outputs: `change({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)` - Outputs: `change({ href: string; label: string })`
### Sidebar ### Sidebar
Theme-aware, responsive sidebar component. Theme-aware, responsive sidebar component.
- Mount: `data-component="Sidebar"` - Mount: `data-component="Sidebar"`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Sidebar"`, `items: unknown[] = []`, `active: string = ""`, `orientation: string = "horizontal"`, `mobileLabel: string = "Open navigation"`, `class: string = ""` - Props: `color: string = "primary"`, `size: string = "default"`, `label: string = "Sidebar"`, `items: unknown[] = []`, `active: string = ""`, `mobileLabel: string = "Open navigation"`, `drawerTitle: string = "Navigation"`, `class: string = ""`
- Slots: `default` - Slots: `default`, `drawer`
- Outputs: `toggle({ open: boolean })`, `open({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `close({ source: string })`, `select({ item: string | number | boolean | null | object; value: string | number | boolean; level: string })` - Outputs: `toggle({ open: boolean })`, `open({ sourceEvent: Event })`, `close({ source: string })`, `select({ item: object; value: string; level: number })`
### Stepper ### Stepper
Theme-aware, responsive stepper component. Theme-aware, responsive stepper component.
- Mount: `data-component="Stepper"` - Mount: `data-component="Stepper"`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Stepper"`, `items: unknown[] = []`, `active: string = ""`, `orientation: string = "horizontal"`, `class: string = ""` - Props: `color: string = "primary"`, `size: string = "default"`, `steps: unknown[] = []`, `active: number = 0`, `orientation: string = "horizontal"`, `clickable: boolean = false`, `label: string = "Progress"`, `showPanel: boolean = false`, `controls: boolean = false`, `allowSkip: boolean = false`, `nextDisabled: boolean = false`, `backLabel: string = "Back"`, `nextLabel: string = "Next"`, `skipLabel: string = "Skip"`, `finishLabel: string = "Finish"`, `class: string = ""`
- Slots: `default` - Slots: `step-{index}`, `panel-{index}`, `default`
- Outputs: `change({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `previous({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `next({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `complete({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })` - Outputs: `change({ index: number; step: object })`, `back({ index: number; step: object })`, `next({ index: number; step: object })`, `skip({ index: number; step: object })`, `finish({ index: number; step: object })`
### Tabs ### Tabs
Switch between related responsive content panels with horizontal or vertical orientation and selection events. Switch between related responsive content panels with horizontal or vertical orientation and selection events.
- Mount: `data-component="Tabs"` - Mount: `data-component="Tabs"`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Tabs"`, `items: unknown[] = []`, `active: string = ""`, `orientation: string = "horizontal"`, `class: string = ""` - Props: `color: string = "primary"`, `size: string = "default"`, `items: unknown[] = []`, `active: string = ""`, `orientation: string = "horizontal"`, `mode: string = "client"`, `param: string = "tab"`, `label: string = "Tabs"`, `class: string = ""`
- Slots: `default` - Slots: `panel-{valueOf(item, index)}`, `default`
- Outputs: None - Outputs: `change({ value: string; item: object; index: number })`, `select({ value: string; item: object; index: number })`
## Overlays ## Overlays
@@ -945,7 +945,7 @@ Open an accessible keyboard-aware action menu from pointer or keyboard context i
Present responsive modal side or bottom content with focus management, backdrop behavior, slots, and close events. Present responsive modal side or bottom content with focus management, backdrop behavior, slots, and close events.
- Mount: `data-component="Drawer"` - Mount: `data-component="Drawer"`
- Props: `open: boolean = false`, `defaultOpen: boolean = false`, `placement: string = "right"`, `size: string = "md"`, `color: string = "primary"`, `variant: string = "default"`, `title: string = "Drawer"`, `description: string = ""`, `icon: string = ""`, `label: string = "Drawer"`, `closeLabel: string = "Close drawer"`, `showClose: boolean = true`, `closeOnBackdrop: boolean = true`, `closeOnEscape: boolean = true`, `overlay: boolean = true`, `scrollable: boolean = true`, `triggerLabel: string = ""`, `triggerIcon: string = ""`, `class: string = ""` - Props: `open: boolean = false`, `defaultOpen: boolean = false`, `placement: string = "right"`, `size: string = "md"`, `color: string = "primary"`, `variant: string = "default"`, `title: string = "Drawer"`, `description: string = ""`, `icon: string = ""`, `label: string = "Drawer"`, `closeLabel: string = "Close drawer"`, `showClose: boolean = true`, `closeOnBackdrop: boolean = true`, `closeOnEscape: boolean = true`, `duration: number = 260`, `overlay: boolean = true`, `scrollable: boolean = true`, `triggerLabel: string = ""`, `triggerIcon: string = ""`, `class: string = ""`
- Slots: `trigger`, `header`, `default`, `footer` - Slots: `trigger`, `header`, `default`, `footer`
- Outputs: `open({ placement: string; sourceEvent: Event })`, `close({ reason: string; placement: string; sourceEvent: Event })`, `cancel({ placement: string; sourceEvent: Event })` - Outputs: `open({ placement: string; sourceEvent: Event })`, `close({ reason: string; placement: string; sourceEvent: Event })`, `cancel({ placement: string; sourceEvent: Event })`
@@ -963,7 +963,7 @@ Open an accessible anchored menu with keyboard navigation, item selection, actio
Present an accessible modal dialog with focus management, confirmation, cancellation, slots, and responsive sizing. Present an accessible modal dialog with focus management, confirmation, cancellation, slots, and responsive sizing.
- Mount: `data-component="Modal"` - Mount: `data-component="Modal"`
- Props: `open: boolean = false`, `defaultOpen: boolean = false`, `title: string = "Modal"`, `description: string = ""`, `icon: string = ""`, `label: string = "Modal dialog"`, `size: string = "md"`, `placement: string = "center"`, `color: string = "primary"`, `variant: string = "default"`, `showClose: boolean = true`, `closeLabel: string = "Close modal"`, `closeOnBackdrop: boolean = true`, `closeOnEscape: boolean = true`, `closeOnCancel: boolean = true`, `closeOnConfirm: boolean = false`, `showFooter: boolean = true`, `cancelLabel: string = "Cancel"`, `cancelIcon: string = ""`, `confirmLabel: string = "Confirm"`, `confirmIcon: string = ""`, `confirmDisabled: boolean = false`, `confirmLoading: boolean = false`, `destructive: boolean = false`, `triggerLabel: string = ""`, `triggerIcon: string = ""`, `scrollable: boolean = true`, `class: string = ""` - Props: `open: boolean = false`, `defaultOpen: boolean = false`, `title: string = "Modal"`, `description: string = ""`, `icon: string = ""`, `label: string = "Modal dialog"`, `size: string = "md"`, `placement: string = "center"`, `color: string = "primary"`, `variant: string = "default"`, `showClose: boolean = true`, `closeLabel: string = "Close modal"`, `closeOnBackdrop: boolean = true`, `closeOnEscape: boolean = true`, `closeOnCancel: boolean = true`, `closeOnConfirm: boolean = false`, `showFooter: boolean = true`, `cancelLabel: string = "Cancel"`, `cancelIcon: string = ""`, `confirmLabel: string = "Confirm"`, `confirmIcon: string = ""`, `confirmDisabled: boolean = false`, `confirmLoading: boolean = false`, `destructive: boolean = false`, `triggerLabel: string = ""`, `triggerIcon: string = ""`, `scrollable: boolean = true`, `scrollBehavior: string = "inside"`, `class: string = ""`
- Slots: `trigger`, `header`, `default`, `footer` - Slots: `trigger`, `header`, `default`, `footer`
- Outputs: `open({ sourceEvent: Event })`, `close({ reason: string; sourceEvent: Event })`, `cancel({ sourceEvent: Event })`, `confirm({ sourceEvent: Event })` - Outputs: `open({ sourceEvent: Event })`, `close({ reason: string; sourceEvent: Event })`, `cancel({ sourceEvent: Event })`, `confirm({ sourceEvent: Event })`
@@ -987,11 +987,11 @@ Show concise accessible contextual help on hover, focus, click, or controlled op
## Tables ## Tables
### Table ### DataTable
Theme-aware, responsive table component. Sortable, filterable, paginated data table with row selection.
- Mount: `data-component="Table"` - Mount: `data-component="DataTable"`
- Props: `size: string = "default"`, `color: string = "primary"`, `caption: string = "Table"`, `columns: unknown[] = []`, `rows: unknown[] = []`, `striped: boolean = true`, `class: string = ""` - Props: `color: string = "primary"`, `size: string = "default"`, `columns: unknown[] = []`, `rows: unknown[] = []`, `rowKey: string = "id"`, `remote: boolean = false`, `loadingLabel: string = "Loading"`, `errorLabel: string = "Could not load this data"`, `retryLabel: string = "Try again"`, `caption: string = ""`, `description: string = ""`, `searchable: boolean = true`, `searchPlaceholder: string = "Search"`, `paginated: boolean = true`, `pageSize: number = 10`, `paginationStyle: string = "compact"`, `pageSizes: number[] = [10, 25, 50]`, `selectable: boolean = false`, `actions: unknown[] = []`, `striped: boolean = true`, `bordered: boolean = true`, `gridlines: string = "rows"`, `density: string = "default"`, `emptyLabel: string = "No records to show"`, `noResultsLabel: string = "No records match your search"`, `clearSearchLabel: string = "Clear search"`, `stickyFirstColumn: boolean = false`, `layout: string = "rows"`, `class: string = ""`
- Slots: `default` - Slots: `default`
- Outputs: `sort({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `select({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `rowClick({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)` - Outputs: `sort({ key: string; direction: string })`, `search({ query: string })`, `pageChange({ page: number; pageSize: number })`, `select({ selected: Array<string | number>; all: boolean })`, `change({ page: number; pageSize: number; total: number; query: string; sortKey: string; sortDirection: string })`, `rowClick({ row: object; sourceEvent: Event })`, `action({ id: string; selected: Array<string | number>; rows: object[]; sourceEvent: Event })`, `request({ instanceId: number; page: number; pageSize: number; sortKey: string; sortDirection: string; query: string })`
+7 -7
View File
@@ -168,8 +168,8 @@
}, },
{ {
"name": "DataTable", "name": "DataTable",
"category": "integrations", "category": "tables",
"purpose": "Theme-aware, responsive data table component." "purpose": "Sortable, filterable, paginated data table with row selection."
}, },
{ {
"name": "DatePicker", "name": "DatePicker",
@@ -471,11 +471,6 @@
"category": "forms", "category": "forms",
"purpose": "Theme-aware, responsive switch component." "purpose": "Theme-aware, responsive switch component."
}, },
{
"name": "Table",
"category": "tables",
"purpose": "Theme-aware, responsive table component."
},
{ {
"name": "Tabs", "name": "Tabs",
"category": "navigation", "category": "navigation",
@@ -511,6 +506,11 @@
"category": "integrations", "category": "integrations",
"purpose": "Theme-aware, responsive toast notifications component." "purpose": "Theme-aware, responsive toast notifications component."
}, },
{
"name": "Toaster",
"category": "core",
"purpose": "Reusable toaster component."
},
{ {
"name": "ToggleCount", "name": "ToggleCount",
"category": "advanced-forms", "category": "advanced-forms",
File diff suppressed because it is too large Load Diff
+13 -11
View File
@@ -49,22 +49,24 @@ component Accordion {
return multiple || alwaysOpen return multiple || alwaysOpen
} }
client function dispatchAccordionEvent(sourceEvent, eventName, value, item, root, customEvent) { // Named rather than computed: an output is resolved as a property name, so
root = sourceEvent.currentTarget.closest("[data-wrn-accordion]") // output[eventName] would not reach a parent binding.
client function dispatchAccordionEvent(sourceEvent, eventName, value, item, payload) {
if (!root) { payload = {
return
}
customEvent = document.createEvent("CustomEvent")
customEvent.initCustomEvent(eventName, true, false, {
component: "Accordion", component: "Accordion",
value: value, value: value,
item: item, item: item,
open: isOpen(value), open: isOpen(value),
openValues: openValues openValues: openValues
}) }
root.dispatchEvent(customEvent)
if (eventName === "open") {
output.open(payload)
} else if (eventName === "close") {
output.close(payload)
} else {
output.change(payload)
}
} }
client function toggleItem(sourceEvent, value, item, wasOpen) { client function toggleItem(sourceEvent, value, item, wasOpen) {
+2 -6
View File
@@ -30,17 +30,13 @@ component AvatarGroup {
return items.slice(Number(maxVisible)) return items.slice(Number(maxVisible))
} }
client function toggleOverflow(sourceEvent, root, customEvent) { client function toggleOverflow() {
overflowOpen = !overflowOpen overflowOpen = !overflowOpen
root = sourceEvent.currentTarget.closest("[data-wrn-avatar-group]") output.overflow({
customEvent = document.createEvent("CustomEvent")
customEvent.initCustomEvent("overflow", true, false, {
component: "AvatarGroup", component: "AvatarGroup",
open: overflowOpen, open: overflowOpen,
hiddenCount: hiddenMembers().length hiddenCount: hiddenMembers().length
}) })
root.dispatchEvent(customEvent)
} }
} }
+3 -3
View File
@@ -34,7 +34,7 @@ label: string = "Breadcrumb"
<a <a
href='{homeHref || "/"}' href='{homeHref || "/"}'
class="wire-breadcrumb__link wire-breadcrumb__home" class="wire-breadcrumb__link wire-breadcrumb__home"
@click='event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: { item: { label: homeLabel, href: homeHref || "/", value: "home" }, itemIndex: -1 } }))' @click='output.select({ item: { label: homeLabel, href: homeHref || "/", value: "home" }, itemIndex: -1 })'
> >
{#if homeIcon === "icon-[lucide--house]"} {#if homeIcon === "icon-[lucide--house]"}
<span class="icon-[lucide--house] wire-breadcrumb__icon" aria-hidden="true"></span> <span class="icon-[lucide--house] wire-breadcrumb__icon" aria-hidden="true"></span>
@@ -72,7 +72,7 @@ label: string = "Breadcrumb"
target='{item.target || ""}' target='{item.target || ""}'
rel='{item.external ? "noopener noreferrer" : (item.rel || "")}' rel='{item.external ? "noopener noreferrer" : (item.rel || "")}'
class="wire-breadcrumb__link" class="wire-breadcrumb__link"
@click='event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: { item: item, itemIndex: itemIndex } }))' @click='output.select({ item: item, itemIndex: itemIndex })'
> >
{#if item.icon} {#if item.icon}
<span class='{item.icon} wire-breadcrumb__icon' aria-hidden="true"></span> <span class='{item.icon} wire-breadcrumb__icon' aria-hidden="true"></span>
@@ -87,7 +87,7 @@ label: string = "Breadcrumb"
{:else} {:else}
<span <span
class="wire-breadcrumb__current" class="wire-breadcrumb__current"
aria-current='{item.active || item.current || (active && active === (item.value || item.label || item.title)) || (!active && itemIndex === items.length - 1) ? "page" : ""}' aria-current='{item.active || item.current || (active && active === (item.value || item.label || item.title)) || (!active && itemIndex === items.length - 1) ? "page" : "false"}'
aria-disabled='{item.disabled ? "true" : "false"}' aria-disabled='{item.disabled ? "true" : "false"}'
> >
{#if item.icon} {#if item.icon}
+88 -14
View File
@@ -1,3 +1,13 @@
// Columns -- a simple multi-column split for page content.
//
// <Columns columns={2} gap="lg">...</Columns>
//
// Columns and Grid overlap deliberately: Columns is the coarse two or three
// way split of a page, and its gaps are wider for that reason. Grid is the one
// to reach for when the items are cards and the count matters.
//
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
// and silently swallows the rule that follows it.
component Columns { component Columns {
props { props {
size: string = "default" size: string = "default"
@@ -8,28 +18,92 @@ component Columns {
class: string = "" class: string = ""
} }
functions {
shared function columnCount() {
var value = Number(columns)
if (!value || value < 1) {
return 1
}
return Math.min(4, value)
}
}
view { view {
<div <div
{...attrs}
data-ui-component="Columns" data-ui-component="Columns"
class='wire-columns {class}'
data-size='{size}' data-size='{size}'
data-color='{color}' data-color='{color}'
data-columns='{columns}'
data-gap='{gap}' data-gap='{gap}'
class='grid w-full grid-cols-1 items-start {class}' data-max-width='{maxWidth}'
class:max-w-3xl='maxWidth === "md"' data-columns='{columnCount()}'
class:max-w-5xl='maxWidth === "lg"'
class:max-w-7xl='maxWidth === "xl"'
class:max-w-screen-2xl='maxWidth === "2xl"'
class:max-w-none='maxWidth === "full"'
class:md:grid-cols-2='columns >= 2'
class:lg:grid-cols-3='columns === 3'
class:lg:grid-cols-4='columns === 4'
class:gap-3='gap === "sm"'
class:gap-6='gap === "md"'
class:gap-10='gap === "lg"'
class:gap-14='gap === "xl"'
> >
<slot></slot> <slot></slot>
</div> </div>
} }
style {
.wire-columns {
--columns-gap: 1.5rem;
display: grid;
width: 100%;
min-width: 0;
gap: var(--columns-gap);
grid-template-columns: 1fr;
align-items: start;
}
.wire-columns[data-max-width="md"] {
max-width: 48rem;
}
.wire-columns[data-max-width="lg"] {
max-width: 64rem;
}
.wire-columns[data-max-width="xl"] {
max-width: 80rem;
}
.wire-columns[data-max-width="2xl"] {
max-width: 96rem;
}
.wire-columns[data-max-width="full"] {
max-width: none;
}
.wire-columns[data-gap="sm"] {
--columns-gap: 0.75rem;
}
.wire-columns[data-gap="lg"] {
--columns-gap: 2.5rem;
}
.wire-columns[data-gap="xl"] {
--columns-gap: 3.5rem;
}
/*
* Splits at the tablet breakpoint rather than the phone one: a page split
* is worth keeping single-column for longer than a card deck is.
*/
@media (min-width: 768px) {
.wire-columns:not([data-columns="1"]) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1024px) {
.wire-columns[data-columns="3"] {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.wire-columns[data-columns="4"] {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
}
} }
+7 -2
View File
@@ -45,8 +45,12 @@ component ContextMenu {
sourceEvent.preventDefault() sourceEvent.preventDefault()
} }
if (placement === "pointer" && sourceEvent) { if (placement === "pointer" && sourceEvent) {
positionX = Math.max(12, Math.min(sourceEvent.clientX || 12, window.innerWidth - 340)) // Place the menu at the pointer and let the anchored clamp in the
positionY = Math.max(12, Math.min(sourceEvent.clientY || 12, window.innerHeight - 420)) // runtime pull it back on screen once it has been laid out and can
// actually be measured. Subtracting a guessed 340x420 here instead
// pushed every menu that was not that size away from the pointer.
positionX = Math.max(12, sourceEvent.clientX || 12)
positionY = Math.max(12, sourceEvent.clientY || 12)
} }
visible = true visible = true
output.open({ output.open({
@@ -161,6 +165,7 @@ component ContextMenu {
<div <div
class="wire-context-menu__panel" class="wire-context-menu__panel"
data-wrn-anchored="true"
data-show='{open || visible}' data-show='{open || visible}'
role="menu" role="menu"
aria-label='{label}' aria-label='{label}'
+114 -9
View File
@@ -1,17 +1,122 @@
// CustomScrollbar -- a scrolling region with a themed scrollbar.
//
// <CustomScrollbar maxHeight="20rem">...long content...</CustomScrollbar>
//
// Scrollbar appearance is CSS, not script: scrollbar-width and scrollbar-color
// are the standard properties, and the ::-webkit-scrollbar rules cover the
// browsers that still need them.
//
// This component used to declare a scroll output it never emitted, and its
// props were columns, gap and maxWidth copied from a grid scaffold. The output
// is removed rather than left unimplemented -- a caller can listen for a plain
// scroll event on the element, which is what it would have been anyway.
//
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
// and silently swallows the rule that follows it.
component CustomScrollbar { component CustomScrollbar {
outputs {
scroll(payload: { sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])
}
props { props {
size: string = "default"
color: string = "primary" color: string = "primary"
columns: number = 2 size: string = "default"
gap: string = "md" axis: string = "vertical"
maxWidth: string = "xl" // Track thickness in pixels. Clamped, because it arrives as an attribute
// and a scrollbar wider than the content is not useful to anyone.
thickness: number = 8
maxHeight: string = "20rem"
radius: string = "999px"
class: string = "" class: string = ""
} }
functions {
shared function trackSize() {
var value = Number(thickness)
if (!value || value < 2) {
return 8
}
return Math.min(24, value)
}
}
view { view {
<div class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--custom-scrollbar wire-next--gap-{gap} wire-next--columns-{columns} wire-next--max-{maxWidth} {class}"><slot /></div> <div
{...attrs}
data-ui-component="CustomScrollbar"
class='wire-scrollbar {class}'
data-color='{color}'
data-size='{size}'
data-axis='{axis}'
style='--scrollbar-thickness:{trackSize()}px;--scrollbar-radius:{radius};max-height:{maxHeight};'
>
<slot />
</div>
}
style {
.wire-scrollbar {
--scrollbar-accent: var(--wire-color-primary);
min-width: 0;
overflow: auto;
/*
* The standard properties. Firefox and current Chrome honour these; the
* webkit rules below are only for the engines that still ignore them.
*/
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--scrollbar-accent) 55%, transparent) transparent;
overscroll-behavior: contain;
}
.wire-scrollbar[data-color="secondary"] {
--scrollbar-accent: var(--wire-color-secondary);
}
.wire-scrollbar[data-color="success"] {
--scrollbar-accent: var(--wire-color-success);
}
.wire-scrollbar[data-color="danger"] {
--scrollbar-accent: var(--wire-color-danger);
}
.wire-scrollbar[data-color="info"] {
--scrollbar-accent: var(--wire-color-info);
}
.wire-scrollbar[data-axis="vertical"] {
overflow-x: hidden;
overflow-y: auto;
}
.wire-scrollbar[data-axis="horizontal"] {
overflow-x: auto;
overflow-y: hidden;
}
.wire-scrollbar::-webkit-scrollbar {
width: var(--scrollbar-thickness, 8px);
height: var(--scrollbar-thickness, 8px);
}
.wire-scrollbar::-webkit-scrollbar-track {
background: var(--wire-color-surface-soft);
border-radius: var(--scrollbar-radius, 999px);
}
.wire-scrollbar::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--scrollbar-accent) 45%, transparent);
border-radius: var(--scrollbar-radius, 999px);
}
.wire-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-accent);
}
/*
* A pointer-less device paints its own overlay scrollbar and ignores the
* width above, so the region keeps its own padding instead.
*/
@media (hover: none) {
.wire-scrollbar {
scrollbar-width: auto;
}
}
} }
} }
File diff suppressed because it is too large Load Diff
+83 -1
View File
@@ -20,6 +20,8 @@ open: boolean = false
showClose: boolean = true showClose: boolean = true
closeOnBackdrop: boolean = true closeOnBackdrop: boolean = true
closeOnEscape: boolean = true closeOnEscape: boolean = true
// Open/close animation length in ms. 0 disables the animation entirely.
duration: number = 260
overlay: boolean = true overlay: boolean = true
scrollable: boolean = true scrollable: boolean = true
triggerLabel: string = "" triggerLabel: string = ""
@@ -79,7 +81,9 @@ open: boolean = false
data-overlay='{overlay ? "true" : "false"}' data-overlay='{overlay ? "true" : "false"}'
data-scrollable='{scrollable ? "true" : "false"}' data-scrollable='{scrollable ? "true" : "false"}'
class='wire-drawer {class}' class='wire-drawer {class}'
style='--drawer-duration: {duration}ms'
> >
{#if triggerLabel} {#if triggerLabel}
<button <button
type="button" type="button"
@@ -101,7 +105,6 @@ open: boolean = false
<div <div
class="wire-drawer__layer" class="wire-drawer__layer"
data-show='{open || visible}'
role="presentation" role="presentation"
@keydown='handleKeydown(event)' @keydown='handleKeydown(event)'
> >
@@ -218,12 +221,32 @@ open: boolean = false
cursor: pointer; cursor: pointer;
} }
/*
* The layer stays in the layout and is revealed by [data-open]; it used to
* be toggled with data-show, which sets display:none, and display cannot
* be transitioned -- the drawer simply snapped in and out. visibility is
* delayed by the duration on the way out so the panel can finish sliding
* before the layer is taken out of the hit-testing tree.
*/
.wire-drawer__layer { .wire-drawer__layer {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 1200; z-index: 1200;
display: flex; display: flex;
pointer-events: none; pointer-events: none;
visibility: hidden;
opacity: 0;
transition:
opacity var(--drawer-duration, 260ms) ease,
visibility 0s linear var(--drawer-duration, 260ms);
}
.wire-drawer[data-open="true"] .wire-drawer__layer {
visibility: visible;
opacity: 1;
transition:
opacity var(--drawer-duration, 260ms) ease,
visibility 0s linear 0s;
} }
.wire-drawer__backdrop { .wire-drawer__backdrop {
@@ -262,6 +285,38 @@ open: boolean = false
box-shadow: -28px 0 80px color-mix(in srgb, black 28%, transparent); box-shadow: -28px 0 80px color-mix(in srgb, black 28%, transparent);
pointer-events: auto; pointer-events: auto;
overflow: hidden; overflow: hidden;
/* Slides in from whichever edge the placement puts it on. */
transform: translateX(100%);
transition: transform var(--drawer-duration, 260ms) cubic-bezier(0.32, 0.72, 0, 1);
}
.wire-drawer[data-open="true"] .wire-drawer__panel {
transform: none;
}
.wire-drawer[data-placement="left"] .wire-drawer__panel {
transform: translateX(-100%);
}
.wire-drawer[data-placement="top"] .wire-drawer__panel {
transform: translateY(-100%);
}
.wire-drawer[data-placement="bottom"] .wire-drawer__panel {
transform: translateY(100%);
}
.wire-drawer[data-open="true"][data-placement="left"] .wire-drawer__panel,
.wire-drawer[data-open="true"][data-placement="top"] .wire-drawer__panel,
.wire-drawer[data-open="true"][data-placement="bottom"] .wire-drawer__panel {
transform: none;
}
@media (prefers-reduced-motion: reduce) {
.wire-drawer__layer,
.wire-drawer__panel {
transition: none;
}
} }
.wire-drawer[data-size="sm"] .wire-drawer__panel { .wire-drawer[data-size="sm"] .wire-drawer__panel {
@@ -395,12 +450,19 @@ open: boolean = false
color: color-mix(in srgb, currentColor 76%, transparent); color: color-mix(in srgb, currentColor 76%, transparent);
} }
/*
* padding is reset explicitly: an app-level `button { padding: ... }` rule
* outranks the browser default and leaves this fixed-size button with a
* content box of a couple of pixels, which squeezes the icon to a sliver
* and reads as "the close button has no icon". Same trap as Modal.
*/
.wire-drawer__close { .wire-drawer__close {
appearance: none; appearance: none;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex: 0 0 auto; flex: 0 0 auto;
padding: 0;
width: 2.35rem; width: 2.35rem;
height: 2.35rem; height: 2.35rem;
color: var(--wire-color-text-muted); color: var(--wire-color-text-muted);
@@ -417,10 +479,30 @@ open: boolean = false
outline: none; outline: none;
} }
/* Never let the glyph be shrunk by the flex container. */
.wire-drawer__close svg {
flex: 0 0 auto;
width: 1rem;
height: 1rem;
}
/*
* Slot content is authored by the host app, so the app global stylesheet
* styles it too. A bare element selector there (p { color: ... }) beats
* anything the panel merely *inherits*, which is how modal body copy ended
* up muted grey on a saturated background. State the colour explicitly;
* :where() keeps the specificity low enough that any class the app puts on
* its own slot content still wins.
*/
.wire-drawer__body { .wire-drawer__body {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
padding: 1.35rem; padding: 1.35rem;
color: var(--wire-color-text);
}
.wire-drawer__body :where(p, li, dd, dt, h1, h2, h3, h4, h5, h6, span, label, code) {
color: inherit;
} }
.wire-drawer[data-scrollable="true"] .wire-drawer__body { .wire-drawer[data-scrollable="true"] .wire-drawer__body {
+1
View File
@@ -171,6 +171,7 @@ items: unknown[] = []
<div <div
class="wire-dropdown__panel" class="wire-dropdown__panel"
data-wrn-anchored="true"
data-show='{open || visible}' data-show='{open || visible}'
role="menu" role="menu"
aria-label='{menuLabel}' aria-label='{menuLabel}'
+105 -25
View File
@@ -1,3 +1,13 @@
// Image -- a framed image with a reserved aspect ratio.
//
// <Image src="/hero.jpg" alt="Dashboard" size="video" rounded={true} />
//
// The frame keeps its ratio whether or not the image has loaded, so the page
// does not jump when it arrives. Without a src it renders a labelled
// placeholder of the same shape rather than collapsing.
//
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
// and silently swallows the rule that follows it.
component Image { component Image {
props { props {
size: string = "default" size: string = "default"
@@ -7,41 +17,111 @@ component Image {
width: string = "" width: string = ""
height: string = "" height: string = ""
loading: string = "lazy" loading: string = "lazy"
fit: string = "cover"
rounded: boolean = false rounded: boolean = false
class: string = "" class: string = ""
} }
view { view {
<figure <figure
{...attrs}
data-ui-component="Image" data-ui-component="Image"
class='relative m-0 overflow-hidden bg-[var(--wire-color-surface-soft)] {class}' class='wire-image {class}'
class:rounded-2xl='rounded' data-size='{size}'
data-color='{color}'
data-fit='{fit}'
data-rounded='{rounded}'
> >
{#if src} <img
<img class="wire-image__img"
src='{src}' data-show="src"
alt='{alt}' src='{src}'
width='{width}' alt='{alt}'
height='{height}' width='{width}'
loading='{loading}' height='{height}'
decoding="async" loading='{loading}'
class="block h-auto w-full object-cover transition duration-300" decoding="async"
class:aspect-square='size === "square"' />
class:aspect-video='size === "video"'
class:aspect-[4/3]='size === "landscape"' <div
class:aspect-[3/4]='size === "portrait"' class="wire-image__placeholder"
/> data-show="!src"
{:else} role="img"
<div aria-label='{alt || "Image placeholder"}'
class="flex min-h-48 w-full items-center justify-center text-[var(--wire-color-text-muted)]" >
role="img" <span class="icon-[lucide--image] wire-image__placeholder-icon" aria-hidden="true"></span>
aria-label='{alt || "Image placeholder"}' </div>
>
<span class="icon-[lucide--image] size-8" aria-hidden="true"></span>
</div>
{/if}
<slot></slot> <slot></slot>
</figure> </figure>
} }
style {
.wire-image {
position: relative;
margin: 0;
overflow: hidden;
width: 100%;
min-width: 0;
background: var(--wire-color-surface-soft);
}
.wire-image[data-rounded="true"] {
border-radius: var(--wire-radius-lg);
}
/*
* The ratio sits on the frame rather than the image, so the space is
* reserved before the file arrives and the page does not jump.
*/
.wire-image[data-size="square"] {
aspect-ratio: 1 / 1;
}
.wire-image[data-size="video"] {
aspect-ratio: 16 / 9;
}
.wire-image[data-size="landscape"] {
aspect-ratio: 4 / 3;
}
.wire-image[data-size="portrait"] {
aspect-ratio: 3 / 4;
}
.wire-image__img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.wire-image[data-fit="contain"] .wire-image__img {
object-fit: contain;
}
.wire-image[data-fit="fill"] .wire-image__img {
object-fit: fill;
}
.wire-image[data-size="default"] .wire-image__img {
height: auto;
}
.wire-image__placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 12rem;
height: 100%;
color: var(--wire-color-text-muted);
}
.wire-image__placeholder-icon {
width: 2rem;
height: 2rem;
}
}
} }
+16 -16
View File
@@ -232,26 +232,17 @@ component InputNumber {
) )
} }
// Outputs are resolved by name, so each one is written out. Raw
// CustomEvents dispatched on the root -- what this did before -- never
// reach a parent @binding.
client function dispatchInputNumberEvent( client function dispatchInputNumberEvent(
sourceEvent, sourceEvent,
eventName, eventName,
action, action,
previousValue, previousValue,
root, payload
customEvent
) { ) {
root = sourceEvent.currentTarget.closest("[data-wrn-input-number]") payload = {
if (!root && sourceEvent.target) {
root = sourceEvent.target.closest("[data-wrn-input-number]")
}
if (!root) {
return
}
customEvent = document.createEvent("CustomEvent")
customEvent.initCustomEvent(eventName, true, false, {
component: "InputNumber", component: "InputNumber",
name: name, name: name,
value: currentValue, value: currentValue,
@@ -261,8 +252,17 @@ component InputNumber {
max: hasMax() ? Number(max) : null, max: hasMax() ? Number(max) : null,
step: normalizedStep(), step: normalizedStep(),
valid: !isInvalid() valid: !isInvalid()
}) }
root.dispatchEvent(customEvent)
if (eventName === "input") {
output.input(payload)
} else if (eventName === "change") {
output.change(payload)
} else if (eventName === "increment") {
output.increment(payload)
} else if (eventName === "decrement") {
output.decrement(payload)
}
} }
client function applyControlValue(nextValue, action, sourceEvent, previousValue) { client function applyControlValue(nextValue, action, sourceEvent, previousValue) {
+48 -2
View File
@@ -1,9 +1,55 @@
// Kbd -- a keyboard key.
//
// <Kbd label="Ctrl" />
//
// Deliberately small. A kbd element is a kbd element; the work here is making
// it look like a key and follow the theme.
//
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
// and silently swallows the rule that follows it.
component Kbd { component Kbd {
props { props {
size: string = "default" size: string = "default"
color: string = "primary" color: string = "primary"
label: string = "K" label: string = "K"
class: string = "" class: string = ""
} }
view { <kbd class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--kbd {class}"><slot>{label}</slot></kbd> }
view {
<kbd {...attrs} class='wire-kbd {class}' data-size='{size}' data-color='{color}'>
<slot>{label}</slot>
</kbd>
}
style {
.wire-kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.6rem;
padding: 0.15rem 0.4rem;
border: 1px solid var(--wire-color-border);
/* The lower edge is what reads as a physical key. */
border-bottom-width: 2px;
border-radius: var(--wire-radius-sm);
background: var(--wire-color-surface-soft);
color: var(--wire-color-text);
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
line-height: 1;
}
.wire-kbd[data-size="sm"] {
min-width: 1.4rem;
padding: 0.1rem 0.3rem;
font-size: 0.7rem;
}
.wire-kbd[data-size="lg"] {
min-width: 2rem;
padding: 0.3rem 0.55rem;
font-size: 0.9rem;
}
}
} }
+214 -8
View File
@@ -1,19 +1,225 @@
// LayoutSplitter -- two panes with a divider the reader can move.
//
// <LayoutSplitter size={40} minSize={20}>
// <div data-slot="start">...</div>
// <div data-slot="end">...</div>
// </LayoutSplitter>
//
// The dragging lives in the reactive runtime behind data-wrn-splitter. Pointer
// moves fire far too often to route through a client function, and a state
// write made inside a pointermove callback is dropped, so the resolved size is
// held in the DOM as the --wrn-split custom property and these styles read it.
//
// This component previously declared resizeStart, resize and resizeEnd with no
// pointer handling whatsoever: a caller wired up @resize and received nothing,
// for ever, with no error.
//
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
// and silently swallows the rule that follows it.
component LayoutSplitter { component LayoutSplitter {
outputs { outputs {
resizeStart(payload: { sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[]) // Named sizeChange rather than resize so it cannot be confused with the
resize(payload: { sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[]) // native window event a caller may already be listening for. An earlier
resizeEnd(payload: { sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[]) // comment here claimed a natively-named output could never reach a parent
// binding; that was wrong, and the rename was never what fixed anything.
sizeChange(payload: { size: number })
} }
props { props {
size: string = "default"
color: string = "primary" color: string = "primary"
columns: number = 2 size: number = 50
gap: string = "md" orientation: string = "horizontal"
maxWidth: string = "xl" // Smallest share either pane may take, as a percentage. It also fixes the
// upper bound at 100 - minSize, so neither pane can be dragged away to
// nothing and left unrecoverable by pointer.
minSize: number = 15
step: number = 5
label: string = "Resize panels"
class: string = "" class: string = ""
} }
functions {
// The runtime resolves the size and announces it; this turns that into the
// declared output so a parent @resize binding receives it.
client function reportResize(sourceEvent) {
var detail = sourceEvent ? sourceEvent.detail : null
if (!detail) {
return
}
output.sizeChange({ size: detail.size })
}
shared function isVertical() {
return orientation === "vertical"
}
shared function lowerBound() {
var value = Number(minSize)
if (!value || value < 0) {
return 15
}
return Math.min(45, value)
}
shared function upperBound() {
return 100 - lowerBound()
}
// Sizes arrive as HTML attributes, so a value outside the bounds is
// routine rather than exceptional. Clamp instead of rendering something
// the reader cannot undo.
shared function currentSize() {
var value = Number(size)
if (!value || value < 0) {
return 50
}
return Math.min(upperBound(), Math.max(lowerBound(), value))
}
shared function stepSize() {
var value = Number(step)
return value && value > 0 ? value : 5
}
}
view { view {
<div class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--layout-splitter wire-next--gap-{gap} wire-next--columns-{columns} wire-next--max-{maxWidth} {class}"><slot /></div> <div
{...attrs}
data-ui-component="LayoutSplitter"
class='wire-splitter {class}'
data-color='{color}'
data-orientation='{orientation}'
data-wrn-splitter='{isVertical() ? "vertical" : "horizontal"}'
data-wrn-splitter-min='{lowerBound()}'
data-wrn-splitter-step='{stepSize()}'
style='--wrn-split:{currentSize()}%;'
@wrnexus:splitter:resize='reportResize(event)'
>
<div class="wire-splitter__pane wire-splitter__pane--start">
<slot name="start"></slot>
</div>
<div
class="wire-splitter__handle"
data-wrn-splitter-handle="true"
role="separator"
tabindex="0"
aria-label='{label}'
aria-orientation='{isVertical() ? "horizontal" : "vertical"}'
aria-valuenow='{currentSize()}'
aria-valuemin='{lowerBound()}'
aria-valuemax='{upperBound()}'
>
<span class="wire-splitter__grip" aria-hidden="true"></span>
</div>
<div class="wire-splitter__pane wire-splitter__pane--end">
<slot name="end"></slot>
</div>
<slot />
</div>
}
style {
.wire-splitter {
--splitter-accent: var(--wire-color-primary);
display: grid;
/* The first track follows the size the runtime resolves. */
grid-template-columns: var(--wrn-split, 50%) auto minmax(0, 1fr);
align-items: stretch;
width: 100%;
min-width: 0;
}
.wire-splitter[data-color="secondary"] {
--splitter-accent: var(--wire-color-secondary);
}
.wire-splitter[data-color="success"] {
--splitter-accent: var(--wire-color-success);
}
.wire-splitter[data-color="danger"] {
--splitter-accent: var(--wire-color-danger);
}
.wire-splitter[data-color="info"] {
--splitter-accent: var(--wire-color-info);
}
.wire-splitter[data-orientation="vertical"] {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: var(--wrn-split, 50%) auto minmax(0, 1fr);
}
.wire-splitter__pane {
min-width: 0;
min-height: 0;
overflow: auto;
}
.wire-splitter__handle {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
padding: 0 0.25rem;
border: 0;
background: transparent;
cursor: col-resize;
/* Without this the pointer drag selects the text in both panes. */
touch-action: none;
user-select: none;
}
.wire-splitter[data-orientation="vertical"] .wire-splitter__handle {
padding: 0.25rem 0;
cursor: row-resize;
}
.wire-splitter__grip {
display: block;
width: 2px;
height: 100%;
min-height: 1.5rem;
border-radius: 999px;
background: var(--wire-color-border);
transition: background 140ms ease;
}
.wire-splitter[data-orientation="vertical"] .wire-splitter__grip {
width: 100%;
min-width: 1.5rem;
height: 2px;
}
.wire-splitter__handle:hover .wire-splitter__grip,
.wire-splitter[data-wrn-splitter-dragging="true"] .wire-splitter__grip {
background: var(--splitter-accent);
}
.wire-splitter__handle:focus-visible {
outline: 2px solid var(--splitter-accent);
outline-offset: -2px;
border-radius: var(--wire-radius-sm);
}
/*
* Two panes side by side stop making sense on a phone. Stacking them keeps
* both readable, and the divider stops being draggable because the grid
* no longer has a second track to trade against.
*/
@media (max-width: 639px) {
.wire-splitter,
.wire-splitter[data-orientation="vertical"] {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto auto auto;
}
.wire-splitter__handle {
cursor: default;
}
}
} }
} }
+114 -24
View File
@@ -1,3 +1,13 @@
// Link -- a text link that follows the theme.
//
// <Link href="/docs" label="Documentation" />
// <Link href="https://example.com" label="Spec" external={true} />
//
// external adds rel="noopener noreferrer" and an outbound icon, so the reader
// is told the link leaves the site before they follow it.
//
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
// and silently swallows the rule that follows it.
component Link { component Link {
props { props {
size: string = "default" size: string = "default"
@@ -6,42 +16,122 @@ component Link {
href: string = "#" href: string = "#"
target: string = "" target: string = ""
rel: string = "" rel: string = ""
underline: string = "hover"
external: boolean = false external: boolean = false
class: string = "" class: string = ""
} }
view { view {
<a <a
{...attrs}
data-ui-component="Link" data-ui-component="Link"
class='wire-link {class}'
href='{href}' href='{href}'
target='{target}' target='{target}'
rel='{external ? (rel || "noopener noreferrer") : rel}' rel='{external ? (rel || "noopener noreferrer") : rel}'
class='inline-flex min-w-0 items-center gap-1.5 rounded-md font-semibold underline-offset-4 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[var(--wire-color-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--wire-color-background)] {class}' data-size='{size}'
class:text-xs='size === "xs"' data-color='{color}'
class:text-sm='size === "sm" || size === "default"' data-underline='{underline}'
class:text-base='size === "md"' data-external='{external}'
class:text-lg='size === "lg"'
class:text-[var(--wire-color-primary)]='color === "primary"'
class:hover:text-[var(--wire-color-primary-hover)]='color === "primary"'
class:text-[var(--wire-color-secondary)]='color === "secondary"'
class:hover:text-[var(--wire-color-secondary-hover)]='color === "secondary"'
class:text-[var(--wire-color-success)]='color === "success"'
class:text-[var(--wire-color-warning-text)]='color === "warning"'
class:text-[var(--wire-color-danger)]='color === "danger"'
class:text-[var(--wire-color-info)]='color === "info"'
class:text-[var(--wire-color-text)]='color === "neutral"'
class:hover:underline='external === false'
> >
<span class="truncate">{label}</span> <span class="wire-link__label">{label}</span>
<span
{#if external} class="icon-[lucide--external-link] wire-link__icon"
<span data-show="external"
class="icon-[lucide--external-link] size-3.5 shrink-0" aria-hidden="true"
aria-hidden="true" ></span>
></span>
{/if}
<slot></slot> <slot></slot>
</a> </a>
} }
style {
.wire-link {
--link-tone: var(--wire-color-primary);
--link-tone-hover: var(--wire-color-primary-hover);
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
border-radius: var(--wire-radius-sm);
color: var(--link-tone);
font-size: 0.875rem;
font-weight: 600;
text-decoration: none;
text-underline-offset: 4px;
transition: color 140ms ease;
}
.wire-link[data-color="secondary"] {
--link-tone: var(--wire-color-secondary);
--link-tone-hover: var(--wire-color-secondary-hover);
}
.wire-link[data-color="success"] {
--link-tone: var(--wire-color-success);
--link-tone-hover: var(--wire-color-success);
}
.wire-link[data-color="warning"] {
--link-tone: var(--wire-color-warning-text);
--link-tone-hover: var(--wire-color-warning-text);
}
.wire-link[data-color="danger"] {
--link-tone: var(--wire-color-danger);
--link-tone-hover: var(--wire-color-danger);
}
.wire-link[data-color="info"] {
--link-tone: var(--wire-color-info);
--link-tone-hover: var(--wire-color-info);
}
.wire-link[data-color="neutral"] {
--link-tone: var(--wire-color-text);
--link-tone-hover: var(--wire-color-text);
}
.wire-link[data-size="xs"] {
font-size: 0.75rem;
}
.wire-link[data-size="md"] {
font-size: 1rem;
}
.wire-link[data-size="lg"] {
font-size: 1.125rem;
}
.wire-link[data-underline="always"] {
text-decoration: underline;
}
.wire-link[data-underline="hover"]:hover {
text-decoration: underline;
}
.wire-link:hover {
color: var(--link-tone-hover);
}
.wire-link:focus-visible {
outline: 2px solid var(--wire-color-focus);
outline-offset: 2px;
}
/* The label truncates; the outbound icon must not shrink with it. */
.wire-link__label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wire-link__icon {
flex: 0 0 auto;
width: 0.875rem;
height: 0.875rem;
}
}
} }
+5 -1
View File
@@ -1,4 +1,8 @@
component List { component List {
outputs {
select(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
}
props { props {
size: string = "default" size: string = "default"
color: string = "primary" color: string = "primary"
@@ -52,7 +56,7 @@ component List {
class:py-2.5='size === "sm"' class:py-2.5='size === "sm"'
class:px-5='size === "lg"' class:px-5='size === "lg"'
class:py-4='size === "lg"' class:py-4='size === "lg"'
@click='event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: { item: item, index: index } }))' @click='output.select({ item: item, index: index })'
> >
{#if item.icon} {#if item.icon}
<span class="flex size-10 shrink-0 items-center justify-center rounded-xl bg-[var(--wire-color-primary-soft)] text-[var(--wire-color-primary)]"> <span class="flex size-10 shrink-0 items-center justify-center rounded-xl bg-[var(--wire-color-primary-soft)] text-[var(--wire-color-primary)]">
+11 -3
View File
@@ -1,4 +1,12 @@
component Map { component Map {
outputs {
// markerClick and select both fire for a marker press; select is the
// generic name callers reach for, markerClick the explicit one.
markerClick(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
select(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
zoom(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
}
props { props {
size: string = "default" size: string = "default"
color: string = "primary" color: string = "primary"
@@ -54,7 +62,7 @@ component Map {
aria-label='{item.label || item.title || "Map marker"}' aria-label='{item.label || item.title || "Map marker"}'
class="absolute inline-flex size-10 items-center justify-center rounded-full border-4 border-[var(--wire-color-surface-raised)] bg-[var(--wire-color-primary)] text-[var(--wire-color-on-primary)] shadow-lg transition hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-focus)]" class="absolute inline-flex size-10 items-center justify-center rounded-full border-4 border-[var(--wire-color-surface-raised)] bg-[var(--wire-color-primary)] text-[var(--wire-color-on-primary)] shadow-lg transition hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-focus)]"
style='left: {item.x || (20 + index * 12)}%; top: {item.y || (30 + (index % 3) * 18)}%;' style='left: {item.x || (20 + index * 12)}%; top: {item.y || (30 + (index % 3) * 18)}%;'
@click='event.currentTarget.dispatchEvent(new CustomEvent("markerClick", { bubbles: true, detail: item })); event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: item }))' @click='output.markerClick(item); output.select(item)'
> >
<span class='{item.icon || "icon-[lucide--map-pin]"}' aria-hidden="true"></span> <span class='{item.icon || "icon-[lucide--map-pin]"}' aria-hidden="true"></span>
</button> </button>
@@ -66,7 +74,7 @@ component Map {
type="button" type="button"
aria-label="Zoom in" aria-label="Zoom in"
class="inline-flex size-10 items-center justify-center rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-raised)] text-[var(--wire-color-text)] shadow-sm transition hover:bg-[var(--wire-color-surface-soft)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-focus)]" class="inline-flex size-10 items-center justify-center rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-raised)] text-[var(--wire-color-text)] shadow-sm transition hover:bg-[var(--wire-color-surface-soft)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-focus)]"
@click='event.currentTarget.dispatchEvent(new CustomEvent("zoom", { bubbles: true, detail: { direction: "in" } }))' @click='output.zoom({ direction: "in" })'
> >
<span class="icon-[lucide--plus] size-4" aria-hidden="true"></span> <span class="icon-[lucide--plus] size-4" aria-hidden="true"></span>
</button> </button>
@@ -74,7 +82,7 @@ component Map {
type="button" type="button"
aria-label="Zoom out" aria-label="Zoom out"
class="inline-flex size-10 items-center justify-center rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-raised)] text-[var(--wire-color-text)] shadow-sm transition hover:bg-[var(--wire-color-surface-soft)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-focus)]" class="inline-flex size-10 items-center justify-center rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-raised)] text-[var(--wire-color-text)] shadow-sm transition hover:bg-[var(--wire-color-surface-soft)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-focus)]"
@click='event.currentTarget.dispatchEvent(new CustomEvent("zoom", { bubbles: true, detail: { direction: "out" } }))' @click='output.zoom({ direction: "out" })'
> >
<span class="icon-[lucide--minus] size-4" aria-hidden="true"></span> <span class="icon-[lucide--minus] size-4" aria-hidden="true"></span>
</button> </button>
+9 -3
View File
@@ -1,4 +1,10 @@
component Marquee { component Marquee {
outputs {
// Fired when the reader pauses the scroll, by hover, focus or the button.
pause(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
resume(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
}
props { props {
size: string = "default" size: string = "default"
color: string = "primary" color: string = "primary"
@@ -27,8 +33,8 @@ component Marquee {
<div <div
class="group relative min-w-0 flex-1 overflow-hidden" class="group relative min-w-0 flex-1 overflow-hidden"
@mouseenter='paused = true; event.currentTarget.dispatchEvent(new CustomEvent("pause", { bubbles: true }))' @mouseenter='paused = true; output.pause({})'
@mouseleave='paused = false; event.currentTarget.dispatchEvent(new CustomEvent("resume", { bubbles: true }))' @mouseleave='paused = false; output.resume({})'
@focusin='paused = true' @focusin='paused = true'
@focusout='paused = false' @focusout='paused = false'
> >
@@ -77,7 +83,7 @@ component Marquee {
type="button" type="button"
aria-label='{paused ? "Resume announcements" : "Pause announcements"}' aria-label='{paused ? "Resume announcements" : "Pause announcements"}'
class="flex shrink-0 items-center justify-center border-l border-[var(--wire-color-border)] px-4 text-[var(--wire-color-text-muted)] transition hover:bg-[var(--wire-color-surface-soft)] hover:text-[var(--wire-color-text)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--wire-color-focus)]" class="flex shrink-0 items-center justify-center border-l border-[var(--wire-color-border)] px-4 text-[var(--wire-color-text-muted)] transition hover:bg-[var(--wire-color-surface-soft)] hover:text-[var(--wire-color-text)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--wire-color-focus)]"
@click='paused = !paused; event.currentTarget.dispatchEvent(new CustomEvent(paused ? "pause" : "resume", { bubbles: true }))' @click='paused = !paused; paused ? output.pause({}) : output.resume({})'
> >
<span class="icon-[lucide--pause] size-4" data-show='!paused' aria-hidden="true"></span> <span class="icon-[lucide--pause] size-4" data-show='!paused' aria-hidden="true"></span>
<span class="icon-[lucide--play] size-4" data-show='paused' aria-hidden="true"></span> <span class="icon-[lucide--play] size-4" data-show='paused' aria-hidden="true"></span>
+333 -12
View File
@@ -1,23 +1,344 @@
// MegaMenu -- a trigger and a wide panel of grouped links.
//
// <MegaMenu label="Products" columns='[{"heading":"Platform",
// "items":[{"label":"Runtime","href":"/runtime"}]}]' />
//
// Deliberately one level deep. A mega menu exists to show breadth flat, so
// everything is one click away; nesting inside the panel buries content behind
// hover-within-hover and is close to unusable by keyboard and touch. Use Nav
// when you actually want cascading submenus.
//
// The panel carries data-wrn-anchored so the runtime clamp keeps it inside the
// viewport instead of hanging off the edge of a wide layout.
//
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
// and silently swallows the rule that follows it.
component MegaMenu { component MegaMenu {
outputs { outputs {
open(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object }) open(payload: { sourceEvent: Event })
close(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object }) close(payload: { reason: string })
select(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null) select(payload: { item: object; value: string })
} }
props { props {
size: string = "default"
color: string = "primary" color: string = "primary"
label: string = "Mega Menu" size: string = "default"
items: unknown[] = [] label: string = "Menu"
active: string = "" icon: string = ""
orientation: string = "horizontal" columns: unknown[] = []
footer: string = ""
defaultOpen: boolean = false
class: string = "" class: string = ""
} }
state visible = defaultOpen
functions {
shared function columnList() {
return Array.isArray(columns) ? columns : []
}
shared function itemsOf(column) {
return column && Array.isArray(column.items) ? column.items : []
}
client function showPanel(sourceEvent) {
visible = true
output.open({ sourceEvent: sourceEvent })
}
client function hidePanel(reason) {
visible = false
output.close({ reason: reason })
}
client function togglePanel(sourceEvent) {
if (visible) {
hidePanel("toggle")
} else {
showPanel(sourceEvent)
}
}
client function handleKeydown(sourceEvent) {
if (sourceEvent.key === "Escape" && visible) {
sourceEvent.preventDefault()
hidePanel("escape")
}
}
client function choose(item) {
if (item.disabled) {
return
}
output.select({ item: item, value: item.value || item.label || "" })
hidePanel("select")
}
}
view { view {
<nav class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--mega-menu wire-next--{orientation} {class}" aria-label="{label}"> <div
{#each items as item}<a href="{item.href}" aria-current="{item.value === active ? 'page' : ''}">{item.label}</a>{/each} {...attrs}
<slot /> data-ui-component="MegaMenu"
</nav> class='wire-mega {class}'
data-color='{color}'
data-size='{size}'
data-open='{visible}'
@keydown='handleKeydown(event)'
@mouseleave='hidePanel("pointer-leave")'
>
<button
type="button"
class="wire-mega__trigger"
aria-haspopup="true"
aria-expanded='{visible}'
@click='togglePanel(event)'
@mouseenter='showPanel(event)'
@focus='showPanel(event)'
>
<span class='wire-mega__trigger-icon {icon}' data-show="icon" aria-hidden="true"></span>
<span class="wire-mega__trigger-label">{label}</span>
<span class="wire-mega__arrow" aria-hidden="true">&#8250;</span>
</button>
<div
class="wire-mega__panel"
data-wrn-anchored="true"
data-show="visible"
data-wrn-roving="both"
>
<div class="wire-mega__columns">
{#each columnList() as column}
<section class="wire-mega__column">
<h3 class="wire-mega__heading" data-show="column.heading">{column.heading}</h3>
<ul class="wire-mega__list">
{#each itemsOf(column) as item}
<li>
<a
class="wire-mega__link"
href='{item.href || "#"}'
data-wrn-roving-item="true"
aria-disabled='{item.disabled ? "true" : "false"}'
@click='choose(item)'
>
<span
class='wire-mega__link-icon {item.icon}'
data-show="item.icon"
aria-hidden="true"
></span>
<span class="wire-mega__link-body">
<span class="wire-mega__link-label">{item.label}</span>
<span class="wire-mega__link-description" data-show="item.description">
{item.description}
</span>
</span>
</a>
</li>
{/each}
</ul>
</section>
{/each}
</div>
<p class="wire-mega__footer" data-show="footer">{footer}</p>
<slot />
</div>
</div>
}
style {
.wire-mega {
--mega-accent: var(--wire-color-primary);
position: relative;
display: inline-block;
max-width: 100%;
}
.wire-mega[data-color="secondary"] {
--mega-accent: var(--wire-color-secondary);
}
.wire-mega[data-color="success"] {
--mega-accent: var(--wire-color-success);
}
.wire-mega[data-color="danger"] {
--mega-accent: var(--wire-color-danger);
}
.wire-mega[data-color="info"] {
--mega-accent: var(--wire-color-info);
}
.wire-mega[data-size="sm"] {
font-size: 0.82rem;
}
.wire-mega[data-size="lg"] {
font-size: 1rem;
}
.wire-mega__trigger {
appearance: none;
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.7rem;
border: 1px solid transparent;
border-radius: var(--wire-radius-sm);
background: transparent;
color: var(--wire-color-text-muted);
font: inherit;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
}
.wire-mega__trigger:hover,
.wire-mega[data-open="true"] .wire-mega__trigger {
background: var(--wire-color-surface-soft);
color: var(--wire-color-text);
}
.wire-mega__trigger:focus-visible {
outline: 2px solid var(--mega-accent);
outline-offset: 2px;
}
.wire-mega__arrow {
display: inline-block;
transition: transform 160ms ease;
}
.wire-mega[data-open="true"] .wire-mega__arrow {
transform: rotate(90deg);
}
/*
* The panel is offset below the trigger for looks, which used to close the
* menu on the way to it: that offset is dead space belonging to neither
* element, so crossing it fired mouseleave on the root. The bridge below
* covers the gap with a descendant, so the pointer never actually leaves.
*/
.wire-mega__panel::before {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 100%;
height: 0.6rem;
}
.wire-mega__panel {
position: absolute;
z-index: 40;
top: calc(100% + 0.4rem);
left: 0;
width: max-content;
max-width: min(60rem, calc(100vw - 2rem));
padding: 1rem;
border: 1px solid var(--wire-color-border);
border-radius: var(--wire-radius-lg);
background: var(--wire-color-surface);
box-shadow: var(--wire-shadow-1);
}
.wire-mega__columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
gap: 1.25rem;
}
.wire-mega__column {
min-width: 0;
}
.wire-mega__heading {
margin: 0 0 0.5rem;
color: var(--wire-color-text-muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.wire-mega__list {
display: grid;
gap: 0.15rem;
margin: 0;
padding: 0;
list-style: none;
}
.wire-mega__link {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.45rem 0.5rem;
border-radius: var(--wire-radius-sm);
color: var(--wire-color-text);
text-decoration: none;
}
.wire-mega__link:hover {
background: var(--wire-color-surface-soft);
}
.wire-mega__link:focus-visible {
outline: 2px solid var(--mega-accent);
outline-offset: 2px;
}
.wire-mega__link[aria-disabled="true"] {
opacity: 0.5;
pointer-events: none;
}
.wire-mega__link-body {
display: flex;
flex-direction: column;
min-width: 0;
}
.wire-mega__link-label {
font-size: 0.88rem;
font-weight: 600;
}
.wire-mega__link-description {
color: var(--wire-color-text-muted);
font-size: 0.78rem;
line-height: 1.5;
}
.wire-mega__footer {
margin: 1rem 0 0;
padding-top: 0.75rem;
border-top: 1px solid var(--wire-color-border);
color: var(--wire-color-text-muted);
font-size: 0.8rem;
}
/*
* On a phone the panel stops floating and becomes part of the flow. A
* pointer-opened overlay pinned to a trigger is unreachable on touch, and
* a 60rem grid has nowhere to go on a 360px screen.
*/
@media (max-width: 767px) {
.wire-mega {
display: block;
}
.wire-mega__panel {
position: static;
width: auto;
max-width: 100%;
box-shadow: none;
}
.wire-mega__columns {
grid-template-columns: 1fr;
}
}
} }
} }
+196 -17
View File
@@ -35,6 +35,7 @@ component Modal {
triggerLabel: string = "" triggerLabel: string = ""
triggerIcon: string = "" triggerIcon: string = ""
scrollable: boolean = true scrollable: boolean = true
scrollBehavior: string = "inside"
class: string = "" class: string = ""
} }
@@ -50,6 +51,16 @@ component Modal {
output.open({ sourceEvent: sourceEvent }) output.open({ sourceEvent: sourceEvent })
} }
// Slot content can close the modal it sits in by dispatching a bubbling
// wrnexus:modal:close event, e.g. from a form success handler:
//
// event.target.dispatchEvent(
// new CustomEvent("wrnexus:modal:close", { bubbles: true })
// )
//
// The listener is on the modal root, so the event only ever closes the
// modal the dispatching element is actually inside -- no ids to wire up
// and no way to close somebody else's modal by accident.
client function hideModal(reason, sourceEvent) { client function hideModal(reason, sourceEvent) {
visible = false visible = false
output.close({ output.close({
@@ -87,21 +98,23 @@ component Modal {
<div <div
{...attrs} {...attrs}
data-ui-component="Modal" data-ui-component="Modal"
data-open='{open || visible ? "true" : "false"}' data-open='{isOpen() ? "true" : "false"}'
data-size='{size}' data-size='{size}'
data-placement='{placement}' data-placement='{placement}'
data-color='{color}' data-color='{color}'
data-variant='{variant}' data-variant='{variant}'
data-scrollable='{scrollable ? "true" : "false"}' data-scrollable='{scrollable ? "true" : "false"}'
data-scroll='{scrollBehavior}'
data-destructive='{destructive ? "true" : "false"}' data-destructive='{destructive ? "true" : "false"}'
class='wire-modal {class}' class='wire-modal {class}'
@wrnexus:modal:close='hideModal("api", event)'
> >
{#if triggerLabel} {#if triggerLabel}
<button <button
type="button" type="button"
class="wire-modal__trigger" class="wire-modal__trigger"
aria-haspopup="dialog" aria-haspopup="dialog"
aria-expanded='{open || visible ? "true" : "false"}' aria-expanded='{isOpen() ? "true" : "false"}'
@click='showModal(event)' @click='showModal(event)'
> >
{#if triggerIcon} {#if triggerIcon}
@@ -127,7 +140,7 @@ component Modal {
<div <div
class="wire-modal__layer" class="wire-modal__layer"
data-show='{open || visible}' data-show="isOpen()"
role="presentation" role="presentation"
@keydown='handleKeydown(event)' @keydown='handleKeydown(event)'
> >
@@ -183,11 +196,20 @@ component Modal {
aria-label='{closeLabel}' aria-label='{closeLabel}'
@click='hideModal("close-button", event)' @click='hideModal("close-button", event)'
> >
<span <svg
class="icon-[lucide--x]" viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true" aria-hidden="true"
> >
</span> <path d="M18 6 6 18" />
<path d="M6 6 18 18" />
</svg>
</button> </button>
{/if} {/if}
</header> </header>
@@ -277,16 +299,26 @@ component Modal {
--modal-contrast: var(--wire-color-secondary-contrast); --modal-contrast: var(--wire-color-secondary-contrast);
} }
/*
* Fallbacks below (the second var() argument): the theme token generator
* (packages/styles/src/theme.ts) only emits a real -contrast token for
* primary and secondary. info, success, and danger have no contrast
* token defined at all, so var(--wire-color-info-contrast) with no
* fallback resolves to nothing, and --modal-contrast becomes invalid --
* which made confirm-button and solid-panel text unreadable. White is a
* safe default against these saturated colors until the theme package
* defines real tokens for them.
*/
.wire-modal[data-color="info"] { .wire-modal[data-color="info"] {
--modal-accent: var(--wire-color-info); --modal-accent: var(--wire-color-info);
--modal-soft: var(--wire-color-info-soft); --modal-soft: var(--wire-color-info-soft);
--modal-contrast: var(--wire-color-info-contrast); --modal-contrast: var(--wire-color-info-contrast, white);
} }
.wire-modal[data-color="success"] { .wire-modal[data-color="success"] {
--modal-accent: var(--wire-color-success); --modal-accent: var(--wire-color-success);
--modal-soft: var(--wire-color-success-soft); --modal-soft: var(--wire-color-success-soft);
--modal-contrast: var(--wire-color-success-contrast); --modal-contrast: var(--wire-color-success-contrast, white);
} }
.wire-modal[data-color="warning"] { .wire-modal[data-color="warning"] {
@@ -299,7 +331,7 @@ component Modal {
.wire-modal[data-destructive="true"] { .wire-modal[data-destructive="true"] {
--modal-accent: var(--wire-color-danger); --modal-accent: var(--wire-color-danger);
--modal-soft: var(--wire-color-danger-soft); --modal-soft: var(--wire-color-danger-soft);
--modal-contrast: var(--wire-color-on-danger); --modal-contrast: var(--wire-color-on-danger, white);
} }
.wire-modal__trigger, .wire-modal__trigger,
@@ -321,8 +353,35 @@ component Modal {
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 650; font-weight: 650;
cursor: pointer; cursor: pointer;
transition: opacity 150ms ease, transform 150ms ease, box-shadow 150ms ease;
} }
.wire-modal__trigger:hover {
opacity: 0.92;
}
.wire-modal__trigger:active {
transform: scale(0.97);
}
.wire-modal__trigger:focus-visible {
outline: none;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--modal-accent) 40%, transparent);
}
/*
* Hidden by default so the SSR-rendered HTML never paints the layer before
* hydration runs. data-open on the wire-modal root is evaluated and
* serialized to a real true or false string at render time -- a plain
* bind, not the raw-expression data-show directive -- so this selector
* is correct on first paint with zero flash, with no dependency on
* client JS having run yet. The data-show attribute and client directive
* still run after hydration to keep things in sync for state changes,
* but visibility itself is driven by CSS here. visibility (rather than
* display) is used so the open and close transitions below can actually
* animate -- a box that starts at display: none has no prior frame to
* transition from.
*/
.wire-modal__layer { .wire-modal__layer {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -331,6 +390,15 @@ component Modal {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 1rem; padding: 1rem;
visibility: hidden;
opacity: 0;
transition: opacity 180ms ease, visibility 0s linear 180ms;
}
.wire-modal[data-open="true"] .wire-modal__layer {
visibility: visible;
opacity: 1;
transition: opacity 180ms ease, visibility 0s linear 0s;
} }
.wire-modal[data-placement="top"] .wire-modal__layer { .wire-modal[data-placement="top"] .wire-modal__layer {
@@ -338,6 +406,12 @@ component Modal {
padding-top: clamp(1rem, 8vh, 5rem); padding-top: clamp(1rem, 8vh, 5rem);
} }
.wire-modal[data-scroll="page"] .wire-modal__layer {
align-items: flex-start;
overflow-y: auto;
padding: 2.5rem 1rem;
}
.wire-modal__backdrop { .wire-modal__backdrop {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -370,6 +444,18 @@ component Modal {
0 1px 0 color-mix(in srgb, white 5%, transparent) inset, 0 1px 0 color-mix(in srgb, white 5%, transparent) inset,
0 40px 110px color-mix(in srgb, black 38%, transparent); 0 40px 110px color-mix(in srgb, black 38%, transparent);
overflow: hidden; overflow: hidden;
opacity: 0;
transform: scale(0.96) translateY(10px);
transition: opacity 180ms ease, transform 220ms cubic-bezier(0.16, 1, 0.3, 1);
}
.wire-modal[data-open="true"] .wire-modal__panel {
opacity: 1;
transform: none;
}
.wire-modal[data-size="xs"] .wire-modal__panel {
width: min(19rem, calc(100vw - 2rem));
} }
.wire-modal[data-size="sm"] .wire-modal__panel { .wire-modal[data-size="sm"] .wire-modal__panel {
@@ -390,6 +476,10 @@ component Modal {
max-height: none; max-height: none;
} }
.wire-modal[data-scroll="page"] .wire-modal__panel {
max-height: none;
}
.wire-modal[data-variant="soft"] .wire-modal__panel { .wire-modal[data-variant="soft"] .wire-modal__panel {
background: background:
linear-gradient(145deg, var(--modal-soft), transparent 68%), linear-gradient(145deg, var(--modal-soft), transparent 68%),
@@ -447,6 +537,7 @@ component Modal {
} }
.wire-modal__heading-copy h2 { .wire-modal__heading-copy h2 {
color: inherit;
font-size: 1.08rem; font-size: 1.08rem;
font-weight: 650; font-weight: 650;
line-height: 1.3; line-height: 1.3;
@@ -462,32 +553,100 @@ component Modal {
color: color-mix(in srgb, currentColor 76%, transparent); color: color-mix(in srgb, currentColor 76%, transparent);
} }
/*
* padding is reset explicitly: an app-level `button { padding: … }` rule
* outranks the browser default, and 1rem of horizontal padding left this
* 2.15rem button with a ~2px content box -- which squeezed the icon to
* 0.4px wide and read as "the close button has no icon".
*/
.wire-modal__close { .wire-modal__close {
appearance: none; appearance: none;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex: 0 0 auto; flex: 0 0 auto;
width: 2.35rem; padding: 0;
height: 2.35rem; width: 2.15rem;
height: 2.15rem;
color: var(--wire-color-text-muted); color: var(--wire-color-text-muted);
background: var(--wire-color-surface-soft); background: transparent;
border: 1px solid var(--wire-color-border); border: 1px solid transparent;
border-radius: 0.75rem; border-radius: 9999px;
cursor: pointer; cursor: pointer;
transition: background 150ms ease, color 150ms ease, border-color 150ms ease, transform 150ms ease;
}
/*
* On a solid panel the background is the accent color, so the muted-grey
* default is close to invisible -- the dismiss affordance reads as
* missing rather than subtle. Derive it from the panel contrast color
* instead, and give it a faint ring so it is unmistakably a control.
*/
.wire-modal[data-variant="solid"] .wire-modal__close {
color: color-mix(in srgb, currentColor 82%, transparent);
border-color: color-mix(in srgb, currentColor 35%, transparent);
}
.wire-modal[data-variant="solid"] .wire-modal__close:hover {
color: currentColor;
background: color-mix(in srgb, black 18%, transparent);
border-color: color-mix(in srgb, currentColor 55%, transparent);
}
.wire-modal__close:hover {
color: var(--wire-color-text);
background: var(--wire-color-surface-soft);
} }
.wire-modal__close:hover,
.wire-modal__close:focus-visible { .wire-modal__close:focus-visible {
color: var(--modal-accent); color: var(--modal-accent);
border-color: color-mix(in srgb, var(--modal-accent) 34%, var(--wire-color-border)); border-color: color-mix(in srgb, var(--modal-accent) 45%, transparent);
outline: none; outline: none;
} }
.wire-modal__close:active {
transform: scale(0.92);
}
/* Never let the glyph be shrunk by the flex container. */
.wire-modal__close svg {
flex: 0 0 auto;
width: 1rem;
height: 1rem;
}
/*
* Slot content is authored by the host app, so the app global stylesheet
* styles it too. A bare element selector there (p { color: ... }) beats
* anything the panel merely *inherits*, which is how solid-variant modals
* ended up with muted grey body copy on a saturated accent background --
* unreadable, and worst exactly where contrast matters most (the
* destructive confirm). Setting the color on the body makes the panel
* choice explicit instead of leaving it to inheritance.
*
* NOTE: apostrophes are avoided in .wrn style comments on purpose -- the
* block scanner treats a quote as a string delimiter while it counts
* braces, so a stray one breaks parsing of the whole component.
*/
.wire-modal__body { .wire-modal__body {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
padding: 1.4rem; padding: 1.4rem;
color: var(--wire-color-text);
}
/*
* :where() keeps this at the specificity of .wire-modal__body alone, so it
* outranks a global element selector but still yields to any class the app
* puts on its own slot content (an error message, a muted caption). A
* plain .wire-modal__body p list would have quietly overridden those.
*/
.wire-modal__body :where(p, li, dd, dt, h1, h2, h3, h4, h5, h6, span, label, code) {
color: inherit;
}
.wire-modal[data-variant="solid"] .wire-modal__body {
color: var(--modal-contrast);
} }
.wire-modal[data-scrollable="true"] .wire-modal__body { .wire-modal[data-scrollable="true"] .wire-modal__body {
@@ -548,6 +707,18 @@ component Modal {
border: 1px solid transparent; border: 1px solid transparent;
} }
/*
* On a solid-variant panel the panel background is also --modal-accent,
* so a plain primary button (same color) has no visible edge against it.
* Darken the fill slightly and add a light border so the button still
* reads as a distinct, clickable pill instead of blending into the panel.
*/
.wire-modal[data-variant="solid"] .wire-modal__button--primary {
background: color-mix(in srgb, black 18%, var(--modal-accent));
border-color: color-mix(in srgb, white 32%, transparent);
box-shadow: 0 1px 0 color-mix(in srgb, white 12%, transparent) inset;
}
.wire-modal__button:disabled { .wire-modal__button:disabled {
opacity: 0.55; opacity: 0.55;
cursor: not-allowed; cursor: not-allowed;
@@ -575,6 +746,7 @@ component Modal {
} }
.wire-modal__panel, .wire-modal__panel,
.wire-modal[data-size="xs"] .wire-modal__panel,
.wire-modal[data-size="sm"] .wire-modal__panel, .wire-modal[data-size="sm"] .wire-modal__panel,
.wire-modal[data-size="lg"] .wire-modal__panel, .wire-modal[data-size="lg"] .wire-modal__panel,
.wire-modal[data-size="xl"] .wire-modal__panel { .wire-modal[data-size="xl"] .wire-modal__panel {
@@ -599,10 +771,17 @@ component Modal {
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.wire-modal__button, .wire-modal__button,
.wire-modal__spinner { .wire-modal__spinner,
.wire-modal__layer,
.wire-modal__panel,
.wire-modal__close {
animation: none; animation: none;
transition: none; transition: none;
} }
.wire-modal__panel {
transform: none;
}
} }
} }
} }
+338 -6
View File
@@ -1,22 +1,354 @@
// Nav -- a navigation link list, flat or with submenus.
//
// <Nav items='[{"label":"Home","href":"/","value":"home"}]' active="home" />
//
// An item carrying its own items array becomes a submenu. Depth is capped at
// three levels: this template language has no component recursion, so each
// level is written out, and three covers any realistic navigation.
//
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
// and silently swallows the rule that follows it.
component Nav { component Nav {
outputs { outputs {
select(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null) select(payload: { item: object; value: string })
change(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
} }
props { props {
size: string = "default"
color: string = "primary" color: string = "primary"
label: string = "Nav" size: string = "default"
items: unknown[] = [] items: unknown[] = []
active: string = "" active: string = ""
orientation: string = "horizontal" orientation: string = "horizontal"
label: string = "Main"
collapsible: boolean = true
toggleLabel: string = "Menu"
class: string = "" class: string = ""
} }
state expanded = false
functions {
shared function itemList() {
return Array.isArray(items) ? items : []
}
shared function childrenOf(item) {
return item && Array.isArray(item.items) ? item.items : []
}
shared function isActive(item) {
return Boolean(item.value) && item.value === active
}
shared function rovingAxis() {
return orientation === "vertical" ? "vertical" : "horizontal"
}
client function choose(item) {
if (item.disabled) {
return
}
output.select({ item: item, value: item.value || "" })
}
client function toggleMenu() {
expanded = !expanded
}
}
view { view {
<nav class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--nav wire-next--{orientation} {class}" aria-label="{label}"> <nav
{#each items as item}<a href="{item.href}" aria-current="{item.value === active ? 'page' : ''}">{item.label}</a>{/each} {...attrs}
data-ui-component="Nav"
class='wire-nav {class}'
data-orientation='{orientation}'
data-color='{color}'
data-size='{size}'
data-expanded='{expanded}'
role="navigation"
aria-label='{label}'
>
<button
type="button"
class="wire-nav__toggle"
data-show="collapsible"
aria-expanded='{expanded}'
aria-label='{toggleLabel}'
@click='toggleMenu()'
>
<span class="wire-nav__toggle-bar" aria-hidden="true"></span>
<span class="wire-nav__toggle-text">{toggleLabel}</span>
</button>
<ul class="wire-nav__list" data-wrn-roving='{rovingAxis()}'>
{#each itemList() as item}
<li class="wire-nav__item" data-has-children='{childrenOf(item).length > 0}'>
<a
class="wire-nav__link"
href='{item.href || "#"}'
data-wrn-roving-item="true"
data-active='{isActive(item)}'
aria-current='{isActive(item) ? "page" : "false"}'
aria-disabled='{item.disabled ? "true" : "false"}'
@click='choose(item)'
>
<span
class='wire-nav__icon {item.icon}'
data-show="item.icon"
aria-hidden="true"
></span>
<span class="wire-nav__label">{item.label}</span>
<span class="wire-nav__badge" data-show="item.badge">{item.badge}</span>
<span
class="wire-nav__arrow"
data-show="childrenOf(item).length > 0"
aria-hidden="true"
>&#8250;</span>
</a>
<ul
class="wire-nav__submenu"
data-wrn-anchored="true"
data-show="childrenOf(item).length > 0"
>
{#each childrenOf(item) as child}
<li class="wire-nav__item" data-has-children='{childrenOf(child).length > 0}'>
<a
class="wire-nav__link"
href='{child.href || "#"}'
data-active='{isActive(child)}'
aria-current='{isActive(child) ? "page" : "false"}'
aria-disabled='{child.disabled ? "true" : "false"}'
@click='choose(child)'
>
<span
class='wire-nav__icon {child.icon}'
data-show="child.icon"
aria-hidden="true"
></span>
<span class="wire-nav__label">{child.label}</span>
<span class="wire-nav__badge" data-show="child.badge">{child.badge}</span>
<span
class="wire-nav__arrow"
data-show="childrenOf(child).length > 0"
aria-hidden="true"
>&#8250;</span>
</a>
<ul
class="wire-nav__submenu wire-nav__submenu--level3"
data-wrn-anchored="true"
data-show="childrenOf(child).length > 0"
>
{#each childrenOf(child) as leaf}
<li class="wire-nav__item">
<a
class="wire-nav__link"
href='{leaf.href || "#"}'
data-active='{isActive(leaf)}'
aria-current='{isActive(leaf) ? "page" : "false"}'
aria-disabled='{leaf.disabled ? "true" : "false"}'
@click='choose(leaf)'
>
<span
class='wire-nav__icon {leaf.icon}'
data-show="leaf.icon"
aria-hidden="true"
></span>
<span class="wire-nav__label">{leaf.label}</span>
</a>
</li>
{/each}
</ul>
</li>
{/each}
</ul>
</li>
{/each}
</ul>
<slot /> <slot />
</nav> </nav>
} }
style {
.wire-nav {
--nav-accent: var(--wire-color-primary);
max-width: 100%;
}
.wire-nav[data-color="secondary"] {
--nav-accent: var(--wire-color-secondary);
}
.wire-nav[data-color="success"] {
--nav-accent: var(--wire-color-success);
}
.wire-nav[data-color="danger"] {
--nav-accent: var(--wire-color-danger);
}
.wire-nav[data-color="info"] {
--nav-accent: var(--wire-color-info);
}
.wire-nav[data-size="sm"] {
font-size: 0.82rem;
}
.wire-nav[data-size="lg"] {
font-size: 1rem;
}
.wire-nav__list {
display: flex;
align-items: center;
gap: 0.25rem;
margin: 0;
padding: 0;
list-style: none;
}
.wire-nav[data-orientation="vertical"] .wire-nav__list {
flex-direction: column;
align-items: stretch;
}
.wire-nav__item {
position: relative;
}
.wire-nav__link {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.45rem 0.7rem;
border-radius: var(--wire-radius-sm);
color: var(--wire-color-text-muted);
font-size: 0.9rem;
font-weight: 600;
text-decoration: none;
}
.wire-nav__link:hover {
background: var(--wire-color-surface-soft);
color: var(--wire-color-text);
}
.wire-nav__link:focus-visible {
outline: 2px solid var(--nav-accent);
outline-offset: 2px;
}
.wire-nav__link[data-active="true"] {
background: color-mix(in srgb, var(--nav-accent) 16%, transparent);
color: var(--nav-accent);
}
.wire-nav__link[aria-disabled="true"] {
opacity: 0.5;
pointer-events: none;
}
.wire-nav__badge {
padding: 0.05rem 0.4rem;
border-radius: 999px;
background: color-mix(in srgb, var(--nav-accent) 16%, transparent);
color: var(--nav-accent);
font-size: 0.72rem;
}
.wire-nav__arrow {
display: inline-block;
transition: transform 160ms ease;
}
.wire-nav__item:hover > .wire-nav__link > .wire-nav__arrow,
.wire-nav__item:focus-within > .wire-nav__link > .wire-nav__arrow {
transform: rotate(90deg);
}
.wire-nav__submenu {
position: absolute;
z-index: 30;
top: 100%;
left: 0;
min-width: 12rem;
margin: 0;
padding: 0.35rem;
border: 1px solid var(--wire-color-border);
border-radius: var(--wire-radius-md);
background: var(--wire-color-surface);
box-shadow: var(--wire-shadow-1);
list-style: none;
opacity: 0;
visibility: hidden;
transition: opacity 160ms ease;
}
.wire-nav__item:hover > .wire-nav__submenu,
.wire-nav__item:focus-within > .wire-nav__submenu {
opacity: 1;
visibility: visible;
}
.wire-nav__submenu--level3 {
top: 0;
left: 100%;
}
.wire-nav__toggle {
display: none;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.7rem;
border: 1px solid var(--wire-color-border);
border-radius: var(--wire-radius-sm);
background: var(--wire-color-surface);
color: var(--wire-color-text);
font: inherit;
font-size: 0.9rem;
cursor: pointer;
}
.wire-nav__toggle-bar {
width: 1rem;
height: 2px;
background: currentColor;
box-shadow:
0 -5px 0 currentColor,
0 5px 0 currentColor;
}
/*
* On a phone the bar becomes a disclosure: submenus stop being floating
* overlays and stack inline, because a hover-opened overlay is
* unreachable on touch.
*/
@media (max-width: 767px) {
.wire-nav__toggle {
display: inline-flex;
}
.wire-nav__list {
flex-direction: column;
align-items: stretch;
}
.wire-nav[data-expanded="false"] .wire-nav__list {
display: none;
}
.wire-nav__submenu,
.wire-nav__submenu--level3 {
position: static;
opacity: 1;
visibility: visible;
border: 0;
box-shadow: none;
padding-left: 1rem;
}
}
}
} }
+441 -5
View File
@@ -86,11 +86,11 @@ size: string = "default"
</button> </button>
<div class="wire-navbar__collapse {mobileOpen ? 'is-open' : ''}"> <div class="wire-navbar__collapse {mobileOpen ? 'is-open' : ''}">
<nav class="wire-navbar__menus" aria-label="{label}"> <nav class="wire-navbar__menus" aria-label="{label}" data-wrn-roving="horizontal">
{#each items as item} {#each items as item}
{#if item.children && item.children.length} {#if item.children && item.children.length}
<details class="wire-navbar__dropdown wire-navbar__dropdown--{item.type || 'dropdown'}" name="wire-navbar-menu" @toggle="toggleDropdown(event, item)"> <details class="wire-navbar__dropdown wire-navbar__dropdown--{item.type || 'dropdown'}" name="wire-navbar-menu" @toggle="toggleDropdown(event, item)">
<summary aria-current="{isItemActive(item) ? 'page' : ''}"> <summary data-wrn-roving-item="true" aria-current="{isItemActive(item) ? 'page' : 'false'}">
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if} {#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
<span>{item.label}</span> <span>{item.label}</span>
<span class="wire-navbar__chevron" aria-hidden="true"></span> <span class="wire-navbar__chevron" aria-hidden="true"></span>
@@ -103,13 +103,13 @@ size: string = "default"
{#if child.label}<strong class="wire-navbar__group-title">{child.label}</strong>{/if} {#if child.label}<strong class="wire-navbar__group-title">{child.label}</strong>{/if}
{#if child.description}<small>{child.description}</small>{/if} {#if child.description}<small>{child.description}</small>{/if}
{#each child.children as nested} {#each child.children as nested}
<a href="{nested.href || '#'}" target="{nested.target || ''}" rel="{nested.rel || ''}" aria-current="{nested.value === active ? 'page' : ''}" @click="selectItem(nested, 3)"> <a href="{nested.href || '#'}" target="{nested.target || ''}" rel="{nested.rel || ''}" aria-current="{nested.value === active ? 'page' : 'false'}" @click="selectItem(nested, 3)">
{#if nested.icon}<span class="{nested.icon}" aria-hidden="true"></span>{/if} {#if nested.icon}<span class="{nested.icon}" aria-hidden="true"></span>{/if}
<span><strong>{nested.label}</strong>{#if nested.description}<small>{nested.description}</small>{/if}</span> <span><strong>{nested.label}</strong>{#if nested.description}<small>{nested.description}</small>{/if}</span>
</a> </a>
{/each} {/each}
{:else} {:else}
<a href="{child.href || '#'}" target="{child.target || ''}" rel="{child.rel || ''}" aria-current="{child.value === active ? 'page' : ''}" @click="selectItem(child, 2)"> <a href="{child.href || '#'}" target="{child.target || ''}" rel="{child.rel || ''}" aria-current="{child.value === active ? 'page' : 'false'}" @click="selectItem(child, 2)">
{#if child.icon}<span class="{child.icon}" aria-hidden="true"></span>{/if} {#if child.icon}<span class="{child.icon}" aria-hidden="true"></span>{/if}
<span><strong>{child.label}</strong>{#if child.description}<small>{child.description}</small>{/if}</span> <span><strong>{child.label}</strong>{#if child.description}<small>{child.description}</small>{/if}</span>
</a> </a>
@@ -119,7 +119,7 @@ size: string = "default"
</div> </div>
</details> </details>
{:else} {:else}
<a class="wire-navbar__menu-link" href="{item.href || '#'}" target="{item.target || ''}" rel="{item.rel || ''}" aria-current="{item.value === active ? 'page' : ''}" @click="selectItem(item, 1)"> <a class="wire-navbar__menu-link" data-wrn-roving-item="true" href="{item.href || '#'}" target="{item.target || ''}" rel="{item.rel || ''}" aria-current="{item.value === active ? 'page' : 'false'}" @click="selectItem(item, 1)">
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if} {#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
<span>{item.label}</span> <span>{item.label}</span>
</a> </a>
@@ -140,4 +140,440 @@ size: string = "default"
</div> </div>
</header> </header>
} }
style {
/* 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;
}
@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;
}
}
@media (max-width: 600px) {
.wire-navbar__brand-copy small {
display: none;
}
}
@keyframes wire-navbar-mobile-panel {
from {
opacity: 0;
transform: translateY(-0.35rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
}
} }
+277 -9
View File
@@ -1,23 +1,291 @@
// Pagination -- page controls over a known total.
//
// <Pagination page={2} pageSize={10} total={137} variant="numbered" />
//
// The component owns no data. It reports the requested page through its
// change output and lets the caller fetch or slice.
//
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
// and silently swallows the rule that follows it.
component Pagination { component Pagination {
outputs { outputs {
change(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null) change(payload: { page: number; pageSize: number })
previous(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object }) previous(payload: { page: number })
next(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object }) next(payload: { page: number })
} }
props { props {
size: string = "default"
color: string = "primary" color: string = "primary"
size: string = "default"
page: number = 1
pageSize: number = 10
total: number = 0
variant: string = "compact"
siblingCount: number = 1
showSummary: boolean = true
label: string = "Pagination" label: string = "Pagination"
items: unknown[] = [] previousLabel: string = "Previous"
active: string = "" nextLabel: string = "Next"
orientation: string = "horizontal"
class: string = "" class: string = ""
} }
functions {
shared function lastPage() {
var size = Number(pageSize) > 0 ? Number(pageSize) : 10
var count = Number(total) > 0 ? Number(total) : 0
return Math.max(1, Math.ceil(count / size))
}
// Out-of-range values arrive routinely: page travels as an HTML attribute
// and callers compute it from data that may have shrunk underneath them.
shared function currentPage() {
var value = Number(page)
if (!value || value < 1) {
return 1
}
return Math.min(value, lastPage())
}
shared function firstShown() {
if (Number(total) < 1) {
return 0
}
return (currentPage() - 1) * (Number(pageSize) || 10) + 1
}
shared function lastShown() {
return Math.min(currentPage() * (Number(pageSize) || 10), Number(total) || 0)
}
shared function pageNumbers() {
var last = lastPage()
var current = currentPage()
var siblings = Math.max(0, Number(siblingCount) || 0)
var start = Math.max(1, current - siblings)
var end = Math.min(last, current + siblings)
var pages = []
if (start > 1) {
pages.push({ value: 1, label: "1", gap: false })
if (start > 2) {
pages.push({ value: 0, label: "...", gap: true })
}
}
for (var index = start; index <= end; index += 1) {
pages.push({ value: index, label: String(index), gap: false })
}
if (end < last) {
if (end < last - 1) {
pages.push({ value: 0, label: "...", gap: true })
}
pages.push({ value: last, label: String(last), gap: false })
}
return pages
}
client function goToPage(target) {
var next = Math.min(Math.max(1, Number(target) || 1), lastPage())
output.change({ page: next, pageSize: Number(pageSize) || 10 })
}
client function goPrevious() {
var target = Math.max(1, currentPage() - 1)
output.previous({ page: target })
output.change({ page: target, pageSize: Number(pageSize) || 10 })
}
client function goNext() {
var target = Math.min(lastPage(), currentPage() + 1)
output.next({ page: target })
output.change({ page: target, pageSize: Number(pageSize) || 10 })
}
}
view { view {
<nav class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--pagination wire-next--{orientation} {class}" aria-label="{label}"> <nav
{#each items as item}<a href="{item.href}" aria-current="{item.value === active ? 'page' : ''}">{item.label}</a>{/each} {...attrs}
data-ui-component="Pagination"
class='wire-pagination {class}'
data-variant='{variant}'
data-color='{color}'
data-size='{size}'
role="navigation"
aria-label='{label}'
>
<p class="wire-pagination__summary" data-show="showSummary">
{firstShown()} to {lastShown()} of {total}
</p>
<div class="wire-pagination__controls">
<button
type="button"
class="wire-pagination__step"
aria-label='{previousLabel}'
@click='goPrevious()'
>
<span class="wire-pagination__step-icon" aria-hidden="true">&#8249;</span>
<span class="wire-pagination__step-label">{previousLabel}</span>
</button>
<ol class="wire-pagination__pages" data-show="variant === 'numbered'">
{#each pageNumbers() as entry}
<li class="wire-pagination__slot">
<span class="wire-pagination__gap" data-show="entry.gap">{entry.label}</span>
<button
type="button"
class="wire-pagination__page"
data-show="!entry.gap"
data-active='{entry.value === currentPage()}'
aria-current='{entry.value === currentPage() ? "page" : "false"}'
@click='goToPage(entry.value)'
>
{entry.label}
</button>
</li>
{/each}
</ol>
<p class="wire-pagination__compact" data-show="variant !== 'numbered'">
{currentPage()} / {lastPage()}
</p>
<button
type="button"
class="wire-pagination__step"
aria-label='{nextLabel}'
@click='goNext()'
>
<span class="wire-pagination__step-label">{nextLabel}</span>
<span class="wire-pagination__step-icon" aria-hidden="true">&#8250;</span>
</button>
</div>
<slot /> <slot />
</nav> </nav>
} }
style {
.wire-pagination {
--pagination-accent: var(--wire-color-primary);
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
max-width: 100%;
}
.wire-pagination[data-color="secondary"] {
--pagination-accent: var(--wire-color-secondary);
}
.wire-pagination[data-color="success"] {
--pagination-accent: var(--wire-color-success);
}
.wire-pagination[data-color="danger"] {
--pagination-accent: var(--wire-color-danger);
}
.wire-pagination[data-color="info"] {
--pagination-accent: var(--wire-color-info);
}
.wire-pagination[data-size="sm"] {
font-size: 0.82rem;
}
.wire-pagination[data-size="lg"] {
font-size: 1rem;
}
.wire-pagination__summary {
margin: 0;
color: var(--wire-color-text-muted);
font-size: 0.82rem;
}
.wire-pagination__controls {
display: flex;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
}
.wire-pagination__pages {
display: flex;
align-items: center;
gap: 0.25rem;
margin: 0;
padding: 0;
list-style: none;
}
.wire-pagination__slot {
display: inline-flex;
}
.wire-pagination__page,
.wire-pagination__step {
appearance: none;
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-width: 2.25rem;
justify-content: center;
padding: 0.4rem 0.6rem;
border: 1px solid var(--wire-color-border);
border-radius: var(--wire-radius-sm);
background: var(--wire-color-surface);
color: var(--wire-color-text);
font: inherit;
font-size: 0.85rem;
cursor: pointer;
}
.wire-pagination__page:hover,
.wire-pagination__step:hover {
background: var(--wire-color-surface-soft);
}
.wire-pagination__page:focus-visible,
.wire-pagination__step:focus-visible {
outline: 2px solid var(--pagination-accent);
outline-offset: 2px;
}
.wire-pagination__page[data-active="true"] {
border-color: var(--pagination-accent);
background: var(--pagination-accent);
color: var(--wire-color-primary-contrast);
font-weight: 700;
}
.wire-pagination__gap {
padding: 0 0.35rem;
color: var(--wire-color-text-muted);
}
.wire-pagination__compact {
margin: 0;
padding: 0 0.5rem;
color: var(--wire-color-text-muted);
font-size: 0.85rem;
}
/* Below the small breakpoint the word labels crowd the arrows out. */
@media (max-width: 639px) {
.wire-pagination {
justify-content: center;
}
.wire-pagination__step-label {
display: none;
}
.wire-pagination__summary {
width: 100%;
text-align: center;
}
}
}
} }
+8 -1
View File
@@ -123,12 +123,13 @@ component Popover {
<section <section
class="wire-popover__panel" class="wire-popover__panel"
data-wrn-anchored="true"
data-show='{open || visible}' data-show='{open || visible}'
role="dialog" role="dialog"
aria-label='{title || triggerLabel}' aria-label='{title || triggerLabel}'
> >
{#if showArrow} {#if showArrow}
<span class="wire-popover__arrow" aria-hidden="true"></span> <span class="wire-popover__arrow" data-wrn-anchor-arrow="true" aria-hidden="true"></span>
{/if} {/if}
{#if title || description || icon || showClose} {#if title || description || icon || showClose}
@@ -471,7 +472,13 @@ component Popover {
color: color-mix(in srgb, currentColor 76%, transparent); color: color-mix(in srgb, currentColor 76%, transparent);
} }
/*
* padding is reset explicitly: an app-level `button { padding: ... }` rule
* outranks the browser default and crushes the icon inside this
* fixed-size button. Same trap as Modal and Drawer.
*/
.wire-popover__close { .wire-popover__close {
padding: 0;
appearance: none; appearance: none;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+157 -6
View File
@@ -1,21 +1,172 @@
// Scrollspy -- a table of contents that follows the reader.
//
// <Scrollspy items='[{"label":"Overview","href":"#overview"}]' />
//
// Each href points at an element on the page. The runtime observes those
// elements and moves aria-current to the link for whichever one is in view.
//
// The runtime writes the marker straight onto the links rather than into
// component state. An IntersectionObserver callback fires long after the
// client function that registered it has returned, and a state write made
// there is dropped -- so the DOM is the only place the answer can live.
//
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
// and silently swallows the rule that follows it.
component Scrollspy { component Scrollspy {
outputs { outputs {
change(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null) change(payload: { href: string; label: string })
} }
props { props {
size: string = "default"
color: string = "primary" color: string = "primary"
label: string = "Scrollspy" size: string = "default"
items: unknown[] = [] items: unknown[] = []
active: string = "" active: string = ""
orientation: string = "horizontal" label: string = "On this page"
heading: string = ""
class: string = "" class: string = ""
} }
functions {
shared function itemList() {
return Array.isArray(items) ? items : []
}
shared function isActive(item) {
return Boolean(item.href) && item.href === active
}
}
view { view {
<nav class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--scrollspy wire-next--{orientation} {class}" aria-label="{label}"> <nav
{#each items as item}<a href="{item.href}" aria-current="{item.value === active ? 'page' : ''}">{item.label}</a>{/each} {...attrs}
data-ui-component="Scrollspy"
class='wire-scrollspy {class}'
data-color='{color}'
data-size='{size}'
data-wrn-scrollspy="true"
role="navigation"
aria-label='{label}'
>
<p class="wire-scrollspy__heading" data-show="heading">{heading}</p>
<ul class="wire-scrollspy__list">
{#each itemList() as item}
<li class="wire-scrollspy__item">
<a
class="wire-scrollspy__link"
href='{item.href || "#"}'
data-active='{isActive(item)}'
aria-current='{isActive(item) ? "location" : "false"}'
>
<span class="wire-scrollspy__label">{item.label}</span>
</a>
</li>
{/each}
</ul>
<slot /> <slot />
</nav> </nav>
} }
style {
.wire-scrollspy {
--scrollspy-accent: var(--wire-color-primary);
max-width: 100%;
}
.wire-scrollspy[data-color="secondary"] {
--scrollspy-accent: var(--wire-color-secondary);
}
.wire-scrollspy[data-color="success"] {
--scrollspy-accent: var(--wire-color-success);
}
.wire-scrollspy[data-color="danger"] {
--scrollspy-accent: var(--wire-color-danger);
}
.wire-scrollspy[data-color="info"] {
--scrollspy-accent: var(--wire-color-info);
}
.wire-scrollspy[data-size="sm"] {
font-size: 0.82rem;
}
.wire-scrollspy[data-size="lg"] {
font-size: 1rem;
}
.wire-scrollspy__heading {
margin: 0 0 0.5rem;
color: var(--wire-color-text-muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.wire-scrollspy__list {
display: grid;
gap: 0.1rem;
margin: 0;
padding: 0;
list-style: none;
border-left: 1px solid var(--wire-color-border);
}
.wire-scrollspy__link {
display: block;
padding: 0.3rem 0.75rem;
margin-left: -1px;
border-left: 2px solid transparent;
color: var(--wire-color-text-muted);
font-size: 0.85rem;
text-decoration: none;
}
.wire-scrollspy__link:hover {
color: var(--wire-color-text);
}
.wire-scrollspy__link:focus-visible {
outline: 2px solid var(--scrollspy-accent);
outline-offset: -2px;
}
.wire-scrollspy__link[data-active="true"] {
border-left-color: var(--scrollspy-accent);
color: var(--scrollspy-accent);
font-weight: 600;
}
/*
* A table of contents is a sidebar affordance. On a phone it stops being
* a rail and becomes a horizontal strip that scrolls, so it costs one
* line rather than a screenful.
*/
@media (max-width: 767px) {
.wire-scrollspy__list {
grid-auto-flow: column;
grid-auto-columns: max-content;
overflow-x: auto;
border-left: 0;
border-bottom: 1px solid var(--wire-color-border);
}
.wire-scrollspy__link {
margin-left: 0;
border-left: 0;
border-bottom: 2px solid transparent;
white-space: nowrap;
}
.wire-scrollspy__link[data-active="true"] {
border-left-color: transparent;
border-bottom-color: var(--scrollspy-accent);
}
}
}
} }
+7 -2
View File
@@ -1,4 +1,9 @@
component SearchBox { component SearchBox {
outputs {
search(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
clear(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
}
props { props {
size: string = "default" size: string = "default"
color: string = "primary" color: string = "primary"
@@ -22,7 +27,7 @@ component SearchBox {
data-ui-component="SearchBox" data-ui-component="SearchBox"
role="search" role="search"
class='w-full {class}' class='w-full {class}'
@submit='event.preventDefault(); event.currentTarget.dispatchEvent(new CustomEvent("search", { bubbles: true, detail: { value: query, name: name } }))' @submit='event.preventDefault(); output.search({ value: query, name: name })'
> >
<label <label
class="mb-2 block text-sm font-semibold text-[var(--wire-color-text)]" class="mb-2 block text-sm font-semibold text-[var(--wire-color-text)]"
@@ -61,7 +66,7 @@ component SearchBox {
aria-label="Clear search" aria-label="Clear search"
data-show='query.length > 0 && !disabled' data-show='query.length > 0 && !disabled'
class="absolute right-12 inline-flex size-8 items-center justify-center rounded-lg text-[var(--wire-color-text-muted)] transition hover:bg-[var(--wire-color-surface-soft)] hover:text-[var(--wire-color-text)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-focus)]" class="absolute right-12 inline-flex size-8 items-center justify-center rounded-lg text-[var(--wire-color-text-muted)] transition hover:bg-[var(--wire-color-surface-soft)] hover:text-[var(--wire-color-text)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-focus)]"
@click='query = ""; event.currentTarget.parentElement.querySelector("input")?.focus(); event.currentTarget.dispatchEvent(new CustomEvent("clear", { bubbles: true, detail: { value: "", name: name } }))' @click='query = ""; event.currentTarget.parentElement.querySelector("input")?.focus(); output.clear({ value: "", name: name })'
> >
<span class="icon-[lucide--x] size-4" aria-hidden="true"></span> <span class="icon-[lucide--x] size-4" aria-hidden="true"></span>
</button> </button>
+302 -49
View File
@@ -1,82 +1,335 @@
// Sidebar -- a vertical navigation rail that becomes a drawer on small screens.
//
// <Sidebar items='[{"label":"Dashboard","href":"/","value":"dash"}]' active="dash" />
//
// An entry is one of three shapes:
// { label, href, value } a single link
// { heading, items: [...] } a labelled group
// { label, items: [...] } a collapsible branch, nested to 3 levels
//
// The off-canvas presentation is Drawer rather than a second implementation,
// so the focus trap and the body scroll lock come from one place.
//
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
// and silently swallows the rule that follows it.
import Drawer from "./Drawer.wrn"
component Sidebar { component Sidebar {
outputs { outputs {
toggle(payload: { open: boolean }) toggle(payload: { open: boolean })
open(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object }) open(payload: { sourceEvent: Event })
close(payload: { source: string }) close(payload: { source: string })
select(payload: { item: string | number | boolean | null | object; value: string | number | boolean; level: string }) select(payload: { item: object; value: string; level: number })
} }
props { props {
size: string = "default"
color: string = "primary" color: string = "primary"
size: string = "default"
label: string = "Sidebar" label: string = "Sidebar"
items: unknown[] = [] items: unknown[] = []
active: string = "" active: string = ""
orientation: string = "horizontal"
mobileLabel: string = "Open navigation" mobileLabel: string = "Open navigation"
drawerTitle: string = "Navigation"
class: string = "" class: string = ""
} }
state mobileOpen: boolean = false state drawerOpen = false
functions { functions {
client function toggleSidebar() { shared function entryList() {
mobileOpen = !mobileOpen return Array.isArray(items) ? items : []
output.toggle({ open: mobileOpen })
output[mobileOpen ? "open" : "close"]({ source: "mobile" })
} }
client function closeSidebar() { // Accepts children as well as items: children is what shipped in 0.8.5
mobileOpen = false // and existing callers should keep working.
output.close({ source: "mobile" }) shared function childrenOf(entry) {
if (!entry) {
return []
}
if (Array.isArray(entry.items)) {
return entry.items
}
return Array.isArray(entry.children) ? entry.children : []
} }
client function selectItem(item, level) { shared function isGroup(entry) {
mobileOpen = false return Boolean(entry.heading)
output.select({ item: item, value: item.value || "", level: level }) }
shared function isActive(entry) {
return Boolean(entry.value) && entry.value === active
}
client function choose(entry, level) {
if (entry.disabled) {
return
}
output.select({ item: entry, value: entry.value || "", level: level })
if (drawerOpen) {
drawerOpen = false
output.close({ source: "select" })
}
}
client function openDrawer(sourceEvent) {
drawerOpen = true
output.open({ sourceEvent: sourceEvent })
output.toggle({ open: true })
}
client function closeDrawer() {
drawerOpen = false
output.close({ source: "drawer" })
output.toggle({ open: false })
} }
} }
view { view {
<div class="wire-sidebar-shell wire-next--color-{color} wire-next--size-{size} {mobileOpen ? 'is-open' : ''} {class}"> <div
<button type="button" class="wire-sidebar-toggle" aria-label="{mobileLabel}" aria-expanded="{mobileOpen}" @click="toggleSidebar()"> {...attrs}
<span class="icon-[lucide--panel-left-open]" aria-hidden="true"></span> data-ui-component="Sidebar"
<span>{label}</span> class='wire-sidebar {class}'
data-color='{color}'
data-size='{size}'
>
<button
type="button"
class="wire-sidebar__launcher"
aria-label='{mobileLabel}'
aria-expanded='{drawerOpen}'
@click='openDrawer(event)'
>
<span class="wire-sidebar__launcher-bar" aria-hidden="true"></span>
<span class="wire-sidebar__launcher-text">{mobileLabel}</span>
</button> </button>
<button type="button" class="wire-sidebar-backdrop" aria-label="Close navigation" @click="closeSidebar()"></button>
<nav class="wire-next wire-next--sidebar wire-next--{orientation} wire-sidebar-panel" aria-label="{label}"> <nav class="wire-sidebar__rail" aria-label='{label}'>
<div class="wire-sidebar-panel__head"> <ul class="wire-sidebar__list" data-wrn-roving="vertical">
<strong>{label}</strong> {#each entryList() as entry}
<button type="button" aria-label="Close navigation" @click="closeSidebar()"><span class="icon-[lucide--x]" aria-hidden="true"></span></button> <li class="wire-sidebar__entry" data-group='{isGroup(entry)}'>
</div> <p class="wire-sidebar__heading" data-show="entry.heading">{entry.heading}</p>
<div class="wire-sidebar-items">
{#each items as item} <a
{#if item.children && item.children.length} class="wire-sidebar__link"
<details class="wire-sidebar-group" name="wire-sidebar-group"> data-show="!entry.heading"
<summary> href='{entry.href || "#"}'
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if} data-wrn-roving-item="true"
<span>{item.label}</span> data-active='{isActive(entry)}'
<span class="icon-[lucide--chevron-down]" aria-hidden="true"></span> aria-current='{isActive(entry) ? "page" : "false"}'
</summary> aria-disabled='{entry.disabled ? "true" : "false"}'
<div class="wire-sidebar-group__children"> @click='choose(entry, 1)'
{#each item.children as child} >
<a href="{child.href || '#'}" aria-current="{child.value === active ? 'page' : ''}" @click="selectItem(child, 2)"> <span
{#if child.icon}<span class="{child.icon}" aria-hidden="true"></span>{/if} class='wire-sidebar__icon {entry.icon}'
<span>{child.label}</span> data-show="entry.icon"
</a> aria-hidden="true"
{/each} ></span>
</div> <span class="wire-sidebar__label">{entry.label}</span>
</details> <span class="wire-sidebar__badge" data-show="entry.badge">{entry.badge}</span>
{:else}
<a href="{item.href || '#'}" aria-current="{item.value === active ? 'page' : ''}" @click="selectItem(item, 1)">
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
<span>{item.label}</span>
</a> </a>
{/if}
<ul class="wire-sidebar__sublist" data-show="childrenOf(entry).length > 0">
{#each childrenOf(entry) as child}
<li class="wire-sidebar__entry">
<a
class="wire-sidebar__link"
href='{child.href || "#"}'
data-active='{isActive(child)}'
aria-current='{isActive(child) ? "page" : "false"}'
aria-disabled='{child.disabled ? "true" : "false"}'
@click='choose(child, 2)'
>
<span
class='wire-sidebar__icon {child.icon}'
data-show="child.icon"
aria-hidden="true"
></span>
<span class="wire-sidebar__label">{child.label}</span>
<span class="wire-sidebar__badge" data-show="child.badge">{child.badge}</span>
</a>
<ul
class="wire-sidebar__sublist wire-sidebar__sublist--level3"
data-show="childrenOf(child).length > 0"
>
{#each childrenOf(child) as leaf}
<li class="wire-sidebar__entry">
<a
class="wire-sidebar__link"
href='{leaf.href || "#"}'
data-active='{isActive(leaf)}'
aria-current='{isActive(leaf) ? "page" : "false"}'
@click='choose(leaf, 3)'
>
<span class="wire-sidebar__label">{leaf.label}</span>
</a>
</li>
{/each}
</ul>
</li>
{/each}
</ul>
</li>
{/each} {/each}
</div> </ul>
<slot /> <slot />
</nav> </nav>
<Drawer
open={drawerOpen}
placement="left"
title={drawerTitle}
label={label}
@close="closeDrawer()"
>
<slot name="drawer"></slot>
</Drawer>
</div> </div>
} }
style {
.wire-sidebar {
--sidebar-accent: var(--wire-color-primary);
max-width: 100%;
}
.wire-sidebar[data-color="secondary"] {
--sidebar-accent: var(--wire-color-secondary);
}
.wire-sidebar[data-color="success"] {
--sidebar-accent: var(--wire-color-success);
}
.wire-sidebar[data-color="danger"] {
--sidebar-accent: var(--wire-color-danger);
}
.wire-sidebar[data-color="info"] {
--sidebar-accent: var(--wire-color-info);
}
.wire-sidebar[data-size="sm"] {
font-size: 0.82rem;
}
.wire-sidebar[data-size="lg"] {
font-size: 1rem;
}
.wire-sidebar__rail {
width: 100%;
}
.wire-sidebar__list,
.wire-sidebar__sublist {
display: grid;
gap: 0.1rem;
margin: 0;
padding: 0;
list-style: none;
}
.wire-sidebar__sublist {
margin-left: 0.85rem;
padding-left: 0.5rem;
border-left: 1px solid var(--wire-color-border);
}
.wire-sidebar__entry[data-group="true"] + .wire-sidebar__entry {
margin-top: 0.35rem;
}
.wire-sidebar__heading {
margin: 0.9rem 0 0.35rem;
color: var(--wire-color-text-muted);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.wire-sidebar__link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.6rem;
border-radius: var(--wire-radius-sm);
color: var(--wire-color-text-muted);
font-size: 0.88rem;
font-weight: 600;
text-decoration: none;
}
.wire-sidebar__link:hover {
background: var(--wire-color-surface-soft);
color: var(--wire-color-text);
}
.wire-sidebar__link:focus-visible {
outline: 2px solid var(--sidebar-accent);
outline-offset: 2px;
}
.wire-sidebar__link[data-active="true"] {
background: color-mix(in srgb, var(--sidebar-accent) 16%, transparent);
color: var(--sidebar-accent);
}
.wire-sidebar__link[aria-disabled="true"] {
opacity: 0.5;
pointer-events: none;
}
.wire-sidebar__label {
min-width: 0;
}
.wire-sidebar__badge {
margin-left: auto;
padding: 0.05rem 0.4rem;
border-radius: 999px;
background: color-mix(in srgb, var(--sidebar-accent) 16%, transparent);
color: var(--sidebar-accent);
font-size: 0.72rem;
}
.wire-sidebar__launcher {
display: none;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.7rem;
border: 1px solid var(--wire-color-border);
border-radius: var(--wire-radius-sm);
background: var(--wire-color-surface);
color: var(--wire-color-text);
font: inherit;
font-size: 0.9rem;
cursor: pointer;
}
.wire-sidebar__launcher-bar {
width: 1rem;
height: 2px;
background: currentColor;
box-shadow:
0 -5px 0 currentColor,
0 5px 0 currentColor;
}
/*
* Below the tablet breakpoint the rail is replaced by the drawer launcher.
* A permanent rail eats most of a phone screen, and Drawer already handles
* the focus trap and the scroll lock.
*/
@media (max-width: 767px) {
.wire-sidebar__launcher {
display: inline-flex;
}
.wire-sidebar__rail {
display: none;
}
}
}
} }
+429 -11
View File
@@ -1,24 +1,442 @@
// Stepper -- ordered progress through a sequence.
//
// <Stepper steps='[{"label":"Account"},{"label":"Billing"}]' active={1} />
//
// Each step can be authored by hand instead of using the built-in body, by
// passing a slot named for its index:
//
// <Stepper steps={steps} active={1}>
// <div data-slot="step-1">...anything...</div>
// </Stepper>
//
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
// and silently swallows the rule that follows it.
component Stepper { component Stepper {
outputs { outputs {
change(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null) change(payload: { index: number; step: object })
previous(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object }) back(payload: { index: number; step: object })
next(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object }) next(payload: { index: number; step: object })
complete(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object }) skip(payload: { index: number; step: object })
finish(payload: { index: number; step: object })
} }
props { props {
size: string = "default"
color: string = "primary" color: string = "primary"
label: string = "Stepper" size: string = "default"
items: unknown[] = [] steps: unknown[] = []
active: string = "" active: number = 0
orientation: string = "horizontal" orientation: string = "horizontal"
clickable: boolean = false
label: string = "Progress"
// Render each step body, showing only the active one -- same contract as
// Tabs, so a wizard does not have to hand-roll the panel switching.
showPanel: boolean = false
controls: boolean = false
allowSkip: boolean = false
// Lets a form hold the step. The component never validates anything
// itself: the page owns the form, so it sets this while the step is
// incomplete and clears it once the step passes.
nextDisabled: boolean = false
backLabel: string = "Back"
nextLabel: string = "Next"
skipLabel: string = "Skip"
finishLabel: string = "Finish"
class: string = "" class: string = ""
} }
functions {
shared function stepList() {
return Array.isArray(steps) ? steps : []
}
shared function activeIndex() {
var count = stepList().length
if (count < 1) {
return 0
}
var value = Number(active)
if (!value || value < 0) {
return 0
}
return Math.min(value, count - 1)
}
shared function statusFor(index) {
if (index < activeIndex()) {
return "complete"
}
if (index === activeIndex()) {
return "current"
}
return "upcoming"
}
// An empty orientation is how the roving runtime is told to stay out of
// the way, so a read-only stepper never takes arrow-key focus.
shared function rovingAxis() {
if (!clickable) {
return ""
}
return orientation === "vertical" ? "vertical" : "horizontal"
}
shared function isActiveStep(index) {
return index === activeIndex()
}
shared function lastIndex() {
return Math.max(0, stepList().length - 1)
}
shared function onLastStep() {
return activeIndex() >= lastIndex()
}
shared function activeStep() {
var list = stepList()
return list.length ? list[activeIndex()] : {}
}
client function goBack() {
var target = Math.max(0, activeIndex() - 1)
output.back({ index: target, step: stepList()[target] || {} })
output.change({ index: target, step: stepList()[target] || {} })
}
client function goNext() {
if (nextDisabled) {
return
}
if (onLastStep()) {
output.finish({ index: activeIndex(), step: activeStep() })
return
}
var target = Math.min(lastIndex(), activeIndex() + 1)
output.next({ index: target, step: stepList()[target] || {} })
output.change({ index: target, step: stepList()[target] || {} })
}
client function goSkip() {
var target = Math.min(lastIndex(), activeIndex() + 1)
output.skip({ index: target, step: stepList()[target] || {} })
output.change({ index: target, step: stepList()[target] || {} })
}
client function selectStep(index, step) {
if (!clickable) {
return
}
output.change({ index: index, step: step })
}
}
view { view {
<nav class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--stepper wire-next--{orientation} {class}" aria-label="{label}"> <div
{#each items as item}<a href="{item.href}" aria-current="{item.value === active ? 'page' : ''}">{item.label}</a>{/each} {...attrs}
data-ui-component="Stepper"
class='wire-stepper {class}'
data-orientation='{orientation}'
data-color='{color}'
data-size='{size}'
data-clickable='{clickable}'
>
<ol
class="wire-stepper__list"
data-wrn-roving='{rovingAxis()}'
aria-label='{label}'
>
{#each stepList() as step, index}
<li
class="wire-stepper__step"
data-status='{statusFor(index)}'
aria-current='{statusFor(index) === "current" ? "step" : "false"}'
>
<button
type="button"
class="wire-stepper__button"
data-wrn-roving-item='{clickable ? "true" : "false"}'
@click='selectStep(index, step)'
>
<span class="wire-stepper__marker" aria-hidden="true">
<span class='wire-stepper__icon {step.icon}' data-show="step.icon"></span>
<span class="wire-stepper__number" data-show="!step.icon">{index + 1}</span>
</span>
<span class="wire-stepper__body">
<span class="wire-stepper__label">{step.label}</span>
<span class="wire-stepper__description" data-show="step.description">
{step.description}
</span>
</span>
</button>
<span class="wire-stepper__custom">
<slot name="step-{index}"></slot>
</span>
</li>
{/each}
</ol>
<div class="wire-stepper__panels" data-show="showPanel">
{#each stepList() as step, index}
<div
class="wire-stepper__panel"
data-show='isActiveStep(index)'
aria-hidden='{index === activeIndex() ? "false" : "true"}'
>
<h4 class="wire-stepper__panel-title" data-show="step.title">{step.title}</h4>
<p class="wire-stepper__panel-body" data-show="step.content">{step.content}</p>
<slot name="panel-{index}"></slot>
</div>
{/each}
</div>
<div class="wire-stepper__controls" data-show="controls">
<button
type="button"
class="wire-stepper__button-control wire-stepper__back"
disabled='{activeIndex() === 0}'
@click='goBack()'
>
{backLabel}
</button>
<span class="wire-stepper__controls-spacer"></span>
<button
type="button"
class="wire-stepper__button-control wire-stepper__skip"
data-show="allowSkip && !onLastStep()"
@click='goSkip()'
>
{skipLabel}
</button>
<button
type="button"
class="wire-stepper__button-control wire-stepper__next"
data-primary="true"
disabled='{nextDisabled}'
@click='goNext()'
>
{onLastStep() ? finishLabel : nextLabel}
</button>
</div>
<slot /> <slot />
</nav> </div>
}
style {
.wire-stepper {
--stepper-accent: var(--wire-color-primary);
max-width: 100%;
}
.wire-stepper__list {
display: flex;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
max-width: 100%;
}
.wire-stepper[data-color="secondary"] {
--stepper-accent: var(--wire-color-secondary);
}
.wire-stepper[data-color="success"] {
--stepper-accent: var(--wire-color-success);
}
.wire-stepper[data-color="danger"] {
--stepper-accent: var(--wire-color-danger);
}
.wire-stepper[data-color="info"] {
--stepper-accent: var(--wire-color-info);
}
.wire-stepper[data-size="sm"] {
font-size: 0.82rem;
}
.wire-stepper[data-size="lg"] {
font-size: 1rem;
}
.wire-stepper[data-orientation="vertical"] .wire-stepper__list {
flex-direction: column;
}
.wire-stepper__step {
display: flex;
flex-direction: column;
flex: 1 1 0;
min-width: 0;
gap: 0.35rem;
}
.wire-stepper__button {
appearance: none;
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.5rem;
border: 0;
border-radius: var(--wire-radius-sm);
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: default;
}
.wire-stepper[data-clickable="true"] .wire-stepper__button {
cursor: pointer;
}
.wire-stepper[data-clickable="true"] .wire-stepper__button:hover {
background: var(--wire-color-surface-soft);
}
.wire-stepper__button:focus-visible {
outline: 2px solid var(--stepper-accent);
outline-offset: 2px;
}
.wire-stepper__marker {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 2rem;
height: 2rem;
border: 1px solid var(--wire-color-border);
border-radius: 999px;
background: var(--wire-color-surface);
font-size: 0.85rem;
font-weight: 700;
}
.wire-stepper__step[data-status="complete"] .wire-stepper__marker {
border-color: var(--stepper-accent);
background: var(--stepper-accent);
color: var(--wire-color-primary-contrast);
}
.wire-stepper__step[data-status="current"] .wire-stepper__marker {
border-color: var(--stepper-accent);
color: var(--stepper-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--stepper-accent) 22%, transparent);
}
.wire-stepper__step[data-status="upcoming"] .wire-stepper__marker {
color: var(--wire-color-text-muted);
}
.wire-stepper__body {
display: flex;
flex-direction: column;
min-width: 0;
}
.wire-stepper__label {
font-size: 0.9rem;
font-weight: 600;
}
.wire-stepper__description {
color: var(--wire-color-text-muted);
font-size: 0.78rem;
}
.wire-stepper__step[data-status="upcoming"] .wire-stepper__label {
color: var(--wire-color-text-muted);
}
.wire-stepper__panels {
margin-top: 1rem;
}
.wire-stepper__panel {
padding: 1rem;
border: 1px solid var(--wire-color-border);
border-radius: var(--wire-radius-md);
background: var(--wire-color-surface);
animation: wire-stepper-in 200ms ease both;
}
.wire-stepper__panel-title {
margin: 0 0 0.35rem;
font-size: 0.95rem;
font-weight: 700;
}
.wire-stepper__panel-body {
margin: 0;
color: var(--wire-color-text-muted);
line-height: 1.6;
}
.wire-stepper__controls {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.85rem;
}
.wire-stepper__controls-spacer {
flex: 1 1 auto;
}
.wire-stepper__button-control {
appearance: none;
padding: 0.45rem 0.9rem;
border: 1px solid var(--wire-color-border);
border-radius: var(--wire-radius-sm);
background: var(--wire-color-surface);
color: var(--wire-color-text);
font: inherit;
font-size: 0.86rem;
font-weight: 600;
cursor: pointer;
}
.wire-stepper__button-control:hover:not(:disabled) {
background: var(--wire-color-surface-soft);
}
.wire-stepper__button-control:focus-visible {
outline: 2px solid var(--stepper-accent);
outline-offset: 2px;
}
.wire-stepper__button-control[data-primary="true"] {
border-color: var(--stepper-accent);
background: var(--stepper-accent);
color: var(--wire-color-primary-contrast);
}
/* A held step has to look held, or the button reads as broken. */
.wire-stepper__button-control:disabled {
opacity: 0.45;
cursor: not-allowed;
}
@keyframes wire-stepper-in {
from {
opacity: 0;
transform: translateX(0.75rem);
}
to {
opacity: 1;
transform: none;
}
}
/* A horizontal stepper cannot stay side by side on a phone. */
@media (max-width: 639px) {
.wire-stepper__list {
flex-direction: column;
}
}
} }
} }
+325 -53
View File
@@ -1,90 +1,362 @@
// Tabs -- a tablist over panels.
//
// <Tabs items='[{"label":"Overview","value":"overview"}]' active="overview" />
//
// With mode="url" the selection is mirrored into a query parameter using
// history.pushState, so the panel swaps without a page load and the back
// button works. The query parameter is used rather than the hash because it
// survives a reload and does not collide with in-page anchors or Scrollspy.
//
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
// and silently swallows the rule that follows it.
component Tabs { component Tabs {
outputs {
change(payload: { value: string; item: object; index: number })
select(payload: { value: string; item: object; index: number })
}
props { props {
size: string = "default"
color: string = "primary" color: string = "primary"
label: string = "Tabs" size: string = "default"
items: unknown[] = [] items: unknown[] = []
active: string = "" active: string = ""
orientation: string = "horizontal" orientation: string = "horizontal"
mode: string = "client"
param: string = "tab"
label: string = "Tabs"
class: string = "" class: string = ""
} }
state activeValue = active || (items[0] ? (items[0].value || items[0].id || "0") : "") state activeValue = ""
// Which way the panel should slide in. Set on every change so the animation
// follows the direction of travel rather than always coming from one side.
state slideFrom = "forward"
functions {
shared function itemList() {
return Array.isArray(items) ? items : []
}
shared function valueOf(item, index) {
return String(item.value || item.id || index)
}
/*
* In url mode the query parameter is the source of truth, not local state.
*
* The client router owns popstate and swaps the whole page on back and
* forward, which discards component state anyway. Reading the URL means
* the right tab simply falls out of whatever render happens next, with no
* listener to lose and nothing to keep in step.
*/
shared function currentValue() {
if (mode === "url" && typeof window !== "undefined" && window.location) {
var fromUrl = new URLSearchParams(window.location.search).get(param || "tab")
return fromUrl ? fromUrl : defaultValue()
}
if (activeValue) {
return activeValue
}
return defaultValue()
}
// The selection this instance started with. The runtime falls back to it
// when the back button lands on a URL that has no tab parameter at all.
shared function defaultValue() {
if (active) {
return active
}
var list = itemList()
return list.length ? valueOf(list[0], 0) : ""
}
shared function isSelected(item, index) {
return valueOf(item, index) === currentValue()
}
shared function rovingAxis() {
return orientation === "vertical" ? "vertical" : "horizontal"
}
client function selectTab(item, index, sourceEvent) {
if (item.disabled) {
return
}
var value = valueOf(item, index)
var list = itemList()
var previous = -1
for (var scan = 0; scan < list.length; scan += 1) {
if (valueOf(list[scan], scan) === currentValue()) {
previous = scan
}
}
slideFrom = previous > index ? "back" : "forward"
activeValue = value
if (mode === "url" && window.history && window.history.pushState) {
var url = new URL(window.location.href)
url.searchParams.set(param || "tab", value)
window.history.pushState({}, "", url.toString())
}
output.change({ value: value, item: item, index: index })
output.select({ value: value, item: item, index: index })
}
}
view { view {
<section <section
{...attrs}
data-ui-component="Tabs" data-ui-component="Tabs"
class='w-full {class}' class='wire-tabs {class}'
class:flex='orientation === "vertical"' data-orientation='{orientation}'
class:items-start='orientation === "vertical"' data-color='{color}'
class:gap-6='orientation === "vertical"' data-size='{size}'
data-slide='{slideFrom}'
data-mode='{mode}'
data-param='{param}'
> >
<div <div
class="wire-tabs__list"
role="tablist" role="tablist"
aria-label='{label}' aria-label='{label}'
aria-orientation='{orientation}' aria-orientation='{orientation}'
class="flex min-w-0 gap-1 overflow-x-auto rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-soft)] p-1" data-wrn-roving='{rovingAxis()}'
class:flex-col='orientation === "vertical"'
class:w-56='orientation === "vertical"'
class:shrink-0='orientation === "vertical"'
> >
{#each items as item, index} {#each itemList() as item, index}
<button <button
type="button" type="button"
class="wire-tabs__tab"
role="tab" role="tab"
id='tab-{item.value || item.id || index}' data-value='{valueOf(item, index)}'
aria-selected='{activeValue === (item.value || item.id || String(index))}' data-wrn-roving-item="true"
aria-controls='panel-{item.value || item.id || index}' id='wire-tab-{valueOf(item, index)}'
tabindex='{activeValue === (item.value || item.id || String(index)) ? "0" : "-1"}' aria-controls='wire-panel-{valueOf(item, index)}'
disabled='{item.disabled || false}' aria-selected='{isSelected(item, index) ? "true" : "false"}'
class="inline-flex min-w-max flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold outline-none transition disabled:cursor-not-allowed disabled:opacity-50" aria-disabled='{item.disabled ? "true" : "false"}'
class:bg-[var(--wire-color-surface-raised)]='activeValue === (item.value || item.id || String(index))' @click='selectTab(item, index, event)'
class:text-[var(--wire-color-primary)]='activeValue === (item.value || item.id || String(index))'
class:shadow-sm='activeValue === (item.value || item.id || String(index))'
class:text-[var(--wire-color-text-muted)]='activeValue !== (item.value || item.id || String(index))'
class:hover:text-[var(--wire-color-text)]='activeValue !== (item.value || item.id || String(index))'
class:justify-start='orientation === "vertical"'
class:px-3='size === "sm"'
class:py-2='size === "sm"'
class:px-5='size === "lg"'
class:py-3='size === "lg"'
@click='activeValue = item.value || item.id || String(index); event.currentTarget.dispatchEvent(new CustomEvent("change", { bubbles: true, detail: { item: item, index: index, value: activeValue } })); event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: { item: item, index: index, value: activeValue } }))'
> >
{#if item.icon} <span class='wire-tabs__icon {item.icon}' data-show="item.icon" aria-hidden="true"></span>
<span class='{item.icon + " size-4"}' aria-hidden="true"></span> <span class="wire-tabs__label">{item.label || item.title}</span>
{/if} <span class="wire-tabs__badge" data-show="item.badge">{item.badge}</span>
<span>{item.label || item.title}</span>
{#if item.badge}
<span class="rounded-full bg-[var(--wire-color-primary-soft)] px-2 py-0.5 text-xs text-[var(--wire-color-primary)]">{item.badge}</span>
{/if}
</button> </button>
{/each} {/each}
</div> </div>
<div class="min-w-0 flex-1 pt-4" class:pt-0='orientation === "vertical"'> <div class="wire-tabs__panels">
{#each items as item, index} {#each itemList() as item, index}
<div <div
class="wire-tabs__panel"
role="tabpanel" role="tabpanel"
id='panel-{item.value || item.id || index}' id='wire-panel-{valueOf(item, index)}'
aria-labelledby='tab-{item.value || item.id || index}' aria-labelledby='wire-tab-{valueOf(item, index)}'
tabindex="0" tabindex="0"
data-show='activeValue === (item.value || item.id || String(index))' data-show='isSelected(item, index)'
class="rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-focus)]"
> >
{#if item.title && item.title !== item.label} <h3 class="wire-tabs__title" data-show="item.title && item.title !== item.label">
<h3 class="text-lg font-bold text-[var(--wire-color-text)]">{item.title}</h3> {item.title}
{/if} </h3>
{#if item.description} <p class="wire-tabs__description" data-show="item.description">{item.description}</p>
<p class="mt-2 leading-7 text-[var(--wire-color-text-muted)]">{item.description}</p> <div class="wire-tabs__content" data-show="item.content">{item.content}</div>
{/if} <slot name="panel-{valueOf(item, index)}"></slot>
{#if item.content}
<div class="mt-4 text-[var(--wire-color-text)]">{item.content}</div>
{/if}
</div> </div>
{/each} {/each}
<slot />
<slot></slot>
</div> </div>
</section> </section>
} }
style {
.wire-tabs {
--tabs-accent: var(--wire-color-primary);
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 100%;
}
.wire-tabs[data-color="secondary"] {
--tabs-accent: var(--wire-color-secondary);
}
.wire-tabs[data-color="success"] {
--tabs-accent: var(--wire-color-success);
}
.wire-tabs[data-color="danger"] {
--tabs-accent: var(--wire-color-danger);
}
.wire-tabs[data-color="info"] {
--tabs-accent: var(--wire-color-info);
}
.wire-tabs[data-size="sm"] {
font-size: 0.82rem;
}
.wire-tabs[data-size="lg"] {
font-size: 1rem;
}
.wire-tabs[data-orientation="vertical"] {
flex-direction: row;
align-items: flex-start;
}
.wire-tabs__list {
display: flex;
gap: 0.25rem;
min-width: 0;
padding: 0.25rem;
border: 1px solid var(--wire-color-border);
border-radius: var(--wire-radius-md);
background: var(--wire-color-surface-soft);
/* A long tablist scrolls rather than wrapping into an unusable stack. */
overflow-x: auto;
}
.wire-tabs[data-orientation="vertical"] .wire-tabs__list {
flex-direction: column;
flex: 0 0 auto;
width: 14rem;
overflow-x: visible;
}
.wire-tabs__tab {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
flex: 1 0 auto;
padding: 0.55rem 0.9rem;
border: 0;
border-radius: var(--wire-radius-sm);
background: transparent;
color: var(--wire-color-text-muted);
font: inherit;
font-size: 0.88rem;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
transition:
background 160ms ease,
color 160ms ease;
}
.wire-tabs[data-orientation="vertical"] .wire-tabs__tab {
justify-content: flex-start;
}
.wire-tabs__tab:hover {
color: var(--wire-color-text);
}
.wire-tabs__tab:focus-visible {
outline: 2px solid var(--tabs-accent);
outline-offset: 2px;
}
.wire-tabs__tab[aria-selected="true"] {
background: var(--wire-color-surface);
color: var(--tabs-accent);
box-shadow: var(--wire-shadow-1);
}
.wire-tabs__tab[aria-disabled="true"] {
opacity: 0.5;
pointer-events: none;
}
.wire-tabs__badge {
padding: 0.05rem 0.4rem;
border-radius: 999px;
background: color-mix(in srgb, var(--tabs-accent) 16%, transparent);
color: var(--tabs-accent);
font-size: 0.72rem;
}
.wire-tabs__panels {
min-width: 0;
flex: 1 1 auto;
}
.wire-tabs__panel {
outline: none;
animation: wire-tabs-slide-forward 220ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.wire-tabs[data-slide="back"] .wire-tabs__panel {
animation-name: wire-tabs-slide-back;
}
/* The panels clip their own slide so it never widens the page. */
.wire-tabs__panels {
overflow-x: clip;
}
.wire-tabs__panel:focus-visible {
outline: 2px solid var(--tabs-accent);
outline-offset: 4px;
border-radius: var(--wire-radius-sm);
}
.wire-tabs__title {
margin: 0 0 0.35rem;
font-size: 1rem;
font-weight: 700;
}
.wire-tabs__description {
margin: 0;
color: var(--wire-color-text-muted);
line-height: 1.6;
}
.wire-tabs__content {
margin-top: 0.6rem;
}
@keyframes wire-tabs-slide-forward {
from {
opacity: 0;
transform: translateX(1.25rem);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes wire-tabs-slide-back {
from {
opacity: 0;
transform: translateX(-1.25rem);
}
to {
opacity: 1;
transform: none;
}
}
/* Respect a reduced-motion preference: swap instantly instead. */
@media (prefers-reduced-motion: reduce) {
.wire-tabs__panel {
animation: none;
}
}
@media (max-width: 639px) {
.wire-tabs[data-orientation="vertical"] {
flex-direction: column;
}
.wire-tabs[data-orientation="vertical"] .wire-tabs__list {
width: 100%;
flex-direction: row;
overflow-x: auto;
}
}
}
} }
+5 -1
View File
@@ -1,4 +1,8 @@
component Timeline { component Timeline {
outputs {
select(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
}
props { props {
size: string = "default" size: string = "default"
color: string = "primary" color: string = "primary"
@@ -30,7 +34,7 @@ component Timeline {
{#each items as item, index} {#each items as item, index}
<li <li
class="relative pb-8 pl-8 last:pb-0" class="relative pb-8 pl-8 last:pb-0"
@click='event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: { item: item, index: index } }))' @click='output.select({ item: item, index: index })'
> >
<span <span
class="absolute -left-[1.05rem] top-0 flex size-8 items-center justify-center rounded-full border-4 border-[var(--wire-color-background)] bg-[var(--wire-color-primary)] text-[var(--wire-color-on-primary)] shadow-sm" class="absolute -left-[1.05rem] top-0 flex size-8 items-center justify-center rounded-full border-4 border-[var(--wire-color-background)] bg-[var(--wire-color-primary)] text-[var(--wire-color-on-primary)] shadow-sm"
+10 -27
View File
@@ -57,34 +57,17 @@ component ToggleCount {
: emptyValue : emptyValue
} }
client function dispatchToggleEvent(sourceEvent, previousValue, root, customEvent) { client function dispatchToggleEvent(sourceEvent, previousValue, payload) {
root = sourceEvent.currentTarget.closest("[data-wrn-toggle-count]") payload = {
component: "ToggleCount",
if (!root) { name: name,
return value: selectedValue,
previousValue: previousValue,
firstValue: firstValue,
secondValue: secondValue
} }
output.change(payload)
customEvent = document.createEvent("CustomEvent") output.toggle(payload)
customEvent.initCustomEvent("change", true, false, {
component: "ToggleCount",
name: name,
value: selectedValue,
previousValue: previousValue,
firstValue: firstValue,
secondValue: secondValue
})
root.dispatchEvent(customEvent)
customEvent = document.createEvent("CustomEvent")
customEvent.initCustomEvent("toggle", true, false, {
component: "ToggleCount",
name: name,
value: selectedValue,
previousValue: previousValue,
firstValue: firstValue,
secondValue: secondValue
})
root.dispatchEvent(customEvent)
} }
client function selectValue(sourceEvent, nextValue, previousValue) { client function selectValue(sourceEvent, nextValue, previousValue) {
+62 -14
View File
@@ -11,26 +11,74 @@ component Typography {
view { view {
<div <div
data-ui-component="Typography" data-ui-component="Typography"
class='wire-typography text-[var(--wire-color-text)] {class}' class='wire-typography {class}'
class:max-w-3xl='maxWidth === "md"' data-size='{size}'
class:max-w-5xl='maxWidth === "lg"' data-columns='{columns}'
class:max-w-7xl='maxWidth === "xl"' data-gap='{gap}'
class:max-w-none='maxWidth === "full"' data-max-width='{maxWidth}'
class:text-sm='size === "sm"'
class:text-base='size === "default" || size === "md"'
class:text-lg='size === "lg"'
class:columns-1='columns === 1'
class:md:columns-2='columns === 2'
class:lg:columns-3='columns === 3'
class:gap-4='gap === "sm"'
class:gap-8='gap === "md"'
class:gap-12='gap === "lg"'
> >
<slot></slot> <slot></slot>
</div> </div>
} }
style { style {
.wire-typography {
color: var(--wire-color-text);
font-size: 1rem;
}
.wire-typography[data-size="sm"] {
font-size: 0.875rem;
}
.wire-typography[data-size="lg"] {
font-size: 1.125rem;
}
.wire-typography[data-max-width="md"] {
max-width: 48rem;
}
.wire-typography[data-max-width="lg"] {
max-width: 64rem;
}
.wire-typography[data-max-width="xl"] {
max-width: 80rem;
}
.wire-typography[data-max-width="full"] {
max-width: none;
}
/*
* Multi-column prose only below a comfortable reading width; a narrow
* screen split into columns is unreadable.
*/
.wire-typography[data-gap="sm"] {
column-gap: 1rem;
}
.wire-typography[data-gap="md"] {
column-gap: 2rem;
}
.wire-typography[data-gap="lg"] {
column-gap: 3rem;
}
@media (min-width: 768px) {
.wire-typography[data-columns="2"] {
column-count: 2;
}
}
@media (min-width: 1024px) {
.wire-typography[data-columns="3"] {
column-count: 3;
}
}
.wire-typography :where(h1, h2, h3, h4) { .wire-typography :where(h1, h2, h3, h4) {
color: var(--wire-color-text); color: var(--wire-color-text);
font-weight: 700; font-weight: 700;
+12 -11
View File
@@ -35,22 +35,23 @@ component Alert {
state visible: boolean = true state visible: boolean = true
functions { functions {
client function dispatchAlertEvent(sourceEvent, eventName, action, root, customEvent) { // The output name has to be written out rather than computed: outputs are
root = sourceEvent.currentTarget.closest("[data-wrn-alert]") // resolved as named properties, so a dynamic key would not reach a parent
// binding. Only two names exist here, so a branch is honest and cheap.
if (!root) { client function dispatchAlertEvent(sourceEvent, eventName, action, payload) {
return payload = {
}
customEvent = document.createEvent("CustomEvent")
customEvent.initCustomEvent(eventName, true, false, {
component: "Alert", component: "Alert",
title: title, title: title,
color: color, color: color,
variant: variant, variant: variant,
action: action action: action
}) }
root.dispatchEvent(customEvent)
if (eventName === "dismiss") {
output.dismiss(payload)
} else {
output.action(payload)
}
} }
client function dismissAlert(sourceEvent) { client function dismissAlert(sourceEvent) {
+6 -8
View File
@@ -35,15 +35,13 @@ component Badge {
state visible: boolean = true state visible: boolean = true
functions { functions {
client function dismissBadge(sourceEvent, root, customEvent) { // output.dismiss reaches a parent @dismiss binding; a raw dispatchEvent on
// the root does not. The runtime registers parent handlers in a registry
// that only the output proxy consults, so the CustomEvent this used to
// build bubbled past every binding and was never seen by anyone.
client function dismissBadge() {
visible = false visible = false
root = sourceEvent.currentTarget.closest("[data-wrn-badge]") output.dismiss({ component: "Badge", label: label })
customEvent = document.createEvent("CustomEvent")
customEvent.initCustomEvent("dismiss", true, false, {
component: "Badge",
label: label
})
root.dispatchEvent(customEvent)
} }
} }
+21 -61
View File
@@ -45,67 +45,27 @@ component Card {
state dismissed: boolean = false state dismissed: boolean = false
functions { functions {
client function dispatchCardNavigation(sourceEvent, item, index, root, customEvent) { // Outputs must go through output.*; a CustomEvent dispatched on the root
root = sourceEvent.currentTarget.closest("[data-wrn-card]") // bubbles past every parent @binding without being seen, because the
// runtime keeps parent handlers in a registry only the output proxy reads.
if (!root) { client function dispatchCardNavigation(sourceEvent, item, index) {
return output.navigate({ component: "Card", item: item, index: index })
}
customEvent = document.createEvent("CustomEvent")
customEvent.initCustomEvent("navigate", true, false, {
component: "Card",
item: item,
index: index
})
root.dispatchEvent(customEvent)
} }
client function dispatchCardNavigationValue(sourceEvent, root, customEvent) { client function dispatchCardNavigationValue(sourceEvent) {
root = sourceEvent.currentTarget.closest("[data-wrn-card]") output.navigate({
if (!root) {
return
}
customEvent = document.createEvent("CustomEvent")
customEvent.initCustomEvent("navigate", true, false, {
component: "Card", component: "Card",
value: sourceEvent.currentTarget.value value: sourceEvent.currentTarget.value
}) })
root.dispatchEvent(customEvent)
} }
client function dispatchCardHeaderAction(sourceEvent, action, index, root, customEvent) { client function dispatchCardHeaderAction(sourceEvent, action, index) {
root = sourceEvent.currentTarget.closest("[data-wrn-card]") output.action({ component: "Card", action: action, index: index })
if (!root) {
return
}
customEvent = document.createEvent("CustomEvent")
customEvent.initCustomEvent("action", true, false, {
component: "Card",
action: action,
index: index
})
root.dispatchEvent(customEvent)
} }
client function dismissCard(sourceEvent, root, customEvent) { client function dismissCard() {
dismissed = true dismissed = true
root = sourceEvent.currentTarget.closest("[data-wrn-card]") output.dismiss({ component: "Card", title: title })
if (!root) {
return
}
customEvent = document.createEvent("CustomEvent")
customEvent.initCustomEvent("dismiss", true, false, {
component: "Card",
title: title
})
root.dispatchEvent(customEvent)
} }
} }
@@ -137,8 +97,8 @@ component Card {
alt='{item.imageAlt || item.alt || ""}' alt='{item.imageAlt || item.alt || ""}'
loading='{item.loading || "lazy"}' loading='{item.loading || "lazy"}'
decoding="async" decoding="async"
@load='event.currentTarget.dispatchEvent(new CustomEvent("load", { bubbles: true, detail: { item: item, index: itemIndex } }))' @load='output.load({ item: item, index: itemIndex })'
@error='event.currentTarget.dispatchEvent(new CustomEvent("error", { bubbles: true, detail: { item: item, index: itemIndex } }))' @error='output.error({ item: item, index: itemIndex })'
/> />
</div> </div>
{/if} {/if}
@@ -157,7 +117,7 @@ component Card {
<a <a
href='{item.actionHref || item.href || "#"}' href='{item.actionHref || item.href || "#"}'
class="wire-next__card-action" class="wire-next__card-action"
@click='event.currentTarget.dispatchEvent(new CustomEvent("action", { bubbles: true, detail: { item: item, index: itemIndex } }))' @click='output.action({ item: item, index: itemIndex })'
> >
<span>{item.actionLabel || "Learn more"}</span> <span>{item.actionLabel || "Learn more"}</span>
<span class="icon-[lucide--arrow-right]" aria-hidden="true"></span> <span class="icon-[lucide--arrow-right]" aria-hidden="true"></span>
@@ -180,8 +140,8 @@ component Card {
alt='{imageAlt}' alt='{imageAlt}'
loading="lazy" loading="lazy"
decoding="async" decoding="async"
@load='event.currentTarget.dispatchEvent(new CustomEvent("load", { bubbles: true, detail: { src: imageSrc } }))' @load='output.load({ src: imageSrc })'
@error='event.currentTarget.dispatchEvent(new CustomEvent("error", { bubbles: true, detail: { src: imageSrc } }))' @error='output.error({ src: imageSrc })'
/> />
<div class="wire-next__card-overlay-shade" aria-hidden="true"></div> <div class="wire-next__card-overlay-shade" aria-hidden="true"></div>
</div> </div>
@@ -194,8 +154,8 @@ component Card {
alt='{imageAlt}' alt='{imageAlt}'
loading="lazy" loading="lazy"
decoding="async" decoding="async"
@load='event.currentTarget.dispatchEvent(new CustomEvent("load", { bubbles: true, detail: { src: imageSrc } }))' @load='output.load({ src: imageSrc })'
@error='event.currentTarget.dispatchEvent(new CustomEvent("error", { bubbles: true, detail: { src: imageSrc } }))' @error='output.error({ src: imageSrc })'
/> />
</div> </div>
{/if} {/if}
@@ -323,7 +283,7 @@ component Card {
<a <a
href='{actionHref || "#"}' href='{actionHref || "#"}'
class="wire-next__card-action" class="wire-next__card-action"
@click='event.currentTarget.dispatchEvent(new CustomEvent("action", { bubbles: true, detail: { href: actionHref, label: actionLabel } }))' @click='output.action({ href: actionHref, label: actionLabel })'
> >
<span>{actionLabel}</span> <span>{actionLabel}</span>
<span class="icon-[lucide--arrow-right]" aria-hidden="true"></span> <span class="icon-[lucide--arrow-right]" aria-hidden="true"></span>
@@ -340,8 +300,8 @@ component Card {
alt='{imageAlt}' alt='{imageAlt}'
loading="lazy" loading="lazy"
decoding="async" decoding="async"
@load='event.currentTarget.dispatchEvent(new CustomEvent("load", { bubbles: true, detail: { src: imageSrc } }))' @load='output.load({ src: imageSrc })'
@error='event.currentTarget.dispatchEvent(new CustomEvent("error", { bubbles: true, detail: { src: imageSrc } }))' @error='output.error({ src: imageSrc })'
/> />
</div> </div>
{/if} {/if}
+140 -24
View File
@@ -1,46 +1,162 @@
// Container -- a centred, width-limited page wrapper.
//
// <Container maxWidth="lg">...</Container>
//
// It also accepts columns and gap, because it always has and applications
// depend on it. Reach for Grid when a grid is the point; reach for Container
// when the point is the reading width.
//
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
// and silently swallows the rule that follows it.
component Container { component Container {
props { props {
size: string = "default" size: string = "default"
color: string = "primary" color: string = "primary"
// Stays 2 to match what shipped. A published default cannot be changed
// without silently reflowing every Container already in use.
columns: number = 2 columns: number = 2
gap: string = "md" gap: string = "md"
maxWidth: string = "xl" maxWidth: string = "xl"
centered: boolean = true
class: string = "" class: string = ""
} }
functions {
shared function columnCount() {
var value = Number(columns)
if (!value || value < 1) {
return 1
}
return Math.min(6, value)
}
}
view { view {
<div <div
{...attrs}
data-ui-component="Container" data-ui-component="Container"
class='wire-container {class}'
data-size='{size}' data-size='{size}'
data-color='{color}' data-color='{color}'
data-columns='{columns}'
data-gap='{gap}' data-gap='{gap}'
data-max-width='{maxWidth}' data-max-width='{maxWidth}'
class='mx-auto w-full {class}' data-centered='{centered}'
class:max-w-3xl='maxWidth === "md"' data-columns='{columnCount()}'
class:max-w-5xl='maxWidth === "lg"'
class:max-w-7xl='maxWidth === "xl" || maxWidth === "wide"'
class:max-w-screen-2xl='maxWidth === "2xl"'
class:max-w-none='maxWidth === "full"'
class:px-4='size === "compact" || size === "default"'
class:px-5='size === "comfortable"'
class:px-6='size === "spacious"'
class:sm:px-6='size !== "compact"'
class:lg:px-8='size === "default" || size === "comfortable" || size === "spacious"'
class:grid='columns > 1'
class:grid-cols-1='columns > 1'
class:sm:grid-cols-2='columns === 2 || columns === 3 || columns === 4 || columns === 5 || columns === 6'
class:lg:grid-cols-3='columns === 3'
class:lg:grid-cols-4='columns === 4'
class:lg:grid-cols-5='columns === 5'
class:lg:grid-cols-6='columns === 6'
class:gap-2='gap === "xs"'
class:gap-3='gap === "sm"'
class:gap-5='gap === "md"'
class:gap-8='gap === "lg"'
class:gap-10='gap === "xl"'
> >
<slot></slot> <slot></slot>
</div> </div>
} }
style {
.wire-container {
--container-gap: 1.25rem;
width: 100%;
min-width: 0;
padding-inline: 1rem;
}
.wire-container[data-centered="true"] {
margin-inline: auto;
}
/* Reading widths, not breakpoints: the cap is about line length. */
.wire-container[data-max-width="md"] {
max-width: 48rem;
}
.wire-container[data-max-width="lg"] {
max-width: 64rem;
}
.wire-container[data-max-width="xl"],
.wire-container[data-max-width="wide"] {
max-width: 80rem;
}
.wire-container[data-max-width="2xl"] {
max-width: 96rem;
}
.wire-container[data-max-width="full"] {
max-width: none;
}
.wire-container[data-size="compact"] {
padding-inline: 1rem;
}
.wire-container[data-size="comfortable"] {
padding-inline: 1.25rem;
}
.wire-container[data-size="spacious"] {
padding-inline: 1.5rem;
}
.wire-container[data-gap="xs"] {
--container-gap: 0.5rem;
}
.wire-container[data-gap="sm"] {
--container-gap: 0.75rem;
}
.wire-container[data-gap="lg"] {
--container-gap: 2rem;
}
.wire-container[data-gap="xl"] {
--container-gap: 2.5rem;
}
/*
* Columns are opt-in. One column stays plain flow so a container does not
* quietly turn every page into a grid.
*/
.wire-container:not([data-columns="1"]) {
display: grid;
gap: var(--container-gap);
grid-template-columns: 1fr;
}
@media (min-width: 640px) {
.wire-container[data-columns="2"],
.wire-container[data-columns="3"],
.wire-container[data-columns="4"],
.wire-container[data-columns="5"],
.wire-container[data-columns="6"] {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.wire-container[data-size="default"],
.wire-container[data-size="comfortable"],
.wire-container[data-size="spacious"] {
padding-inline: 1.5rem;
}
}
@media (min-width: 1024px) {
.wire-container[data-columns="3"] {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.wire-container[data-columns="4"] {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.wire-container[data-columns="5"] {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.wire-container[data-columns="6"] {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.wire-container[data-size="default"],
.wire-container[data-size="comfortable"],
.wire-container[data-size="spacious"] {
padding-inline: 2rem;
}
}
}
} }
+121 -49
View File
@@ -1,69 +1,141 @@
// Divider -- a rule between sections, optionally labelled.
//
// <Divider />
// <Divider label="or" />
// <Divider orientation="vertical" />
//
// The label sits between two rules rather than on top of one, so the text
// never overlaps the line at any width.
//
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
// and silently swallows the rule that follows it.
component Divider { component Divider {
props { props {
size: string = "default" size: string = "default"
color: string = "primary" color: string = "primary"
label: string = "" label: string = ""
orientation: string = "horizontal" orientation: string = "horizontal"
variant: string = "solid"
class: string = "" class: string = ""
} }
functions {
shared function isVertical() {
return orientation === "vertical"
}
// The rule only takes the component colour when there is a label. A plain
// divider is chrome and should stay the border token.
shared function tone() {
return label ? color : "border"
}
}
view { view {
<div <div
{...attrs} {...attrs}
data-ui-component="Divider" data-ui-component="Divider"
class='wire-divider {class}'
data-size='{size}' data-size='{size}'
data-color='{color}' data-color='{color}'
data-tone='{tone()}'
data-variant='{variant}'
data-orientation='{orientation}' data-orientation='{orientation}'
class='w-full {class}' data-labelled='{label ? "true" : "false"}'
class:h-full='orientation === "vertical"' role="separator"
class:w-auto='orientation === "vertical"' aria-orientation='{isVertical() ? "vertical" : "horizontal"}'
> >
{#if orientation === "vertical"} <span class="wire-divider__rule" aria-hidden="true"></span>
<div <span class="wire-divider__label" data-show="label">{label}</span>
role="separator" <span class="wire-divider__rule" data-show="label" aria-hidden="true"></span>
aria-orientation="vertical"
class="mx-3 h-full min-h-6 border-l border-[var(--wire-color-border)]"
class:border-l-2='size === "lg"'
class:border-[var(--wire-color-primary)]='color === "primary"'
class:border-[var(--wire-color-secondary)]='color === "secondary"'
class:border-[var(--wire-color-success)]='color === "success"'
class:border-[var(--wire-color-warning)]='color === "warning"'
class:border-[var(--wire-color-danger)]='color === "danger"'
></div>
{:else}
<div
role="separator"
aria-orientation="horizontal"
class="flex w-full items-center gap-4"
>
<span
class="h-px flex-1 bg-[var(--wire-color-border)]"
class:h-0.5='size === "lg"'
class:bg-[var(--wire-color-primary)]='color === "primary" && label !== ""'
class:bg-[var(--wire-color-secondary)]='color === "secondary" && label !== ""'
class:bg-[var(--wire-color-success)]='color === "success" && label !== ""'
class:bg-[var(--wire-color-warning)]='color === "warning" && label !== ""'
class:bg-[var(--wire-color-danger)]='color === "danger" && label !== ""'
aria-hidden="true"
></span>
{#if label}
<span class="shrink-0 text-xs font-bold uppercase tracking-[0.16em] text-[var(--wire-color-text-muted)]">
{label}
</span>
<span
class="h-px flex-1 bg-[var(--wire-color-border)]"
class:h-0.5='size === "lg"'
class:bg-[var(--wire-color-primary)]='color === "primary"'
class:bg-[var(--wire-color-secondary)]='color === "secondary"'
class:bg-[var(--wire-color-success)]='color === "success"'
class:bg-[var(--wire-color-warning)]='color === "warning"'
class:bg-[var(--wire-color-danger)]='color === "danger"'
aria-hidden="true"
></span>
{/if}
</div>
{/if}
</div> </div>
} }
style {
.wire-divider {
--divider-tone: var(--wire-color-border);
--divider-thickness: 1px;
display: flex;
align-items: center;
gap: 1rem;
width: 100%;
min-width: 0;
}
.wire-divider[data-tone="primary"] {
--divider-tone: var(--wire-color-primary);
}
.wire-divider[data-tone="secondary"] {
--divider-tone: var(--wire-color-secondary);
}
.wire-divider[data-tone="success"] {
--divider-tone: var(--wire-color-success);
}
.wire-divider[data-tone="warning"] {
--divider-tone: var(--wire-color-warning);
}
.wire-divider[data-tone="danger"] {
--divider-tone: var(--wire-color-danger);
}
.wire-divider[data-size="lg"] {
--divider-thickness: 2px;
}
.wire-divider__rule {
flex: 1 1 auto;
height: var(--divider-thickness);
background: var(--divider-tone);
}
.wire-divider[data-variant="dashed"] .wire-divider__rule {
height: 0;
background: transparent;
border-top: var(--divider-thickness) dashed var(--divider-tone);
}
.wire-divider[data-variant="dotted"] .wire-divider__rule {
height: 0;
background: transparent;
border-top: var(--divider-thickness) dotted var(--divider-tone);
}
.wire-divider__label {
flex: 0 0 auto;
color: var(--wire-color-text-muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.wire-divider[data-orientation="vertical"] {
flex-direction: column;
width: auto;
height: 100%;
min-height: 1.5rem;
margin-inline: 0.75rem;
}
.wire-divider[data-orientation="vertical"] .wire-divider__rule {
width: var(--divider-thickness);
height: auto;
}
.wire-divider[data-orientation="vertical"][data-variant="dashed"] .wire-divider__rule {
width: 0;
border-top: 0;
border-left: var(--divider-thickness) dashed var(--divider-tone);
}
.wire-divider[data-orientation="vertical"][data-variant="dotted"] .wire-divider__rule {
width: 0;
border-top: 0;
border-left: var(--divider-thickness) dotted var(--divider-tone);
}
}
} }
+117 -19
View File
@@ -1,3 +1,13 @@
// Grid -- an equal-track grid that collapses on small screens.
//
// <Grid columns={3} gap="lg">...</Grid>
//
// Pass minItemWidth to let the browser decide the count instead: the track
// list becomes auto-fit, which reflows continuously rather than at fixed
// breakpoints and suits card decks whose item count is not known.
//
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
// and silently swallows the rule that follows it.
component Grid { component Grid {
props { props {
size: string = "default" size: string = "default"
@@ -5,36 +15,124 @@ component Grid {
columns: number = 2 columns: number = 2
gap: string = "md" gap: string = "md"
maxWidth: string = "xl" maxWidth: string = "xl"
// When set, the count is derived from the available width instead of the
// columns prop. Accepts any CSS length.
minItemWidth: string = ""
class: string = "" class: string = ""
} }
functions {
shared function columnCount() {
var value = Number(columns)
if (!value || value < 1) {
return 1
}
return Math.min(6, value)
}
shared function autoTrack() {
return minItemWidth ? "repeat(auto-fit, minmax(" + minItemWidth + ", 1fr))" : ""
}
}
view { view {
<div <div
{...attrs}
data-ui-component="Grid" data-ui-component="Grid"
class='wire-grid {class}'
data-size='{size}' data-size='{size}'
data-color='{color}' data-color='{color}'
data-columns='{columns}'
data-gap='{gap}' data-gap='{gap}'
class='grid w-full grid-cols-1 {class}' data-max-width='{maxWidth}'
class:max-w-3xl='maxWidth === "md"' data-columns='{columnCount()}'
class:max-w-5xl='maxWidth === "lg"' data-auto='{minItemWidth ? "true" : "false"}'
class:max-w-7xl='maxWidth === "xl"' style='--grid-auto-track:{autoTrack()};'
class:max-w-screen-2xl='maxWidth === "2xl"'
class:max-w-none='maxWidth === "full"'
class:sm:grid-cols-2='columns >= 2'
class:lg:grid-cols-3='columns === 3'
class:lg:grid-cols-4='columns === 4'
class:lg:grid-cols-5='columns === 5'
class:lg:grid-cols-6='columns === 6'
class:gap-2='gap === "xs"'
class:gap-3='gap === "sm"'
class:gap-5='gap === "md"'
class:gap-8='gap === "lg"'
class:gap-10='gap === "xl"'
class:items-start='size === "compact"'
class:items-stretch='size !== "compact"'
> >
<slot></slot> <slot></slot>
</div> </div>
} }
style {
.wire-grid {
--grid-gap: 1.25rem;
display: grid;
width: 100%;
min-width: 0;
gap: var(--grid-gap);
grid-template-columns: 1fr;
align-items: stretch;
}
.wire-grid[data-size="compact"] {
align-items: start;
}
.wire-grid[data-max-width="md"] {
max-width: 48rem;
}
.wire-grid[data-max-width="lg"] {
max-width: 64rem;
}
.wire-grid[data-max-width="xl"] {
max-width: 80rem;
}
.wire-grid[data-max-width="2xl"] {
max-width: 96rem;
}
.wire-grid[data-max-width="full"] {
max-width: none;
}
.wire-grid[data-gap="xs"] {
--grid-gap: 0.5rem;
}
.wire-grid[data-gap="sm"] {
--grid-gap: 0.75rem;
}
.wire-grid[data-gap="lg"] {
--grid-gap: 2rem;
}
.wire-grid[data-gap="xl"] {
--grid-gap: 2.5rem;
}
/*
* auto-fit needs no breakpoints, so it wins over the fixed counts below
* and applies at every width.
*/
.wire-grid[data-auto="true"] {
grid-template-columns: var(--grid-auto-track);
}
@media (min-width: 640px) {
.wire-grid[data-auto="false"]:not([data-columns="1"]) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1024px) {
.wire-grid[data-auto="false"][data-columns="3"] {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.wire-grid[data-auto="false"][data-columns="4"] {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.wire-grid[data-auto="false"][data-columns="5"] {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.wire-grid[data-auto="false"][data-columns="6"] {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
}
}
} }
-20
View File
@@ -1,20 +0,0 @@
component Table {
outputs {
sort(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
select(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
rowClick(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
}
props {
size: string = "default"
color: string = "primary"
caption: string = "Table"
columns: unknown[] = []
rows: unknown[] = []
striped: boolean = true
class: string = ""
}
view {
<div class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--table {class}"><table><caption>{caption}</caption><thead><tr>{#each columns as column}<th>{column.label}</th>{/each}</tr></thead><tbody>{#each rows as row}<tr>{#each columns as column}<td>{row[column.key]}</td>{/each}</tr>{/each}</tbody></table><slot /></div>
}
}
+2 -1
View File
@@ -97,11 +97,12 @@ id: string = ""
<span <span
id='{id}' id='{id}'
class="wire-tooltip__content" class="wire-tooltip__content"
data-wrn-anchored="true"
data-show='{open || visible}' data-show='{open || visible}'
role="tooltip" role="tooltip"
> >
{#if showArrow} {#if showArrow}
<span class="wire-tooltip__arrow" aria-hidden="true"></span> <span class="wire-tooltip__arrow" data-wrn-anchor-arrow="true" aria-hidden="true"></span>
{/if} {/if}
{#if title} {#if title}
+23 -2
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/ui", "name": "@wrnexus/ui",
"version": "0.7.0", "version": "0.8.6",
"type": "module", "type": "module",
"description": "@wrnexus/ui — part of the WrNexus framework.", "description": "@wrnexus/ui — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/ui"
},
"homepage": "https://wrnexusjs.dev/packages/ui",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"ui"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -19,6 +35,10 @@
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
}, },
"./registry": {
"types": "./dist/registry.d.ts",
"import": "./dist/registry.js"
},
"./components/*": "./components/*", "./components/*": "./components/*",
"./component-catalog.json": "./component-catalog.json", "./component-catalog.json": "./component-catalog.json",
"./component-migrations.json": "./component-migrations.json", "./component-migrations.json": "./component-migrations.json",
@@ -26,10 +46,11 @@
"./ui.css": "./ui.css" "./ui.css": "./ui.css"
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "^0.7.0" "@wrnexus/core": "^0.8.6"
}, },
"files": [ "files": [
"dist", "dist",
"README.md",
"components", "components",
"ui.css", "ui.css",
"component-catalog.json", "component-catalog.json",
-2034
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -108,6 +108,11 @@ export const GET = serveFromStore("docs"); // your middleware decides who gets i
## API ## API
Uploads can participate in security and media pipelines without changing storage drivers. Pass a
`scan` hook to reject malware/DLP findings before storage, and `afterStore` to enqueue image/video
processing or indexing. If post-processing throws, WRNexus deletes the newly written object so a
partially accepted upload is never left behind.
| Export | What | | Export | What |
| --------------------------------------- | --------------------------------------------------------------- | | --------------------------------------- | --------------------------------------------------------------- |
| `handleUpload(opts)` | POST route handler → JSON `{ ok, files }` | | `handleUpload(opts)` | POST route handler → JSON `{ ok, files }` |
@@ -123,3 +128,36 @@ export const GET = serveFromStore("docs"); // your middleware decides who gets i
- SigV4 signing is implemented from scratch (no `@aws-sdk`); tested against local S3 semantics. - SigV4 signing is implemented from scratch (no `@aws-sdk`); tested against local S3 semantics.
Live AWS/R2 connectivity depends on your credentials + bucket policy. Live AWS/R2 connectivity depends on your credentials + bucket policy.
- v1 buffers each file in memory up to its size cap (fine for images/docs up to tens of MB). - v1 buffers each file in memory up to its size cap (fine for images/docs up to tens of MB).
## Helper and component kit
Use `formatFileSize`, `uploadAccept`, `uploadedFileMap`, `uploaderAttributes`, and `assertUploadedFiles` to keep upload forms and server validation consistent.
Enable `uploaderPlugin()` for:
- `<UploadDropzone />`
- `<UploadStatus />`
The complete blocks compose `Card`, `Alert`, and `Badge` from `@wrnexus/ui`; the specialized upload runtime remains responsible for the native file input and secure transport behavior.
Large files can use `createResumableUploadManager`. Sessions are bounded and
expiring; chunks may arrive out of order, carry SHA-256 checksums, and are
idempotent when retried. Conflicting retries reject, and the object is assembled
only after every exact-sized chunk is present.
```ts
const uploads = createResumableUploadManager({
driver: getStore("documents").driver,
sessions: redisUploadSessionStore,
chunkSize: 5 * 1024 * 1024,
maxBytes: 500 * 1024 * 1024,
accept: ["application/pdf"],
});
const session = await uploads.create({ name: "report.pdf", size, type });
await uploads.uploadChunk(session.id, index, bytes, sha256);
```
The included memory session store is intended for one-process apps and tests.
Multi-instance production deployments should implement `ResumableSessionStore`
with shared durable storage and atomic session updates, and periodically call
`prune()` for abandoned uploads.
+37 -5
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/uploader", "name": "@wrnexus/uploader",
"version": "0.7.0", "version": "0.8.6",
"type": "module", "type": "module",
"description": "@wrnexus/uploader — part of the WrNexus framework.", "description": "Secure upload drivers, policies, client runtime, helper functions, and reusable upload components.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/uploader"
},
"homepage": "https://wrnexusjs.dev/packages/uploader",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"uploader"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -18,12 +34,28 @@
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
} },
"./plugin": {
"types": "./dist/plugin.d.ts",
"import": "./dist/plugin.js"
},
"./components/*": "./components/*"
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "^0.7.0" "@wrnexus/core": "^0.8.6",
"@wrnexus/plugin": "^0.8.6",
"@wrnexus/ui": "^0.8.6"
},
"wrnexus": {
"plugin": {
"plugin": "./dist/plugin.js",
"export": "default",
"factory": true
}
}, },
"files": [ "files": [
"dist" "dist",
"README.md",
"components"
] ]
} }
+77
View File
@@ -4,6 +4,31 @@
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework. Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
## Boundary contracts
Use `ContractRegistry` with `defineContract` or `defineEvent` to publish the
same schema descriptors for APIs, actions, webhooks, realtime, queues, cron,
pub/sub, plugins, configuration, and environment variables.
```ts
import { ContractRegistry, defineEvent, v } from "@wrnexus/validation";
export const contracts = new ContractRegistry().register(
defineEvent({
name: "user.created",
version: 1,
consumers: ["notification-worker", "audit-service"],
payload: v.object({ userId: v.string().uuid(), createdAt: v.string().date() }),
}),
);
```
Export the registry from `app/contracts.ts`, then accept a baseline with
`wrnexus contracts snapshot`. CI can run `wrnexus contracts check`; removed
contracts/fields, required-field additions, type changes, narrowed enums, and
tighter validation fail with stable `WRN-CONTRACT-*` diagnostics and list known
consumers. A generated `wrnexus.contracts.json` can be used instead of a module.
## Overview ## Overview
Define a schema once with the fluent `v` builder, then reuse it in three places: `.parse()` runs server-side and returns coerced values plus per-field errors; `.describe()` emits a plain-JSON `SchemaDescriptor` that the browser runtime interprets (no `eval`, no bundled validator); and helpers like `parseBody` and `parseEnv` wire schemas straight into API routes and startup config. The server rule logic (`applyRule`/`checkField`) and the client runtime (`VALIDATE_RUNTIME`) mirror each other exactly, so a form validates identically in both places. Schemas are conventionally kept in `app/schemas/`. Define a schema once with the fluent `v` builder, then reuse it in three places: `.parse()` runs server-side and returns coerced values plus per-field errors; `.describe()` emits a plain-JSON `SchemaDescriptor` that the browser runtime interprets (no `eval`, no bundled validator); and helpers like `parseBody` and `parseEnv` wire schemas straight into API routes and startup config. The server rule logic (`applyRule`/`checkField`) and the client runtime (`VALIDATE_RUNTIME`) mirror each other exactly, so a form validates identically in both places. Schemas are conventionally kept in `app/schemas/`.
@@ -178,3 +203,55 @@ const head = `<script>${renderSchemasScript({ signup: signupSchema.describe() })
- Refinements (`refine`) run only server-side and are never serialized — client and server agree on every other rule because both interpret the same `RuleDescriptor` list. - Refinements (`refine`) run only server-side and are never serialized — client and server agree on every other rule because both interpret the same `RuleDescriptor` list.
- No runtime dependencies. Ships as TypeScript source (`src/index.ts`) executed directly by Bun. - No runtime dependencies. Ships as TypeScript source (`src/index.ts`) executed directly by Bun.
- Pairs with the WrNexus server (`@wrnexus/core`) for route handlers and the SSR layer that injects `renderSchemasScript` / `VALIDATE_RUNTIME`. - Pairs with the WrNexus server (`@wrnexus/core`) for route handlers and the SSR layer that injects `renderSchemasScript` / `VALIDATE_RUNTIME`.
## Helper and component kit
The public helper API includes `parseOrThrow`, `ValidationError`, `validationResponse`, `firstValidationError`, `validationSummary`, and `schemaFieldNames`.
Schema output is inferred automatically by `ObjectSchema`, `parseOrThrow`, `parseBody`, `parseEnv`, and `asyncSchema`. Use `InferSchema<typeof schema>` when a named output type is useful:
```ts
const accountSchema = v.object({
email: v.string().email(),
attempts: v.number().integer(),
});
type AccountInput = InferSchema<typeof accountSchema>;
const account = parseOrThrow(accountSchema, input);
// account.email: string
// account.attempts: number
```
Enable `validationPlugin()` for:
- `<ValidationSummary />`
- `<FieldError />`
The summary block composes `Alert` from `@wrnexus/ui`, while `FieldError` remains a lightweight accessible field-level primitive.
Schemas can drive external contracts without maintaining a second definition:
```ts
import {
localizeDescriptor,
openApiRequestBody,
parseDescriptor,
toJsonSchema,
} from "@wrnexus/validation";
const jsonSchema = toJsonSchema(contactSchema, {
id: "urn:example:contact",
title: "Contact request",
});
const requestBody = openApiRequestBody(contactSchema);
const mr = localizeDescriptor(contactSchema, (key, params) =>
translations.t(`validation.${key}`, params),
);
const result = parseDescriptor(mr, input);
```
JSON Schema output targets draft 2020-12, closes unknown object properties, and
maps lengths/ranges/formats/enums/patterns/integer rules. OpenAPI request bodies
reuse the same properties. Localized descriptors preserve explicit custom
messages and fill default required, type-coercion, and rule messages; the same
descriptor is consumable by server parsing and the eval-free browser runtime.
+38 -3
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/validation", "name": "@wrnexus/validation",
"version": "0.7.0", "version": "0.8.6",
"type": "module", "type": "module",
"description": "@wrnexus/validation — part of the WrNexus framework.", "description": "Shared server/browser schemas, validation helpers, form runtime, and reusable error components.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/validation"
},
"homepage": "https://wrnexusjs.dev/packages/validation",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"validation"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -18,9 +34,28 @@
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
},
"./plugin": {
"types": "./dist/plugin.d.ts",
"import": "./dist/plugin.js"
},
"./components/*": "./components/*"
},
"dependencies": {
"@wrnexus/core": "^0.8.6",
"@wrnexus/plugin": "^0.8.6",
"@wrnexus/ui": "^0.8.6"
},
"wrnexus": {
"plugin": {
"plugin": "./dist/plugin.js",
"export": "default",
"factory": true
} }
}, },
"files": [ "files": [
"dist" "dist",
"README.md",
"components"
] ]
} }

Some files were not shown because too many files have changed in this diff Show More