|
|
# 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:
|
|
|
|
|
|
1. `npm run check` → 0 new errors.
|
|
|
2. Run the phase's vitest scope (named per phase).
|
|
|
3. 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).
|
|
|
4. Update the finding's `fix-status:` in `audit/components/{kebab}.md` (`open` → `fixed`), then re-run
|
|
|
`MODE=reeval` later 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))` (import
|
|
|
`SvelteSet` from `svelte/reactivity`). Trees also add `expandedSet`.
|
|
|
- Rewrite the provider method to `return this.selectedSet.has(value)` (O(1)). The per-item `$derived` is unchanged.
|
|
|
- The providers already prove the shape — `rovingTargetEl` is 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.
|
|
|
|
|
|
1. 🔴 **dialog-001** — Content double-writes `role`/`aria-roledescription` (a11y race for `alertdialog`).
|
|
|
`dialog-provider:391-397` registers Content `syncAttrs:true`, then `:460-463` sets `role:variant` +
|
|
|
`aria-roledescription:undefined`. **Fix:** make `role` morfo-expressible (bind a `propRef('variant')` /
|
|
|
stateRef so the morfo owns the variant role) OR drop `syncAttrs` for Content and supply role/aria via
|
|
|
`renderProps()` + a single override. Don't mix syncAttrs + manual same-attr writes. Verify alertdialog role
|
|
|
is stable across ticks.
|
|
|
2. 🔴 **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).
|
|
|
3. 🔴 **combobox-001** — virtual+real focus mix (A17). Morfo declares `aria-activedescendant`; provider calls
|
|
|
`dom.focus(items[0])` (:392/408/684). **Fix:** commit to virtual focus — keep DOM focus on the input, drive
|
|
|
selection via `highlightedId`/`data-highlighted` + `scrollIntoView`, never `dom.focus(item)`. (Higher effort;
|
|
|
needs a client/Playwright test asserting `activeElement` stays on the input.)
|
|
|
4. 🔴 **select-001** — `scrollSelectedIntoView` (:244-246) interpolates the user value without `CSS.escape`.
|
|
|
**Fix:** reuse the safe `resolveListItemEl` helper (it already `CSS.escape`s), or wrap in `CSS.escape`.
|
|
|
5. 🔴 **select-002** — morfo `focus.trap:true`/`initial:'first-focusable'` but provider implements virtual focus
|
|
|
(no FocusScope). **Fix:** change the morfo `focus` to reflect virtual focus (trap:false, keep return:'trigger');
|
|
|
cross-check combobox.
|
|
|
6. 🔴 **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 pure `nextIndex` helper (EX-1),
|
|
|
delete/repair the content route. (Pairs with combobox-001.)
|
|
|
7. 🔴 **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).
|
|
|
8. 🔴 **navigation-menu-006** — undisposed `$effect.root` (:523-527) leaks per trigger. **Fix:** replace with a
|
|
|
bare `$effect` in the constructor (auto-disposed), matching the root provider's existing pattern.
|
|
|
9. 🔴 **collapsible-NEW-001** — `collapse` is `sequence:'pre'` + sets state in the handler → ~240ms lag (the
|
|
|
Checkbox-244ms mechanism). **Fix:** change `collapse` to `sequence:'post'` (collapsible.ts:48) OR set state at
|
|
|
the call-site in `toggle()` before `runtime.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 on `data-state='closed'`.
|
|
|
10. 🔴 **breadcrumb-001** — Item `<li>` declares `archetype:'item'` (pulls cursor:pointer/hover). **Fix:** remove
|
|
|
`archetype:'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 an `eidos/components/{c}/` dir + recipe (~33 one-line edits).
|
|
|
- (b) Document that eidos is implicit and `scope` lists 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`-wrapped `inputId` registration 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 to `SvelteMap`/`SvelteSet` (pairs with Phase 1 — same providers). 4 sites.
|
|
|
- **SYS-5 double-write**: drawer-007 (Content manual `role`/`aria-modal` while morfo declares them), popover-001
|
|
|
(Close re-declares `type`/`aria-label`). Drop the manual re-sets; let `syncAttrs`/`renderProps` own them.
|
|
|
- **SYS-2 magic-z (non-overlay)** + **THEME-SYS-2 magic-opacity**: dropdown-menu/context-menu `item-disabled-opacity
|
|
|
0.55` → `var(--opacity-disabled)`; calendar `day-outside-opacity 0.62` → snap to `--opacity-*` (or one named
|
|
|
`--opacity-scrim` for the recurring 0.62/62%). select `item-description-font-size '0.85em'` → tokenize.
|
|
|
- **SYS-INERT** (5 pickers): either fire `open`/`commit-reset` events (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.warn` assertions in the segmented fields (the harness already stubs `warn`), 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)
|
|
|
|
|
|
1. **Phase 1 (SYS-7)** — biggest correctness win, mechanical. (½ day)
|
|
|
2. **alert-dialog-001 + VG-8** — 1-line fix + the grep that prevents it. (15 min)
|
|
|
3. **breadcrumb-001, navigation-menu-006, dialog-002, select-001** — small, self-contained HIGHs. (½ day)
|
|
|
4. **dialog-001, collapsible-NEW-001** — need a browser measurement; do with the dev server up.
|
|
|
5. **combobox-001 + select-002/003** — the focus-strategy refactor (highest effort HIGH); schedule its own block.
|
|
|
6. Phases 3–6 + remaining VGs as follow-up.
|