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

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.

# 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: fixed (212624e0)
## 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.