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>
This commit is contained in:
2026-08-08 17:16:57 +05:30
co-authored by Claude Opus 5
parent c7ab605e85
commit 84dc4e06a2
3 changed files with 201 additions and 0 deletions
+94
View File
@@ -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 34 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.
+43
View File
@@ -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. */
+64
View File
@@ -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");
});