12 KiB
UIX Audit — Fix Execution Plan (ready to run)
plan-version: 1 prepared: 2026-06-26 source: this audit (124/124 components, 0 CRITICAL · 18 HIGH · 129 MEDIUM · 60 LOW) branch: active-uix status: NOT STARTED — read-only audit done; this is the authorized fix track.
How to use this plan
Execute phase by phase, top to bottom (ordered by leverage). After EACH phase:
npm run check→ 0 new errors.- Run the phase's vitest scope (named per phase).
- For HIGH UI/behavior fixes, a browser check (the audit was read-only; some HIGHs — e.g. collapsible sequence-lag — need a frame-by-frame measurement to confirm the fix, like the original Checkbox fix).
- Update the finding's
fix-status:inaudit/components/{kebab}.md(open→fixed), then re-runMODE=reevallater to confirm.
Hard exclusions (every phase): never touch soma/components/words/**, eidos/components/palabras/**,
or chronos — active dev tracks, excluded from the audit. When committing, stage explicit paths.
Severity legend: 🔴 HIGH · 🟠 MEDIUM · ⚪ LOW. Each item links its finding id.
Phase 1 — SYS-7: the A31 O(N²) selection cluster ⭐ HIGHEST LEVERAGE
6 HIGH + 3 MEDIUM, one mechanical pattern, ~2 lines per component. This is the single best ROI in the audit.
Pattern: a per-item $derived calls provider.isSelected(v)/isExpanded(v)/isItemPressed(v)/isItemChecked(v)
which does array.includes(v) (O(N)). With N items re-deriving on every mutation → O(N²) ("hangs at 30+").
Fix (apply to each provider):
- Add a provider-level
readonly selectedSet = $derived(new SvelteSet(this.opts.value.current))(importSvelteSetfromsvelte/reactivity). Trees also addexpandedSet. - Rewrite the provider method to
return this.selectedSet.has(value)(O(1)). The per-item$derivedis unchanged. - The providers already prove the shape —
rovingTargetElis lifted exactly this way (with a comment citing "avoids an O(N²) cascade"). Mirror it.
| 🔴/🟠 | Component | provider method | id |
|---|---|---|---|
| 🔴 | listbox | isSelected @ listbox-provider:157-158 (item @ :413) |
listbox-001 |
| 🔴 | grid-list | isSelected :493 + per-checkbox isChecked :151 |
grid-list-001/002 |
| 🔴 | tree-view | isSelected/isExpanded :96-98/:145-147 (items :351/:353/:569) |
tree-view-002 |
| 🔴 | tree-grid | isSelected/isExpanded :148/:189 (rows :578-579) |
tree-grid-001 |
| 🔴 | tag-group | isSelected :115-117 (item :321 + link :410) |
tag-group-001 |
| 🟠 | toggle-group | isItemPressed :72 (item :146) |
toggle-group-001 |
| 🟠 | checkbox | CheckboxGroupProvider.isItemChecked :313-315 |
checkbox-001 |
| 🟠 | select | isSelected :158 (single-select, low-risk but harmless) |
select-007 |
| 🟠 | combobox | isSelected :182 (single-select, low-risk) |
combobox-004 |
Optional extraction (EX-3): a 3-line shared liftedSet(() => string[]) helper, then all 9 consume it.
Decide inline-vs-helper at execution (≥2 consumers justify the helper).
Verify: npx vitest run src/uix/soma/components/{listbox,grid-list,tree-view,tree-grid,tag-group,toggle-group,checkbox,select,combobox} + a 50-item browser smoke on listbox/tree.
Phase 2 — the isolated HIGH findings (12)
Each is well-grounded and self-contained. Order within the phase is by risk.
- 🔴 dialog-001 — Content double-writes
role/aria-roledescription(a11y race foralertdialog).dialog-provider:391-397registers ContentsyncAttrs:true, then:460-463setsrole:variant+aria-roledescription:undefined. Fix: makerolemorfo-expressible (bind apropRef('variant')/ stateRef so the morfo owns the variant role) OR dropsyncAttrsfor Content and supply role/aria viarenderProps()+ a single override. Don't mix syncAttrs + manual same-attr writes. Verify alertdialog role is stable across ticks. - 🔴 dialog-002 — dead/conflicting
[data-dialog-trigger]CSS envelope after the trigger migrated to a composed<Button>. Fix: delete the[data-dialog-trigger]envelope (dialog.css:22-54) + its--dialog-trigger-*tokens (base.ts:727); Button owns the chrome (the Close recipe is the model). - 🔴 combobox-001 — virtual+real focus mix (A17). Morfo declares
aria-activedescendant; provider callsdom.focus(items[0])(:392/408/684). Fix: commit to virtual focus — keep DOM focus on the input, drive selection viahighlightedId/data-highlighted+scrollIntoView, neverdom.focus(item). (Higher effort; needs a client/Playwright test assertingactiveElementstays on the input.) - 🔴 select-001 —
scrollSelectedIntoView(:244-246) interpolates the user value withoutCSS.escape. Fix: reuse the saferesolveListItemElhelper (it alreadyCSS.escapes), or wrap inCSS.escape. - 🔴 select-002 — morfo
focus.trap:true/initial:'first-focusable'but provider implements virtual focus (no FocusScope). Fix: change the morfofocusto reflect virtual focus (trap:false, keep return:'trigger'); cross-check combobox. - 🔴 select-003 — two keyboard routes duplicate index math; the content route uses real-focus (
activeElement) in a virtual-focus component + the loop off-by (n-2). Fix: extract the purenextIndexhelper (EX-1), delete/repair the content route. (Pairs with combobox-001.) - 🔴 alert-dialog-001 —
export const alertDialogMorfo: Morfo = {uses: Morfo. Fix (1 line):export const alertDialogMorfo = { … } as const satisfies Morfo;(alert-dialog.ts:4 + the closer). - 🔴 navigation-menu-006 — undisposed
$effect.root(:523-527) leaks per trigger. Fix: replace with a bare$effectin the constructor (auto-disposed), matching the root provider's existing pattern. - 🔴 collapsible-NEW-001 —
collapseissequence:'pre'+ sets state in the handler → ~240ms lag (the Checkbox-244ms mechanism). Fix: changecollapsetosequence:'post'(collapsible.ts:48) OR set state at the call-site intoggle()beforeruntime.trigger. Confirm with a frame-by-frame browser measurement before+after (the original Checkbox lag was measured). If a visible-during-exit cue is wanted, drive it from CSS keyed ondata-state='closed'. - 🔴 breadcrumb-001 — Item
<li>declaresarchetype:'item'(pulls cursor:pointer/hover). Fix: removearchetype:'item'from the Item part (breadcrumb.ts:50) — Timeline shows the correct pattern (a display<li>omits it). Verify the breadcrumb li no longer shows pointer/hover.
Verify: per-component vitest + a browser pass on dialog (alertdialog role), combobox/select (focus), collapsible (collapse latency), breadcrumb (cursor).
Phase 3 — SYS-1 scope-drift (one decision, then mechanical) 🟠 ~33 components
First decide the semantics (this is a project call, not a per-component fix): does morfo scope enumerate
authored layers or all consuming layers? Two options:
- (a) Add
'eidos'to every morfo whose component has aneidos/components/{c}/dir + recipe (~33 one-line edits). - (b) Document that eidos is implicit and
scopelists only soma/sema (then SYS-1 is closed by doc, 0 code edits).
Recommend (a) for explicitness (dropdown-menu/accordion/tabs/checkbox/radio-group/splitter already do it). The
list of drifted morfos is in THEMING_COHERENCE.md / each component report's scopeDrift: true.
Verify: npm run morfo:check + npm run check.
Phase 4 — THEME-SYS-1: overlay z-index ladder 🟠 ~10 recipes, one sweep
A canonical STATIC_Z_INDEX scale EXISTS (lib/primitives/static.ts:291: base/raised/sticky/dropdown/popover/
tooltip/modal/toast) and is consumed by the depth planes + menu-dial — but ~10 overlay recipes hardcode an ad-hoc
60–99/1200 scale. Fix (precise mapping, base.ts):
- dropdown-menu/context-menu
content-z→var(--z-index-dropdown) - select/combobox/popover/link-preview content →
var(--z-index-popover) - tooltip →
var(--z-index-tooltip) - dialog/drawer overlay →
var(--z-index-modal)(content = +1 or a new--z-index-modal-content) - toast
toaster-z→var(--z-index-toast)
Verify the rendered stacking order is preserved (60→1200 today, must stay ordered after mapping to 0..900). Verify: browser — overlay stacking (popover under dialog under toast).
Phase 5 — the smaller systemic patterns 🟠
- SYS-A30-EFFECT (date/time/color-field): replace the
$effect-wrappedinputIdregistration with a direct constructor guard, matching NumberField (number-field-provider:585-587). 3 components. - SYS-A33-CLONE (listbox, grid-list, tree-grid, tag-group): migrate the
new Map(this.x)clone-and-reassign registries toSvelteMap/SvelteSet(pairs with Phase 1 — same providers). 4 sites. - SYS-5 double-write: drawer-007 (Content manual
role/aria-modalwhile morfo declares them), popover-001 (Close re-declarestype/aria-label). Drop the manual re-sets; letsyncAttrs/renderPropsown them. - SYS-2 magic-z (non-overlay) + THEME-SYS-2 magic-opacity: dropdown-menu/context-menu
item-disabled-opacity 0.55→var(--opacity-disabled); calendarday-outside-opacity 0.62→ snap to--opacity-*(or one named--opacity-scrimfor the recurring 0.62/62%). selectitem-description-font-size '0.85em'→ tokenize. - SYS-INERT (5 pickers): either fire
open/commit-resetevents (perceptual signal) or drop them from the morfo. Decision per picker.
Phase 6 — MEDIUM/LOW hygiene + test gaps 🟠⚪
- SYS-3 test gaps (pervasive): add the cheap, high-value tests first — the A24 readonly-without-value
logger.warnassertions in the segmented fields (the harness already stubswarn), and provider keyboard-route tests for the overlays. Add client/Playwright tests for the focus-bearing overlays (select/combobox/dialog). - Calendar Home/End (calendar-006 / range-calendar-002): decide APG-week vs month, then align the morfo action label and the provider impl in BOTH calendar + range-calendar.
- The remaining LOW magic-literals (em-padding on code/kbd/mark, scroll-frames, radio-cards easing, etc.) — batch them when convenient; none are blocking.
- ⚠️ calendar / range-calendar are MID-REFACTOR (uncommitted view-switch work) — coordinate with that track before touching their eidos; their findings reflect in-flight state.
Parallel track — VALIDATOR EXTENSIONS (prevent the class) — see VALIDATOR_GAPS.md
Highest leverage of all: fixing the validator stops the whole class recurring. Do the cheap ones first.
| VG | What it catches | Effort |
|---|---|---|
| VG-8 ⭐ | : Morfo instead of as const satisfies Morfo (a grep) — would've caught alert-dialog-001 |
trivial |
| VG-3 | syncAttrs:true + props re-setting a morfo attr (dialog-001/popover-001 class) |
med-high |
| VG-4 | aria-activedescendant morfo + dom.focus(item) (combobox-001 class) |
medium |
| VG-12 | sequence:'pre' event whose handler sets bound state (collapsible-NEW-001 class) |
medium |
| VG-10 | morfo event never reached by runtime.trigger (SYS-INERT) |
medium |
| VG-1/2/6/9/11 | em/% font-size · magic z/opacity/tracking · translationRef-key · $effect.root/$effect-id |
low-med |
Suggested day-1 order (if time-boxed)
- Phase 1 (SYS-7) — biggest correctness win, mechanical. (½ day)
- alert-dialog-001 + VG-8 — 1-line fix + the grep that prevents it. (15 min)
- breadcrumb-001, navigation-menu-006, dialog-002, select-001 — small, self-contained HIGHs. (½ day)
- dialog-001, collapsible-NEW-001 — need a browser measurement; do with the dev server up.
- combobox-001 + select-002/003 — the focus-strategy refactor (highest effort HIGH); schedule its own block.
- Phases 3–6 + remaining VGs as follow-up.