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>
6.2 KiB
UI library audit — 2026-08-08
A measured pass over @wrnexus/ui after the navigation and layout groups were
rebuilt. Numbers come from the source and from a browser, not from estimates.
Fixed in this pass
Ten theme tokens were referenced and never defined. An undefined custom property does not warn — it resolves to nothing — so the styles that used them silently did nothing:
| Token | Effect while undefined |
|---|---|
--wire-color-focus |
Focus rings drew with no colour, in 14 components |
--wire-color-surface-soft |
Every soft surface rendered transparent, in 27 components |
--wire-color-on-danger |
Text on danger fills had no colour |
--wire-color-surface-subtle |
Subtle panels rendered transparent |
--wire-color-input-* (6) |
Input backgrounds, borders and placeholders unstyled |
*-hover, *-contrast |
Missing for info, success, warning and danger |
They are derived in the theme now, so they follow the palette and the accent. Verified in a browser: 38 tokens referenced, 0 undefined (was 10).
Two mistakes worth recording, because both nearly became wrong conclusions:
- A first version of the check read the theme source and reported a dozen false positives, because most tokens are generated per palette rather than written as literals. The check now reads the rendered theme CSS.
- The semantic token spread sat after the primary and secondary palette entries, so it would have overridden them. It now sits before.
Correction: outputs, and what was actually wrong
The first version of this audit called the LayoutSplitter output failure "narrow and unexplained" and treated 32 dead outputs as 16 components needing a rebuild. Both were wrong, and the real cause was found by binding a page handler to a component in a browser and watching what arrived.
Two distinct faults were hiding behind one symptom.
Emitting the wrong way. An output only reaches a parent @binding when the
component calls output.<name>(). The runtime resolves parent handlers from a
registry that only invokeComponentOutput reads, so a component that builds
its own CustomEvent — even a bubbling one, dispatched on its own root —
emits into nothing. Eighteen components did exactly that, so their outputs were
declared, documented, apparently fired, and never delivered. Converting them
took ten dead outputs off the list and cost no new machinery.
Case. HTML lowercases attribute names, so a parent's @sizeChange registers
under sizechange while the component emits sizeChange. The lookup missed,
fell through to a DOM dispatch, and the binding was never invoked — silently,
with no error at either end. All 17 camelCase outputs in the library were
undeliverable, including DataTable.pageChange and DataTable.rowClick,
Map.markerClick, ChatBubble.messageClick and LayoutSplitter.sizeChange.
The runtime now falls back to a case-insensitive lookup, and a test in
packages/csr fails without that fallback.
The lesson worth keeping: "outputs work elsewhere" was an assumption, not a
measurement. The components that worked happened to use all-lowercase names and
output.*; that coincidence made a framework-wide bug look like one broken
component.
Outstanding, with numbers
22 outputs across 11 components have no emitter of any kind (was 32 across
16; see the correction above). The component advertises an output, a caller
binds to it, and nothing ever fires. Native event names are excluded — the
runtime binds a DOM-listener fallback on component tags, so click and input
do arrive.
| Component | Dead outputs |
|---|---|
| FileUpload | upload, progress, success, cancel, remove |
| ToastNotifications | add, dismiss, clear, action |
| AdvancedDatePicker | open, close, clear |
| AdvancedRangeSlider | start, end |
| Chart | dataPointClick, legendToggle |
| Confetti | start, complete |
| TreeView | expand, collapse |
| Toast | dismiss |
| CopyMarkup | success |
A test pins this at 22 as a ceiling that only moves down.
23 components are still on the wire-next scaffold pattern — no style
block, no functions, and for 19 of them an outputs block they never honour.
These are the same shape as the Table scaffold that was removed and the
LayoutSplitter scaffold that was rebuilt.
66 of 108 components have no local style block, so they depend on
ui.css. That matters for delivery: a migrated component ships its CSS only
when it renders, while ui.css is served whole to every page — 175,848 bytes,
25,908 gzipped. Migrating the remainder would shrink it toward nothing.
10 components still use Tailwind utilities: FeatureIconCard, HeroActions, InputNumber, List, Map, Marquee, SearchBox, TextLink, Timeline, button.
Not worth doing
Splitting the runtime into chunks. Measured rather than assumed:
reactive.js is 69,947 bytes minified, roughly 21 kB gzipped, served as its
own file with a one-year immutable cache. Component-specific controllers are
about 18% of it, so splitting them out saves 3–4 kB gzipped on a first visit
and nothing after that. That is a poor return for lazy-loading complexity.
Hydration is not a bottleneck either: 21 scopes across 4,325 elements rehydrate in 1.5 ms, and the shared document observer cost 0.9 ms across three seconds of the busiest activity in the library.
Known framework defects
- A component tag nested inside another component slot is dropped, leaving only its children.
- Component props and slot content render once and do not track page state, so the controlled-component pattern does not work.
- i18n text is not server-rendered. Nav links ship as empty spans and are filled by the client, so navigation is blank until the runtime loads.