diff --git a/docs/ui-library-audit-2026-08-08.md b/docs/ui-library-audit-2026-08-08.md new file mode 100644 index 00000000..7b1fa10c --- /dev/null +++ b/docs/ui-library-audit-2026-08-08.md @@ -0,0 +1,94 @@ +# 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. + +## Outstanding, with numbers + +**32 outputs across 16 components have no emitter of any kind.** This is the +bug LayoutSplitter had: 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 | +| Card | action, navigate, dismiss | +| Accordion | open, close | +| AdvancedRangeSlider | start, end | +| Chart | dataPointClick, legendToggle | +| Confetti | start, complete | +| TreeView | expand, collapse | +| AnnouncementBar, Badge, Toast, alert | dismiss | +| AvatarGroup | overflow | +| CopyMarkup | success | +| Footer | action | + +A test pins this at 32 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 output is not delivered to a parent binding in at least one + case.** LayoutSplitter emits `sizeChange` and the component genuinely fires + the event, but `@sizeChange` on the tag is not invoked. Outputs work + elsewhere, so this is narrow and unexplained. Renaming away from the native + `resize` did not fix it, so the first hypothesis was wrong. +- **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. diff --git a/packages/cli/src/update.ts b/packages/cli/src/update.ts index 62164ceb..2a32df5d 100644 --- a/packages/cli/src/update.ts +++ b/packages/cli/src/update.ts @@ -2029,6 +2029,49 @@ const MIGRATIONS: Migration[] = [ // else arrives through the dependency update. }, }, + { + version: "0.8.6", + id: "0.8.6-navigation-and-layout-groups", + description: + "Rebuilds the navigation and layout component groups, moves them off Tailwind utilities onto wire-* classes, and defines theme tokens that components referenced but nothing declared.", + apply() { + // Source changes no codemod can make safely, so they are listed rather + // than attempted. + // + // Tabs replaced its raw CustomEvents with declared outputs. Code + // listening for the old change and select events on the element must + // move to the @change and @select bindings. + // + // Sidebar renamed its classes to the BEM form used everywhere else: + // wire-sidebar-shell, -items, -group, -toggle, -backdrop, -panel and + // -layout became wire-sidebar__*. Nesting via children still works. + // + // The layout, page and section components moved from Tailwind utility + // classes to wire-* classes with their own styles. Application CSS + // selecting on the utility classes they used to render -- max-w-7xl, + // gap-5, sm:grid-cols-2 and the rest -- no longer matches. Variants are + // data attributes now, so target [data-variant] and friends instead. + // + // LayoutSplitter and CustomScrollbar previously declared outputs they + // never emitted. LayoutSplitter now resizes and emits sizeChange rather + // than resizeStart, resize and resizeEnd, and its props are size, + // minSize, step and orientation rather than columns, gap and maxWidth. + // CustomScrollbar dropped its scroll output; listen for the plain scroll + // event on the element. Its props are axis, thickness, maxHeight and + // radius. + // + // ui.css lost several application-pattern class families that nothing + // referenced: wire-catalog-*, wire-page-shell, wire-product-card, + // wire-legal-toc, wire-sdk-tabs, wire-cookie-* and wire-analytics-preview. + // Anything hand-written against those needs its own styles. + // + // Themes gain tokens that were referenced but never defined, including + // --wire-color-focus, --wire-color-surface-soft, --wire-color-on-danger + // and the input-* family. A custom theme that declared these itself + // keeps winning; one that did not will see focus rings and soft surfaces + // start painting where they previously rendered as nothing. + }, + }, ]; /** Release tooling uses this to require an explicit migration entry per version. */ diff --git a/packages/ui/test/ui.test.ts b/packages/ui/test/ui.test.ts index 3edfa874..cb6aae99 100644 --- a/packages/ui/test/ui.test.ts +++ b/packages/ui/test/ui.test.ts @@ -3100,3 +3100,67 @@ test("every wire color token a component references is defined by the theme", () expect([...missing].map(([token, users]) => `${token} <- ${users.join(", ")}`)).toEqual([]); }); + +test("no component gains an output that nothing ever emits", () => { + /* + * LayoutSplitter declared resizeStart, resize and resizeEnd with no pointer + * handling at all: a caller wired up @resize and received nothing, for ever, + * with no error. The same shape survives in other components, so this pins + * the count rather than letting it grow while the rest are rebuilt. + * + * Native event names are excluded. The runtime binds a DOM-listener fallback + * on component tags, so declaring click or input as an output does reach a + * parent binding through bubbling. + */ + const native = new Set([ + "click", + "focus", + "blur", + "input", + "change", + "submit", + "copy", + "paste", + "load", + "error", + "scroll", + "toggle", + "drop", + "dragstart", + "dragend", + "dragenter", + "dragleave", + "dragover", + "keydown", + "keyup", + "select", + ]); + const runtime = readFileSync( + join(uiComponentsDir(), "..", "..", "csr", "src", "reactive-runtime.ts"), + "utf8", + ); + + const offenders: string[] = []; + for (const name of uiComponentNames()) { + const source = readFileSync(uiComponentPath(name), "utf8"); + const block = /^ {2}outputs \{([\s\S]*?)^ {2}\}/m.exec(source); + if (!block) continue; + for (const match of block[1]!.matchAll(/^\s*([A-Za-z][A-Za-z0-9_]*)\s*\(/gm)) { + const output = match[1]!; + if (source.slice(block.index).includes(`output.${output}(`)) continue; + if (source.includes("output[")) continue; + if (native.has(output.toLowerCase())) continue; + // Emitted by a runtime controller written for this component. + if (runtime.includes(`"${output}"`) && new RegExp(name, "i").test(runtime)) continue; + offenders.push(`${name}.${output}`); + } + } + + /* + * A ceiling, not a target. It only ever moves down: rebuilding one of these + * components should tighten it. + */ + expect(offenders.length).toBeLessThanOrEqual(32); + expect(offenders).not.toContain("LayoutSplitter.sizeChange"); + expect(offenders).not.toContain("CustomScrollbar.scroll"); +});