You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/audit/FIX_PLAN.md

184 lines
12 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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.

Powered by TurnKey Linux.