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/components/listbox.md

46 lines
7.0 KiB

# Audit: listbox
audit-version: 1
audited-at: 2026-06-26
scope: (SCOPE-DRIFT → SYS-1)
method: adversarially-verified workflow (analyze → refute); HIGH lead-verified by direct read of the cited code. B5 ground-truth: the A31 O(N²) isSelected/isExpanded (.includes from a per-item $derived) is confirmed across listbox/grid-list/tree-view/tree-grid/tag-group (SYS-7); rovingTargetEl is correctly LIFTED everywhere (not A31); virtual-* use SvelteMap (A33-clean).
provider: /g/dev/svelte/vicen/src/uix/soma/components/listbox
reactivity (A31/A33/A35): FOUND: A31 O(N²) latent bug at line 413. Per-item isSelected $derived calls provider.isSelected() which does array.includes() — triggers on every provider.value change. Multi-select mode: N items × O(N) array search = O(N²). Roving-target was correctly lifted (line 148-153) but isSelected was not. Single-select unaffected (selection ≤ 1). Tests max 3 items, so bug not caught. HIGH severity.
## Summary
Counts (post-verification): CRITICAL 0 · HIGH 1 · MEDIUM 1 · LOW 0.
## Findings
### HIGH: A31: Per-item $derived reading global provider state via .includes() O(N) method — listbox-001 <!-- id: listbox-001 -->
- dimension: B (Reactivity - A31)
- rule: A31: Per-item $derived reading global provider state via .includes() O(N) method
- location: src/uix/soma/components/listbox/listbox-provider.svelte.ts:413, :157-158
- evidence: Line 413: `readonly isSelected = $derived.by(() => this.provider.isSelected(this.opts.value.current));` — per-item derived calling Line 157-158: `isSelected(value: string): boolean { return this.opts.value.current.includes(value); }`. When provider.opts.value changes, ALL N items' derived re-run, each performing O(N) array.includes() search. With multi-select, N items × O(N) selection-array = O(N²).
- impact: Multi-select listboxes with 30+ items + selections will experience quadratic re-derivation cost. Single-select mode is unaffected (selection array ≤ 1 item). Performance degradation not caught by existing tests which max 3 items.
- proposed-fix: Lift isSelected as a provider-level $derived returning a Set<string> keyed by value, similar to rovingTargetEl pattern (line 148-153). Items would then do O(1) .has() checks instead of O(N) .includes() calls. Alternatively, compute a derived Set at provider level and expose it for item consumption.
- verify: [confirmed] CONFIRMED HIGH. Re-read listbox-provider.svelte.ts. Line 413 (per-item): `readonly isSelected = $derived.by(() => this.provider.isSelected(this.opts.value.current));`. Line 157-158 (provider method): `isSelected(value: string): boolean { return this.opts.value.current.includes(value); }` — reads GLOBAL `opts.value` and does O(value.length) Array.includes(). This is the textbook A31 pattern: every `value` mutation invalidates all N items' `isSelected` derivations, each re-running an O(K) array search (K = selection size). Multi-select: N items x O(N) = O(N²). Single-select: K<=1 so effectively O(N), unaffected. The asymmetry is the proof this is a genuine miss, not a design choice: `rovingTargetEl` (lines 148-153) was deliberately LIFTED to ONE provider-level $derived building `new Set(this.opts.value.current)` with an explicit comment (141-146) documenting 'avoids an O(N²) cascade' — yet the sibling `isSelected` reactive path was left calling the O(N) provider method per-item. Matches the documented Listbox-rovingTarget incident exactly (same provider). Tests max out at 3 items (alpha/beta/gamma, lines 167-212) with no large-N/perf test, so the hazard is unguarded. Fix per candidate is correct; cleanest form: provider-level `readonly selectedSet = $derived(new Set(this.opts.value.current))` consulted via `.has()` from `isSelected()`, mirroring the existing line-151 Set.
- fix-status: fixed (92f988e7)
### MEDIUM: SYS-1: Scope drift — eidos recipe directory exists but morfo.scope does not declare 'eidos — listbox-002 <!-- id: listbox-002 -->
- dimension: A (Contract)
- rule: SYS-1: Scope drift — eidos recipe directory exists but morfo.scope does not declare 'eidos'
- location: src/uix/morfo/components/listbox.ts:7 declares scope: ['soma', 'sema']; eidos components exist at src/uix/eidos/components/listbox/
- evidence: Morfo line 7: `scope: ['soma', 'sema']` omits 'eidos'. Directory structure confirms eidos layer exists with full implementation: listbox.svelte, listbox-item.svelte, listbox-group.svelte, listbox-item-indicator.svelte, listbox-group-label.svelte, listbox.css, types.ts, index.ts.
- impact: Morfo contract validator may not check eidos layer compliance; documentation/tooling may omit eidos layer from automated coverage. Scope mismatch creates maintenance liability if eidos changes are made without realizing morfo is unaware.
- proposed-fix: Add 'eidos' to morfo.scope array: `scope: ['soma', 'sema', 'eidos']`
- verify: [confirmed] CONFIRMED MEDIUM (canonical SYS-1). Morfo line 7: `scope: ['soma', 'sema']` (file ends correctly with `} as const satisfies Morfo;` — contract annotation is fine, not a separate finding). The eidos layer materially exists at src/uix/eidos/components/listbox/: listbox.css (6574 bytes), listbox.svelte, listbox-item.svelte, listbox-group.svelte, listbox-group-label.svelte, listbox-item-indicator.svelte, types.ts, index.ts — a full implementation. Per SYS-1, eidos-recipe-dir-exists + morfo-scope-omits-'eidos' = MEDIUM scope-drift, and that is exactly the case here. Context: this is systemic — only 63/128 morfo components declare 'eidos' in scope despite 130 eidos dirs; major interactive siblings (toggle/dialog/select/combobox/popover) all omit it too. Minor correction to candidate evidence: there is NO `listbox:` recipe key in lib/recipes/base.ts (grep returns nothing) — the CSS is foundation-only, not recipe-driven. But SYS-1 triggers on the eidos DIR existence, which is confirmed, so the finding stands at MEDIUM. Fix: add 'eidos' to the scope array.
- fix-status: open
## No-findings dimensions
C (DOM-selector - CSS.escape use safe on framework numeric IDs), D (Frontier - no soma→eidos imports, no double-write divergence), E (TSC theming - all CSS vars reference canonical --color-* / --space-* / --radius-* / --opacity-* / --font-size-*), G (Redundancy - keyboard nav index math consistent, no duplication with divergence risk)
## Theming facts (E-bis)
- magic z-index: none
- magic literals: 0.875rem (listbox.css:185 item-indicator size - should reference --icon-size-sm or similar scale)
- undeclared parts: none
- roles clean: true · variants clean: true
## Tests (F)
- exists: true · env: jsdom (@vitest-environment jsdom)
- covers: Root accessibility props + focus state; Item selection + toggle + roving tabindex; Keyboard navigation (Arrow Down/Up, Home, End, Ctrl+A typeahead); Group + GroupLabel context wiring; ItemIndicator presence toggling
- untested: Large-scale multi-select (30+ items) performance under rapid selection changes — A31 O(N²) risk not validated; Disabled item filtering in roving target (getItems() correctly scoped to enabled items per line 137-138); Horizontal orientation + RTL keyboard direction (declared in morfo, not tested)

Powered by TurnKey Linux.