6.9 KiB
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
- dimension: A31: per-item O(N²) derived
- rule: A31: per-item
$derivedcalling 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-226isRovingTargetreturns O(1)if (this.isChecked) return truethen defers to isFallbackTabStop, whose DOM walk at :154-158 is wrapped inuntrack(() => 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)