875 lines
42 KiB
Markdown
875 lines
42 KiB
Markdown
# Framework remediation plan
|
|
|
|
**Status: remediation behavior completed; CSS ownership cleanup remains.** The
|
|
repository has 102 maintained UI components, zero
|
|
undeclared non-native output emitters, zero components matching the scaffold
|
|
definition, and local styles on every component. The core reactive runtime is
|
|
48,124 minified bytes; component controllers ship separately at 24,027 bytes.
|
|
|
|
Section 4.1 remains open: the global `ui.css` still contains legacy component
|
|
rules duplicated by local component styles. Those rules must be removed before
|
|
the §0.1 ownership convention can be called complete.
|
|
|
|
Final validation passed the production, package, example, service, editor,
|
|
showcase-generation, typecheck, lint, formatting, public-API, visual-contract,
|
|
security, and runtime-size gates. The only skipped test is the explicitly
|
|
environment-dependent live PostgreSQL/MySQL test.
|
|
|
|
Written 2026-08-09, after the 0.8.6 release. Every number here was measured
|
|
against the tree at commit `7a2b5865`, not estimated. Where a cause is not yet
|
|
proven the item says so and makes proving it step one — nothing in this document
|
|
should be implemented on the strength of a guess.
|
|
|
|
Each work item has the same four fields:
|
|
|
|
- **Issue** — what is wrong, stated so it can be disagreed with.
|
|
- **Evidence** — how we know, with the file or the measurement.
|
|
- **Change** — what to do.
|
|
- **How to test** — the check that fails before and passes after. If an item's
|
|
test cannot fail before the change, the test is wrong; see the note on
|
|
vacuous gates in §0.2.
|
|
|
|
---
|
|
|
|
## 0. Standing conventions
|
|
|
|
These are not tasks. They are the rules the tasks below are written against, and
|
|
they apply to all new work from now on.
|
|
|
|
### 0.1 A component owns everything it needs
|
|
|
|
**Every `.wrn` component is self-contained.** Markup, behaviour and styles live
|
|
in that one file. A component that needs a rule writes it in its own `style {}`
|
|
block, scoped to its own classes.
|
|
|
|
`ui.css` holds **global styles only** — resets, tokens, base element styling and
|
|
genuinely cross-cutting primitives. It is not a place to put component rules,
|
|
and no new component rule may be added to it. The reason is delivery: a
|
|
component's own styles ship only when that component renders, while `ui.css` is
|
|
served whole to every page on the site. See §4.1 for the migration and the
|
|
numbers.
|
|
|
|
Practical rules:
|
|
|
|
- CSS comments in a `style {}` block are `/* */` only. `//` is not a CSS comment
|
|
and silently swallows the rule that follows it.
|
|
- Style the component's own BEM classes (`wire-thing`, `wire-thing__part`).
|
|
Variants are data attributes: `[data-variant="soft"]`, not a class explosion.
|
|
- No Tailwind utility classes in component markup. See §4.3.
|
|
|
|
### 0.2 A test that cannot fail is not a test
|
|
|
|
Two gates in this repo passed for months while testing nothing, because both
|
|
were written against `getBoundingClientRect()`, which returns zeros in
|
|
happy-dom. The 0.8.5 focus trap had no coverage at all as a result.
|
|
|
|
**Before committing any test in this plan, delete the fix, watch the test fail,
|
|
then restore the fix.** If it passes with the fix removed, it is measuring
|
|
nothing. Never gate testable behaviour on measured geometry.
|
|
|
|
---
|
|
|
|
## 1. Bugs from this session
|
|
|
|
All five are **fixed and shipped in 0.8.6**. They are recorded here because each
|
|
one has a permanent guard that must not be removed, and because the pattern they
|
|
share is the subject of §2.
|
|
|
|
### 1.1 camelCase outputs never reached a parent binding — FIXED
|
|
|
|
**Issue.** HTML lowercases attribute names, so a parent writing `@sizeChange`
|
|
registered a handler under `sizechange`, while the component emitted
|
|
`sizeChange`. The registry lookup missed, fell through to a DOM dispatch, and
|
|
the parent binding was never invoked. No error at either end.
|
|
|
|
**Evidence.** All 17 camelCase outputs in the library were undeliverable:
|
|
`DataTable.pageChange`, `DataTable.rowClick`, `Map.markerClick`,
|
|
`ChatBubble.messageClick`, `LayoutSplitter.sizeChange`, the four `carousel.*`
|
|
and four `DragAndDrop.*` names, and the rest. Confirmed in a browser by reading
|
|
`__wrnexusOutputHandlers` off the component root and seeing the key casing.
|
|
|
|
**Change.** Done — `invokeComponentOutput` in
|
|
`packages/csr/src/reactive-runtime.ts` now falls back to a case-insensitive
|
|
lookup.
|
|
|
|
**How to test.**
|
|
|
|
```bash
|
|
bun test packages/csr/test/reactive.test.ts -t "camelCase output"
|
|
```
|
|
|
|
Verified failing with the fallback removed. Keep it that way.
|
|
|
|
### 1.2 Eighteen components emitted into the void — FIXED
|
|
|
|
**Issue.** 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 dispatching its own
|
|
`CustomEvent` — even a bubbling one on its own root — emits to nobody.
|
|
|
|
**Evidence.** 18 components did this. Card, Footer, Breadcrumb, Accordion,
|
|
alert, Badge, AnnouncementBar, AvatarGroup, ToggleCount and InputNumber were
|
|
converted; Marquee, Map, Timeline, List and SearchBox additionally had to
|
|
_declare_ the outputs they were already firing.
|
|
|
|
**Change.** Done. Note the deliberate exception: dispatching on `window` is
|
|
still correct and is left alone — that is how Toaster, Modal and DataTable
|
|
signal across component boundaries, where there is no parent binding to reach.
|
|
|
|
**How to test.**
|
|
|
|
```bash
|
|
bun test packages/ui/test/ui.test.ts -t "hand-built CustomEvent"
|
|
```
|
|
|
|
### 1.3 Ten theme tokens were referenced and never defined — FIXED
|
|
|
|
**Issue.** An undefined custom property does not warn; it resolves to nothing.
|
|
Focus rings drew with no colour in 14 components, every soft surface rendered
|
|
transparent in 27.
|
|
|
|
**Change.** Done — derived in `packages/styles/src/theme.ts`. Two traps worth
|
|
remembering: a check that reads the theme _source_ produces about a dozen false
|
|
positives because most tokens are generated per palette, so any such check must
|
|
read the **rendered** CSS; and the semantic spread must sit _before_ the
|
|
primary/secondary palette entries or it overrides them.
|
|
|
|
**How to test.** Load a page, then in the console collect every `var(--wire-*)`
|
|
referenced by stylesheet rules and check each against
|
|
`getComputedStyle(document.documentElement)`. Expect 38 referenced, 0 undefined.
|
|
|
|
### 1.4 The modal focus trap had no coverage — FIXED
|
|
|
|
**Issue.** `isDialogVisible` and `focusableWithin` both gated on
|
|
`getBoundingClientRect()`, which is all zeros in happy-dom. The trap never ran
|
|
under test.
|
|
|
|
**Change.** Done — both read markers instead. One regression to remember: the
|
|
first fix used `data-show`, which broke Drawer, because a Drawer _animates_ open
|
|
and so cannot use `data-show`; every closed Drawer then looked open and held
|
|
`body { overflow: hidden }` forever. It reads `data-open` now.
|
|
|
|
**How to test.** `bun test packages/csr` — the dialog tests must fail if the
|
|
marker checks are reverted to geometry.
|
|
|
|
### 1.5 Roving focus matched `="false"` — FIXED
|
|
|
|
**Issue.** Attribute-presence selectors match any value, including `"false"`, so
|
|
read-only Steppers captured arrow keys from the page.
|
|
|
|
**Change.** Done — `rovingItemOff` treats bare as on and `"false"` as off.
|
|
|
|
**How to test.** `bun test packages/csr` roving cases; a read-only Stepper must
|
|
not consume `ArrowRight`.
|
|
|
|
---
|
|
|
|
## 2. The model is incomplete
|
|
|
|
This section is the real work. These are capability gaps, not defects, and they
|
|
are what currently limits what anyone can build. **§2.1 is the highest-value
|
|
item in this entire document.**
|
|
|
|
### 2.1 Development diagnostics — FIXED
|
|
|
|
**Issue.** Nothing in §1 produced a single diagnostic at any layer. That is not
|
|
five unlucky bugs; it is a systemic property. The failure mode is always
|
|
"nothing happens", which is the most expensive kind to debug because a bug is
|
|
indistinguishable from something you configured wrong.
|
|
|
|
**Evidence.** `packages/csr/src/reactive-runtime.ts` has 55 `catch` blocks, 11
|
|
of which swallow entirely, and 18 `console.error`/`console.warn` calls across
|
|
~70 kB of runtime. Undefined theme token → renders nothing. Wrong output casing
|
|
→ delivers nothing. Deferred state write → dropped. Component in a slot →
|
|
dropped. Object prop written inline → parsed as interpolation. None warn.
|
|
|
|
**Change.** Add a **dev-mode diagnostic layer**, stripped from production
|
|
builds. It should warn on at least:
|
|
|
|
1. `output.<name>()` called with no handler registered **and** no listener for
|
|
the event — include the registry keys actually present, which is exactly what
|
|
would have made §1.1 a five-minute bug.
|
|
2. A `@binding` naming a function that does not exist in scope.
|
|
3. A `var(--wire-*)` referenced by a component but absent from the rendered
|
|
theme.
|
|
4. A state write discarded because it happened after the client function
|
|
returned (see §2.2).
|
|
5. A component tag dropped during slot flattening (see §2.4).
|
|
6. A prop declared as an object or array that arrived as an unparsed string.
|
|
|
|
Gate it on `process.env.NODE_ENV !== "production"` so the production bundle is
|
|
byte-identical to today. Route everything through one `warnOnce(code, message)`
|
|
helper with stable `WRN-` codes, so warnings are greppable and documentable and
|
|
a loop cannot spam the console.
|
|
|
|
**How to test.** A test per diagnostic that asserts the warning fires, plus one
|
|
asserting the production build contains none of them:
|
|
|
|
```bash
|
|
bun test packages/csr/test/diagnostics.test.ts
|
|
bun run build && grep -c "WRN-DEV-" examples/basic-app/dist/reactive.js # expect 0
|
|
```
|
|
|
|
The production size ratchet now caps the split core runtime at 49,000 bytes; it
|
|
currently measures 48,124 minified bytes.
|
|
|
|
### 2.2 Deferred state writes — FIXED
|
|
|
|
**Issue.** State written after a client function returns — in a `setTimeout`, a
|
|
promise callback, an observer, a `pointermove` — is discarded. The peer-function
|
|
wrapper flushes the entry-time snapshot, so the later write is simply lost.
|
|
|
|
**Evidence.** This is why the focus trap, scrollspy, roving focus and the
|
|
splitter all had to be written as bespoke controllers inside the runtime.
|
|
Component-specific controllers are roughly 18% of `reactive.js` — that is a
|
|
model limitation being paid for in bundle size.
|
|
|
|
**Change.** Two parts, in order.
|
|
|
|
1. **Diagnose it** (part of §2.1). A dropped write must warn with the state key
|
|
and the function name. Cheap, and it stops the silent-loss class immediately.
|
|
2. **Support it.** Let a client function return a promise, or expose an explicit
|
|
`commit()`, so async writes reach the scope. This is the design decision —
|
|
it wants a short written proposal before implementation, because it
|
|
interacts with re-render batching.
|
|
|
|
**How to test.** A test where a client function writes state inside a
|
|
`setTimeout` and the DOM reflects it after the timer. This failed before the fix.
|
|
|
|
### 2.3 Reactive props and slot content — FIXED
|
|
|
|
**Issue.** Component props and slot content do not track page state. The
|
|
controlled-component pattern does not work at all: passing `value={pageState}`
|
|
renders the initial value and never updates.
|
|
|
|
**Why it matters most.** This is the biggest ceiling in the framework. It means
|
|
composition breaks down exactly where a component library needs it — a parent
|
|
cannot drive a child. Every workaround today is "hoist into page state and
|
|
re-render", which does not compose.
|
|
|
|
**Change.** Make prop expressions reactive bindings rather than one-time
|
|
interpolations, the way attribute bindings already work (`data-wrn-bind-*`).
|
|
The mechanism exists for attributes; extend it to component props.
|
|
|
|
If there is a hard reason this cannot work, **document that reason in the
|
|
component authoring guide** and give people a supported pattern instead. What
|
|
is not acceptable is the current state, where it looks like it should work and
|
|
quietly doesn't.
|
|
|
|
**How to test.** A page with `state n`, a component bound to `value={n}`, and a
|
|
button incrementing `n`; the component's rendered output must follow. Must fail
|
|
before the fix.
|
|
|
|
### 2.4 Nested component slots — FIXED
|
|
|
|
**Issue.** Nesting a component tag in another component's slot drops the tag and
|
|
leaves its children orphaned.
|
|
|
|
**Change.** Fix slot flattening to preserve nested component boundaries. Until
|
|
it is fixed, emit the §2.1 diagnostic — a dropped component must never be
|
|
silent.
|
|
|
|
**How to test.** Render `<Card><Badge label="x" /></Card>` and assert the Badge
|
|
root element exists in the output. This failed before the fix.
|
|
|
|
### 2.5 Structured object props — FIXED
|
|
|
|
**Issue.** An object or array prop written inline in an attribute is read as
|
|
interpolation. Three encodings were tried this session and all failed: bare
|
|
`{}` produced a 500, `({})` made `JSON.parse` throw so demos rendered silently
|
|
empty, and entity-escaping failed because the compiler passes attributes
|
|
undecoded. The only thing that works is hoisting to page state with
|
|
`JSON.parse`.
|
|
|
|
**Change.** Either support inline object literals in prop position, or reject
|
|
them at **compile time** with a message naming the prop and showing the hoist
|
|
pattern. A 500 at runtime and a silently empty component are both unacceptable
|
|
outcomes for a plain authoring mistake.
|
|
|
|
**How to test.** A compiler test asserting the specific diagnostic for
|
|
`items={{ a: 1 }}`, and one asserting the hoisted form still compiles.
|
|
|
|
### 2.6 i18n text is server-rendered — FIXED
|
|
|
|
**Original issue.** Translated text shipped as empty spans and was filled after
|
|
hydration. Navigation was blank until the runtime loaded.
|
|
|
|
**Why it matters.** This is an SSR-first framework failing to server-render
|
|
text. It is an SEO problem, a layout-shift problem, and it undercuts the
|
|
framework's central claim.
|
|
|
|
**Change.** Done. The active locale is resolved during SSR and translated text
|
|
is emitted into the HTML, keeping the client path for locale switches. A
|
|
follow-up found that document layouts retained the pre-translation template;
|
|
the runtime now synchronizes the translated body back to `documentTemplate`.
|
|
|
|
**How to test.**
|
|
|
|
```bash
|
|
curl -s http://localhost:3520/ | grep -c "Navigation" # expect > 0
|
|
```
|
|
|
|
Assert against the raw server response with JavaScript disabled — not the
|
|
hydrated DOM.
|
|
|
|
---
|
|
|
|
## 3. Smaller but real
|
|
|
|
### 3.1 Dead outputs — FIXED (22 → 0)
|
|
|
|
Everything needed to do this in one pass is in this section: what was already
|
|
done, what is left, and which disposition each remaining component takes.
|
|
|
|
#### 3.1.1 Already done in 0.8.6 — do not redo
|
|
|
|
Fifteen components were fixed. Ten had outputs that were **declared but
|
|
miswired** — they dispatched a hand-built `CustomEvent` instead of calling
|
|
`output.*`, so the declaration was right and only the emit was wrong. Nothing
|
|
was added to these; they were rewired:
|
|
|
|
> Card, Footer, Breadcrumb, Accordion, alert, Badge, AnnouncementBar,
|
|
> AvatarGroup, ToggleCount, InputNumber
|
|
|
|
Five more were **firing events they had never declared**, so no caller could
|
|
bind to them at all. These gained an `outputs {}` block _and_ were routed
|
|
through `output.*`:
|
|
|
|
| Component | Outputs added | Fires when |
|
|
| ----------- | ------------------------------- | ------------------------------------------------------ |
|
|
| `Map` | `markerClick`, `select`, `zoom` | marker pressed (both names fire); zoom in/out pressed |
|
|
| `SearchBox` | `search`, `clear` | form submitted; clear button pressed |
|
|
| `Marquee` | `pause`, `resume` | hover, focus, or the pause/play button |
|
|
| `List` | `select` | item pressed — **only items with an `href`**, see §3.4 |
|
|
| `Timeline` | `select` | item pressed |
|
|
|
|
Verified firing in a browser: `SearchBox`, `Marquee`, `List`, `Timeline`, plus
|
|
all ten rewired components. **`Map` was not verified** — its three outputs were
|
|
converted by the same mechanical change and the build passes, but no Map was on
|
|
the probe page. Put one on a page and confirm before treating it as done.
|
|
|
|
#### 3.1.2 Original remaining work — resolved
|
|
|
|
**Original issue.** These were not miswired. All nine were **pure scaffolds**: roughly 25
|
|
lines each, zero state, zero functions, no style block, no event handlers. They
|
|
are markup shells that declare outputs. "Add an emitter" is not the work — there
|
|
is nothing to emit from.
|
|
|
|
**The finding that should drive the decision: most of them duplicate a component
|
|
that already works.** This is the Table situation again, where Table was removed
|
|
because DataTable superseded it.
|
|
|
|
| Scaffold | Dead outputs | Already works elsewhere |
|
|
| --------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
| `FileUpload` | upload, progress, success, cancel, remove | `FileInput` (3 fns, 4 outputs) + `FileUploadProgress` (5 fns, 3 outputs), plus the `@wrnexus/uploader` package |
|
|
| `ToastNotifications` | add, dismiss, clear, action | `Toaster` (961 lines, 40 fns, fully built) |
|
|
| `Toast` | dismiss | `Toaster`, same |
|
|
| `AdvancedDatePicker` | open, close, clear | `DatePicker` (8 fns, 8 outputs) |
|
|
| `AdvancedRangeSlider` | start, end | `RangeSlider` (11 fns, 8 outputs) |
|
|
| `Chart` | dataPointClick, legendToggle | nothing — needs building |
|
|
| `TreeView` | expand, collapse | nothing — needs building |
|
|
| `Confetti` | start, complete | nothing — needs building |
|
|
| `CopyMarkup` | success | nothing — and `Clipboard` is a scaffold too (0 fns) |
|
|
|
|
**Change, in two groups.**
|
|
|
|
_Group A — supersede and remove: 5 components, 15 of the 22 outputs._
|
|
`FileUpload`, `ToastNotifications`, `Toast`, `AdvancedDatePicker`,
|
|
`AdvancedRangeSlider`. Each has a working counterpart, so building them a second
|
|
time adds duplicated surface area to maintain for no new capability. Removing
|
|
them is a **breaking change** and needs a migration entry naming the replacement
|
|
per component, in the same shape as the Table removal.
|
|
|
|
This is a judgement call and it is yours: superseding is cheap and honest,
|
|
keeping them means committing to build five more components properly.
|
|
|
|
_Group B — build for real: 4 components, 7 outputs._ `Chart`, `TreeView`,
|
|
`Confetti`, `CopyMarkup` (and `Clipboard` alongside it, since it is in the same
|
|
state). These have no counterpart, so their outputs only become meaningful once
|
|
the component exists. Build each under §0.1 — markup, behaviour and styles in
|
|
its own `.wrn`.
|
|
|
|
Notes that will save time:
|
|
|
|
- **`Chart` needs a rendering decision before any output can mean anything.** An
|
|
inline SVG renderer keeps the CSP story intact and adds no dependency; a
|
|
charting library ships faster but introduces a third-party runtime dependency
|
|
the framework does not currently carry. Decide that first — `dataPointClick`
|
|
and `legendToggle` are trivial once something is actually drawn.
|
|
- **`TreeView` should reuse the existing roving-focus controller** rather than
|
|
growing a new one. Expand/collapse state must live in the component, not in a
|
|
deferred callback — see §2.2.
|
|
- **`CopyMarkup`'s props are generic input boilerplate** (`name`, `value`,
|
|
`placeholder`, `type`, `min`) rather than anything copy-related, which
|
|
suggests it was generated rather than designed. Worth deciding what it is
|
|
meant to be before building it, or folding it into `Clipboard`.
|
|
|
|
Rough effort: `CopyMarkup`/`Clipboard` and `Confetti` are small and
|
|
self-contained. `TreeView` is medium — recursive rendering plus expand state.
|
|
`Chart` is the large one.
|
|
|
|
**How to test.**
|
|
|
|
- The ratchet in `packages/ui/test/ui.test.ts` is now pinned at **0**.
|
|
- For a removal: `bun run check:public-api` flags the dropped export, and the
|
|
migration entry is required before `release:prepare` will pass.
|
|
- For a build: the component renders, its behaviour works in a browser, its
|
|
outputs reach a page-level `@binding`, and it carries its own styles. Bind the
|
|
output from a page and confirm it arrives — §1.1 and §1.2 are both cases where
|
|
reading the source said it worked and the browser said otherwise.
|
|
|
|
### 3.2 Component scaffolds — FIXED (28 → 0)
|
|
|
|
**Original issue.** No style block, no functions and no event handlers — markup shells,
|
|
the same shape as the Table scaffold that was removed and the LayoutSplitter
|
|
scaffold that was rebuilt. A scaffold in a published library is a promise the
|
|
library does not keep.
|
|
|
|
**Evidence.** Counted as components with no `style {}`, no `function` and no
|
|
`@handler`. An earlier figure of 23 in the audit was measured with a looser rule;
|
|
28 was the number:
|
|
|
|
> AdvancedDatePicker, AdvancedRangeSlider, AuthSplitLayout, avatar, Blockquote,
|
|
> button, Chart, Clipboard, Confetti, CopyMarkup, DataMap, DragAndDrop,
|
|
> FeatureIconCard, FileUpload, HeroActions, LegendIndicator, ListGroup,
|
|
> MarketingSectionHeader, progress, Rating, skeleton, spinner, StyledIcon,
|
|
> TextLink, Toast, ToastNotifications, TreeView, WysiwygEditor
|
|
|
|
**Nine of these are the §3.1 dead-output components** — AdvancedDatePicker,
|
|
AdvancedRangeSlider, Chart, Confetti, CopyMarkup, FileUpload, Toast,
|
|
ToastNotifications, TreeView. Do §3.1 first and this list drops to 19 without
|
|
any extra work. Do not plan the two items separately.
|
|
|
|
Separately, 58 components carried the `wire-next` scaffold class in the
|
|
original audit; **45 retain it now**. This is a cosmetic naming artefact and was
|
|
not part of the scaffold completion criterion. That class is a
|
|
generation artefact rather than a design, and it is what ties them to `ui.css`
|
|
instead of their own styles (§4.1).
|
|
|
|
**Change.** Rebuild them under §0.1, or remove them. Note that several are
|
|
primitives where a scaffold is nearly the right answer — `skeleton`, `spinner`,
|
|
`avatar`, `Blockquote` and `StyledIcon` need styles but genuinely need no
|
|
behaviour, so for those "rebuild" means moving their CSS out of `ui.css` into
|
|
the component and nothing more. Sort the list into "needs behaviour" and "needs
|
|
only its styles" before starting; the second group is much larger and much
|
|
cheaper than it looks.
|
|
|
|
**How to test.** Per component: it renders, any interactive behaviour works in a
|
|
browser, its outputs reach a page-level binding, and it carries its own styles.
|
|
Track the count down the same way as §4.1:
|
|
|
|
```bash
|
|
bun run build && gzip -c examples/basic-app/dist/ui.css | wc -c
|
|
```
|
|
|
|
### 3.3 Tailwind component migrations — FIXED (7 → 0)
|
|
|
|
**Original issue.** `List` (23 utility classes), `InputNumber` (22), `Marquee` (15),
|
|
`TextLink` (14), `Map` (12), `SearchBox` (8), `Timeline` (8) still style
|
|
themselves with utility classes in markup.
|
|
|
|
All seven now use local `wire-*` BEM styles. `InputNumber` styles are emitted as
|
|
component-scoped CSS and therefore do not appear in the global `ui.css` file.
|
|
|
|
**Change.** Migrate to `wire-*` BEM classes in the component's own `style {}`
|
|
block, per §0.1. Note this changes the rendered class list, so application CSS
|
|
selecting on those utilities stops matching — it needs a migration note, exactly
|
|
as the layout group did in 0.8.6.
|
|
|
|
**How to test.**
|
|
|
|
```bash
|
|
bun run check:ui-visual # review the rendered diff deliberately
|
|
bun run generate:ui-visual # only after reviewing
|
|
```
|
|
|
|
Compare screenshots before and after; the visual result should be unchanged.
|
|
|
|
### 3.4 `List` selection without an href — FIXED
|
|
|
|
**Issue.** The `@click` handler sits on the `item.href` branch only, so an item
|
|
without a link is not selectable. Pre-existing, and surprising.
|
|
|
|
**Change.** Decide deliberately: either fire `select` for all items, or document
|
|
that `List` is a navigation component and non-link items are inert.
|
|
|
|
**How to test.** Render a `List` with items lacking `href`, click one, assert
|
|
whichever behaviour was chosen.
|
|
|
|
---
|
|
|
|
## 4. Delivery and the dev loop
|
|
|
|
### 4.1 Global `ui.css` migration — PARTIAL
|
|
|
|
**Original issue.** `ui.css` was **175,848 bytes, 26,350 gzipped**, served on
|
|
every page. Only **42 of 108** components had a local `style {}` block.
|
|
|
|
**Result so far.** The maintained library now has **102 of 102** components
|
|
with local styles. Built `ui.css` is **54,016 bytes / 9,338 gzipped** after
|
|
removing the unused `.wire-switch`, `.wire-alert`, and `.wire-card` families and
|
|
localizing the live `.wire-btn`, `.wire-dropdown`, ButtonGroup, alert-variant,
|
|
card-overlay, and related rules. The remaining `.wire-next` field and component
|
|
rules still form a parallel legacy layer. Local-style coverage alone does not
|
|
complete this item; global component selectors must reach zero.
|
|
|
|
**Change.** Done in groups with the visual contract regenerated after each
|
|
migration.
|
|
|
|
**How to test.** Track the number down, every time:
|
|
|
|
```bash
|
|
ls packages/ui/components/*.wrn | wc -l # 102
|
|
grep -lE '^\s*style\s*\{' packages/ui/components/*.wrn | wc -l # 102
|
|
bun run build && gzip -c examples/basic-app/dist/ui.css | wc -c # 9338
|
|
```
|
|
|
|
Add a ratchet test asserting the gzipped size only ever decreases, so this
|
|
cannot silently regress.
|
|
|
|
### 4.2 Dev-server package UI hot reload — FIXED
|
|
|
|
**Issue.** Editing a component under `packages/ui` does not hot-reload. Every UI
|
|
change this session needed a full server restart, which is the single biggest
|
|
drag on the edit loop.
|
|
|
|
**Evidence, and what is _not_ yet proven.** The watcher is wired correctly:
|
|
`componentDirs` includes `uiComponentsDir()` and is passed as `extraDirs`
|
|
(`packages/dev-server/src/index.ts:706-723`), external paths arrive absolute
|
|
(`packages/dev-server/src/watch.ts:104`), and `hotUpdate` calls
|
|
`invalidateModule` and rebuilds the router with `componentDirs`
|
|
(`index.ts:600-616`). So the change _is_ observed. **Why it does not take effect
|
|
is not yet diagnosed.**
|
|
|
|
One concrete lead, not a conclusion: `index.ts:619` filters to
|
|
`files.filter((file) => !isAbsolute(file))`, which excludes every
|
|
`packages/ui` path from the app-file branch below it.
|
|
|
|
**Change.** Step one is to confirm the cause — add a log at the top of
|
|
`hotUpdate` printing the received paths, edit a `.wrn` under `packages/ui`, and
|
|
follow it. Only then fix. Do not implement against the lead above without
|
|
confirming it.
|
|
|
|
**How to test.** Start the dev server, edit visible text in a
|
|
`packages/ui/components/*.wrn`, reload the page **without restarting**, and see
|
|
the change. That is the whole acceptance criterion.
|
|
|
|
### 4.3 Per-process development caches — FIXED
|
|
|
|
**Original issue.** Running `bun test` or `check:production` while a dev server was up
|
|
empties `.wrnexus`, and every client module then 404s until a restart. This
|
|
produced several confusing "the Sidebar is broken" symptoms this session that
|
|
were not component bugs at all.
|
|
|
|
**Evidence.** `resetDevCache` in `packages/dev-server/src/cache.ts:24-30`
|
|
`rmSync`s the whole cache directory on startup, unless
|
|
`WRNEXUS_PRESERVE_CACHE=1`. Any dev server a test spawns against the repo root
|
|
therefore deletes the running server's cache.
|
|
|
|
**Change.** The first option was implemented: each process uses
|
|
`.wrnexus-<pid>`, normal server shutdown removes its directory, and
|
|
`.wrnexus-*/` is excluded by Git, ESLint, TypeScript, and Prettier so an
|
|
interrupted process cannot pollute a commit or a repository-wide tool run.
|
|
|
|
The considered options were:
|
|
|
|
- Give each dev-server instance its own cache directory (keyed by port or pid),
|
|
so instances cannot collide; or
|
|
- Have the test harness set `WRNEXUS_PRESERVE_CACHE=1` and use a temp cache dir
|
|
for every spawned server.
|
|
|
|
Prefer the first — it fixes the class rather than one caller. Either way,
|
|
`resetDevCache` should refuse to delete a cache directory another live server
|
|
owns.
|
|
|
|
**How to test.** Start the dev server, load a page, run `bun test packages` to
|
|
completion, reload the page without restarting. It must still work.
|
|
|
|
### 4.4 Generated artefact ordering — FIXED
|
|
|
|
**Original issue.** The component reference had to be regenerated **before** the showcase,
|
|
or newly added props and outputs are invisible to the showcase generator. This
|
|
is undocumented and cost real time.
|
|
|
|
**Change.** Done. `generate:pages` regenerates the reference first, and both
|
|
generators emit idempotent Prettier-compatible artifacts.
|
|
|
|
**How to test.** Add a prop to a component, run the showcase generator without
|
|
regenerating the reference, and assert it errors rather than silently emitting a
|
|
stale page.
|
|
|
|
### 4.5 Compiler diagnostics for authoring traps — FIXED
|
|
|
|
**Issue.** Block comments are not allowed inside `props {}` — line comments
|
|
only — and `state page` collides with the `page` keyword. Both currently fail in
|
|
confusing ways.
|
|
|
|
**Change.** Detect both at compile time with a message that names the problem.
|
|
|
|
**How to test.** Compiler tests asserting the specific diagnostics.
|
|
|
|
---
|
|
|
|
### 4.6 Runtime and client-module size — measured
|
|
|
|
A per-subsystem measurement of `reactive.js` and of the generated client
|
|
modules, made by minifying the runtime repeatedly with one subsystem removed
|
|
each time. Source-byte share was not used: it overstates code that minifies
|
|
well and understates code that does not, and the split/keep decision turns on
|
|
the real number.
|
|
|
|
**This corrects the earlier audit**, which claimed component controllers were
|
|
"about 18%" of the runtime and concluded that splitting saves "3-4 kB gzipped".
|
|
Both figures were wrong, and the conclusion that followed from them was wrong.
|
|
|
|
#### Runtime before remediation
|
|
|
|
`reactive.js` was **70,101 bytes minified, 21,736 gzipped**. Removing each
|
|
subsystem and re-minifying gives its true cost:
|
|
|
|
| Subsystem | Minified | Share |
|
|
| ----------------- | -------- | ----- |
|
|
| Select/combobox | 7,852 | 11.2% |
|
|
| PinInput | 3,734 | 5.3% |
|
|
| async boundaries | 3,151 | 4.5% |
|
|
| Navbar | 2,419 | 3.5% |
|
|
| splitters | 2,083 | 3.0% |
|
|
| roving focus | 1,646 | 2.3% |
|
|
| modal dialogs | 1,611 | 2.3% |
|
|
| csr fetch | 1,547 | 2.2% |
|
|
| anchored overlays | 1,419 | 2.0% |
|
|
| scrollspy | 1,373 | 2.0% |
|
|
| preferences | 1,045 | 1.5% |
|
|
| toast | 534 | 0.8% |
|
|
|
|
Component-specific controllers (everything except async boundaries and csr
|
|
fetch, which are framework features) total **23,722 minified / 6,660 gzipped —
|
|
30.6% of what a visitor downloads.** The irreducible core is 46,379 minified /
|
|
15,076 gzipped: the expression engine, the scope and reactivity core, and loop
|
|
diffing.
|
|
|
|
After the split, the core runtime is **48,124 minified bytes** and the
|
|
on-demand controller asset is **24,027 minified bytes**. `/` requests no
|
|
controller asset.
|
|
|
|
#### How much of it a page actually used before the split
|
|
|
|
Measured against the example app by checking which controller markers appear in
|
|
the served HTML:
|
|
|
|
| Page | Controllers used |
|
|
| ------------- | ------------------ |
|
|
| `/` | **0 of 10** |
|
|
| `/login` | **0 of 10** |
|
|
| `/layout` | 1 of 10 (splitter) |
|
|
| `/navigation` | 5 of 10 |
|
|
|
|
A typical page downloads and parses 6.6 kB gzipped of controller code it never
|
|
executes. The Select controller — the single largest item at 7,852 bytes — is
|
|
used by none of the pages above.
|
|
|
|
**Change.** Split the component controllers out of the core runtime and load
|
|
them on demand, keyed on the marker attribute that already gates each one
|
|
(`data-wrn-select`, `data-wrn-splitter`, `data-wrn-scrollspy` and so on). The
|
|
gating logic exists; only the loading boundary is missing. Keep the core
|
|
runtime as one immutable-cached file.
|
|
|
|
**How to test.** Assert the core bundle size, and per page assert that a
|
|
controller chunk is requested only when its marker is present:
|
|
|
|
```bash
|
|
bun run scripts/lib/measure-runtime-size.ts # core must stay under budget
|
|
```
|
|
|
|
Plus a browser check on `/`: zero controller chunks requested.
|
|
|
|
#### Generated client modules — FIXED
|
|
|
|
Before remediation, `/navigation` measured:
|
|
|
|
- **490,212 bytes decoded** across 11 client modules, **21,026 transferred** —
|
|
a 23:1 compression ratio.
|
|
- The largest single module is **269,117 bytes** decoded, of which **89.8% is
|
|
duplicated lines**.
|
|
- One line appears **162 times**: `brand = context.state.brand; topLinks =
|
|
context.state.topLinks; ...` — the full state-restore prologue.
|
|
|
|
After hoisting and closure deduplication, the page is **101,253 decoded bytes**;
|
|
the largest module is **51,946 bytes** with **2.4% duplicated lines**.
|
|
|
|
Gzip hides this on the wire, but **parse and compile cost scales with decoded
|
|
bytes, not transferred bytes**. Half a megabyte of JavaScript is parsed to run
|
|
one page.
|
|
|
|
**Cause.** `packages/compiler/src/client-codegen.ts:228-264` inlines the state
|
|
sync into _every peer-function alias, in every client function_. Each alias
|
|
emits `syncStateToContext` once and `syncStateFromContext` three times — the
|
|
catch path, the promise `finally`, and the synchronous path. The output is
|
|
O(functions x peers x state variables). With ~19 state variables and 81 peer
|
|
aliases in that module, that is several thousand generated assignments.
|
|
|
|
**Change.** Hoist the sync out of the per-alias wrapper. The cheapest version
|
|
with no change to how bodies are written: emit **one** pair of closures per
|
|
client function and have every peer alias call them, instead of inlining the
|
|
sync per alias:
|
|
|
|
```js
|
|
const __flush = () => {
|
|
context.state.brand = brand; /* ... */
|
|
};
|
|
const __restore = () => {
|
|
brand = context.state.brand; /* ... */
|
|
};
|
|
const __peer =
|
|
(name) =>
|
|
(...args) => {
|
|
__flush();
|
|
let r;
|
|
try {
|
|
r = context.functions[name](...args);
|
|
} catch (e) {
|
|
__restore();
|
|
throw e;
|
|
}
|
|
if (r && typeof r.then === "function") return Promise.resolve(r).finally(__restore);
|
|
__restore();
|
|
return r;
|
|
};
|
|
const doThing = __peer("doThing");
|
|
```
|
|
|
|
That removes the peer multiplier — the dominant factor — and takes the 81
|
|
copies down to roughly one per function. It is a codegen change only, with no
|
|
change to semantics or to how anyone writes a component.
|
|
|
|
A larger follow-up, if the first is not enough: keep state in a single object
|
|
and rewrite state identifiers in the body to reference it, which removes the
|
|
per-variable multiplier as well. That one needs the body transform and should
|
|
be measured before it is attempted.
|
|
|
|
**How to test.** Pin decoded size, because gzip hides regressions here:
|
|
|
|
```bash
|
|
bun run build
|
|
# assert the largest generated client module is under budget, DECODED not gzipped
|
|
```
|
|
|
|
Add the ratio itself as a signal: any module compressing better than about 10:1
|
|
is duplicating itself and should fail the check. Existing behaviour is covered
|
|
by the original 1,427-test suite; this is purely a size
|
|
assertion on top.
|
|
|
|
### 4.7 Destructive generator — REMOVED
|
|
|
|
**Original issue.** The script failed partway through with
|
|
`TypeError: factories[entry.category] is not a function`
|
|
(`scripts/generate-ui-complete-catalog.mjs:156`) — but not before it has already
|
|
started writing. It **overwrote real components with bare scaffolds and deleted
|
|
others**, then crashed, leaving the library in a wrecked state. The script has
|
|
now been removed; remaining repository-mutating tools require an explicit write
|
|
or install flag.
|
|
|
|
**Evidence.** Running it on 2026-08-09 rewrote Accordion, alert, Badge,
|
|
AvatarGroup, ToggleCount and LayoutSplitter down to ~15-line stubs, deleted 24
|
|
component files, and renamed `Card`, `Container`, `Divider` and `Grid` to
|
|
lowercase — 113 files changed in total. Nothing in `package.json` references it,
|
|
so no gate runs it and no gate would have caught the damage.
|
|
|
|
**Why it is worse than it looks.** The rename is the dangerous part. Windows is
|
|
case-insensitive, so after `git checkout -- .` the tree reported **clean** while
|
|
four components were still misnamed on disk. Only a test failure exposed it. On
|
|
a case-sensitive filesystem the same script produces duplicate files instead.
|
|
|
|
**Change.** Pick one:
|
|
|
|
- **Delete it.** `generate-ui-component-reference.mjs` is the maintained
|
|
generator, it is wired into `release:prepare`, and it works. If this script is
|
|
redundant, it is a loaded gun in the repo for no benefit.
|
|
- **Or fix and gate it**: make it write to a temp directory and swap atomically
|
|
only on success, so a mid-run crash cannot leave a partial library. Then add
|
|
it to a check so it cannot rot again.
|
|
|
|
Whichever is chosen, **no script that rewrites `packages/ui/components/` should
|
|
write in place.** Generate to a staging directory, validate, then move.
|
|
|
|
**Original related finding:** several other ungated scripts mutated
|
|
the repository or start servers when run — `install-captcha.mjs`, the
|
|
`validate-*.mjs` and `verify-*.mjs` families, and `benchmark-framework.mjs`.
|
|
Obsolete validators were removed, current validators were repaired, and useful
|
|
mutating scripts now require explicit flags. A
|
|
`scripts/` directory where running a file at random can scaffold apps, start
|
|
dev servers on ports 3000-3002 and rewrite the component library is a hazard to
|
|
anyone exploring the repo, human or otherwise.
|
|
|
|
**How to test.** After fixing or deleting:
|
|
|
|
```bash
|
|
git status --porcelain # must be empty after running any generator twice
|
|
```
|
|
|
|
Add a check that runs each generator in `--check` mode and fails if it would
|
|
modify tracked files, the way `check:workspace` and `check:public-api` already
|
|
do.
|
|
|
|
### 4.8 Filename casing — FIXED
|
|
|
|
**Issue.** Four components were tracked in git under lowercase names
|
|
(`card.wrn`, `container.wrn`, `divider.wrn`, `grid.wrn`) while existing on disk
|
|
under capitalised ones. Windows hid the discrepancy; `git status` reported clean.
|
|
|
|
**Why it matters.** `ui-redesign-contract.test.ts` reads the real directory and
|
|
expects `Card.wrn`. **On a fresh clone on Linux or in CI the files arrive
|
|
lowercase and that test fails** — a latent break that could not reproduce on a
|
|
Windows workstation.
|
|
|
|
**Change.** Done — the index now tracks the capitalised names, matching the
|
|
component each file declares (`component Card`, `component Container`, and so
|
|
on) and matching every other component in the library.
|
|
|
|
**How to test.**
|
|
|
|
```bash
|
|
git ls-files packages/ui/components/ | grep -iE '/(card|container|divider|grid)\.wrn'
|
|
```
|
|
|
|
must return the capitalised names. Better, set `git config core.ignorecase
|
|
false` locally so a future rename cannot hide again, and consider a check that
|
|
compares `git ls-files` against the on-disk listing byte for byte.
|
|
|
|
## 5. Historical order of work
|
|
|
|
This is the execution order used during remediation, retained as implementation
|
|
history. Every row is complete.
|
|
|
|
| # | Item | Why now |
|
|
| --- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| 0 | §4.7 neutralise the destructive generator | Safety, not improvement. Running one file in `scripts/` at random can shred the component library. Do this before anyone else touches the repo. |
|
|
| 1 | §2.1 dev-mode diagnostics | Changes the debugging economics of everything else. Every §1 bug would have been minutes instead of hours. |
|
|
| 2 | §2.3 reactive props | The biggest capability ceiling. Composition does not work without it. |
|
|
| 3 | §2.6 server-rendered i18n | Contained work; the core SSR claim currently fails on text. |
|
|
| 4 | §4.2 + §4.3 dev loop | Cheap, and it compounds across every task below. |
|
|
| 5 | §4.6 de-duplicate generated client modules | 490 kB decoded parsed per page, 90% of it duplicated. Codegen-only, no API impact. |
|
|
| 6 | §4.1 finish the CSS migration | 66 components, mechanical, takes ~26 kB off every page. |
|
|
| 7 | §4.6 split controllers out of the core runtime | 6.6 kB gzipped a typical page never executes. |
|
|
| 8 | §3.1 Group A: supersede the 5 duplicate scaffolds | 15 of the 22 dead outputs, and 5 fewer components to maintain. Needs a migration entry, not new code. |
|
|
| 9 | §3.2 scaffolds that need only their styles | Larger and cheaper than it looks; folds into item 6. |
|
|
| 10 | §3.1 Group B: build Chart, TreeView, Confetti, CopyMarkup | Real component work. Chart needs a rendering decision first. |
|
|
|
|
§2.2, §2.4 and §2.5 fold into item 1 as diagnostics first, then into item 2 as
|
|
model work.
|
|
|
|
**What I would resist: adding more components.** The library is 108 wide and
|
|
roughly 60% deep. Everything above is depth.
|
|
|
|
## 6. A note on strategy
|
|
|
|
47 packages before 1.0 is a large surface area. Each is a public API you have
|
|
committed to, a version to align and a migration note to write — the 0.8.6
|
|
release touched 117 files to change one behaviour. The gate makes that
|
|
tractable, but tractable is not free. Some packages (`native`, `mobile`,
|
|
`playground`, `mcp`, `graphql`) look like option value rather than load-bearing.
|
|
Consolidating them would buy speed on exactly the model gaps in §2 that
|
|
currently limit users.
|
|
|
|
This is a judgement call, not a defect, and it is yours to make.
|