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/toggle-group.md

36 lines
6.9 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: toggle-group
audit-version: 1
audited-at: 2026-06-26
scope:
method: adversarially-verified workflow; HIGH lead-verified. B6 ground-truth: checkbox/toggle/switch commit-toggle = sequence post (lag-fixed); radio-group/tabs/accordion/stepper set state at call-site (pre OK); toggle-group + checkbox-group carry the A31 .includes pattern (SYS-7); slider has no gesture-layer A6 leak.
provider: G:\dev\svelte\vicen\src\uix\soma\components\toggle-group\toggle-group-provider.svelte.ts
sequence-audit: Toggle-group is NOT a commit-style toggle-in-handler control like Checkbox/Toggle. It is a ROVING SELECTION GROUP (like RadioGroup/Tabs) where state is set at the call-site (toggleItem called from handler, toggleItem sets state immediately). Morfo correctly declares sequence='post'. The 'post' seque
## Summary
Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 1 · LOW 0.
## Findings
### MEDIUM: A31: per-item `$derived` calling a provider method reading global state (.includes) = O(N² — toggle-group-001 <!-- id: toggle-group-001 -->
- dimension: A31: per-item O(N²) derived
- rule: A31: per-item `$derived` calling a provider method reading global state (.includes) = O(N²) (SYS-7 — radio-group/toggle-group items are candidates).
- location: toggle-group-provider.svelte.ts:154–170 (isRovingTarget derived)
- evidence: Line 154–170: `readonly isRovingTarget = $derived.by(() => { const items = this.provider.getItems(); ... const firstPressed = items.find((item) => { const value = item.getAttribute('data-value'); return value ? this.provider.isItemPressed(value) : false; }); ... })`. Each item's isRovingTarget performs a linear find() over all items to locate the first pressed item. With N items, this is called N times, yielding O(N²) total calls to isItemPressed. The .find() loop on line 162–165 scans the entire items array per item instance.
- impact: On groups with many items (20+), roving tabindex computation becomes a performance bottleneck. The search is linear but repeated N times instead of cached at provider level. For a 50-item group, ~2500 iterations; for 100 items, ~10,000.
- proposed-fix: Cache the roving-target calculation at the provider level (memoized derived or a dedicated provider method returning the pressed/first-item in a single pass). Each item then simply checks `provider.rovingTargetValue === this.opts.value.current` (O(1) per item).
- verify: [confirmed] CONFIRMED A31/SYS-7. toggle-group-provider.svelte.ts:154-170 `isRovingTarget = $derived.by(() => { const items = this.provider.getItems(); ... const firstPressed = items.find((item) => { const value = item.getAttribute('data-value'); return value ? this.provider.isItemPressed(value) : false }) ... })`. Each item runs getItems() (full querySelectorAll at :97-98) AND a .find() over all items, each calling isItemPressed -> opts.value.current.includes(value) (:64-66). That is O(N) per item across N items = O(N^2), and the .find walk creates a reactive read of opts.value per item. DIVERGENCE evidence: the doctrinal sibling radio-group solves exactly this — radio-group-provider.svelte.ts:223-226 `isRovingTarget` returns O(1) `if (this.isChecked) return true` then defers to isFallbackTabStop, whose DOM walk at :154-158 is wrapped in `untrack(() => this.getItems()[0] === el)` with a comment naming '(A14 + A31)' and the A35 cascade. toggle-group has neither the O(1) pressed short-circuit nor untrack. Severity MEDIUM is correct (perf/reactivity debt, not a correctness/a11y break).
- fix-status: open
## No-findings dimensions
A: Contract (morfo 'as const satisfies Morfo') - CLEAN (line 101). Parts registered via runtime.part('provider', 'item') exist in morfo and vice versa (all 2-of-3 rules met). data-{c}/-{part} naming correct. Structural identity confirmed: toggle-group items declare data-toggle in morfo (line 77), reusing Toggle recipe. No aria/data mismatch. No scope-drift — morfo declares scope: ['soma', 'sema', 'eidos'] (line 10). No inert events — commit-toggle carries valid sequence: 'post' (line 34)., B: Behavior (soma) - SEQUENCE CLEAN (line 34: 'post'). State set in handler (toggleItem called from onclick at line 174, sets state at line 89). Matches post sequence — no lag risk. ROVING TABINDEX CLEAN (line 223: correctly derives tabindex=0 for first pressed item or first enabled item; all other items get -1). Only ONE item ever gets tabindex=0. RTL CLEAN (line 190: uses getDirectionalKeys(dir, orientation), no manual key mapping). No gesture/observer used (not needed). No setTimeout/setInterval/ResizeObserver. No Soma imports of Eidos (all imports clean: context, createAttrs, morfo, Soma, SomaRuntime)., C: DOM-selector - CLEAN. querySelector calls (line 97) use framework attrs only. No consumer value interpolation., D: Frontier (soma-eidos contract) - CLEAN. Soma does NOT import eidos (verified line 1–10). data-variant/color/size/attached/block are eidos VISUAL attrs on Provider (toggle-group.svelte:58–62) — per doctrine, not violations. syncAttrs:true on runtime.part (line 60) syncs only morfo-declared attrs (data-orientation, data-disabled); visual attrs pass through props without double-write or divergence., E: TSC composition - CLEAN. Per doctrine, toggle-group uses sanctioned cross-recipe composition pattern (lines 4104–4120 in base.ts: toggle-group.composition targets toggle-group-item and sets --toggle-palette-* via group data-color). Items receive data-variant/size from eidos context (toggle-group-item.svelte:27–28) so Toggle recipe rules fire on item's [data-toggle][data-variant][data-size]. No invented cross-recipe pattern., F: Tests - CLEAN. Provider test exists (toggle-group-provider.svelte.test.ts). jsdom environment, tests cover single/multiple modes, roving tabindex, keyboard navigation (roving nav with Home/End, directional keys). Interaction-heavy keyboard paths (lines 203–242) are tested., G: Redundancy - CLEAN. Roving logic is unique to this component (not duplicated). Structural identity is the canonical pattern (not redundancy). No re-implemented selection logic.
## Theming facts (E-bis)
- magic z-index: 1 (line 88, local stacking in attached mode) | 2 (line 94, local stacking for focus ring)
- magic literals: none
- undeclared parts: none
- roles clean: true · variants clean: true
## Tests (F)
- exists: true · env: jsdom (@vitest-environment jsdom)
- covers: Single selection toggle (toggleItem); Multiple mode selection; Deselectable=false (required selection); Roving tabindex (first pressed or first enabled item gets tabindex=0); Keyboard navigation (ArrowRight, ArrowLeft, Home, End); Focus skipping disabled items; Multiple selection toggle/untoggle
- untested: RTL keyboard navigation (ArrowRight vs ArrowLeft swap in RTL context); Vertical orientation keyboard nav (ArrowUp vs ArrowDown); Loop=false boundary behavior (edge wrapping); Disabled cascade from provider to items; Event fallbackTarget cascade selector match (runtime.trigger commit-toggle)

Powered by TurnKey Linux.