# Audit: onion-menu audit-version: 1 audited-at: 2026-06-26 scope: ['soma', 'sema', 'eidos'] method: adversarially-verified workflow (analyze → refute); HIGH/CRITICAL personally re-verified against cited code by the lead. provider: src/uix/eidos/components/onion-menu/onion-menu.svelte (compositional; soma runtime is src/uix/soma/components/onion-menu/onion-menu.svelte.ts, nav helpers isolated in src/uix/soma/components/onion-menu/onion-menu-nav.ts) ## Summary Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 1 · LOW 3. systemic hits: SYS-3: interaction-heavy component with keyboard navigation + focus management tested only via unit nav functions; no client test for the eidos root behavior.. ## Findings ### MEDIUM: F Tests (Interaction coverage) — onion-menu-004 - dimension: F - rule: F Tests (Interaction coverage) - location: src/uix/soma/components/onion-menu/ - evidence: Only onion-menu-nav.test.ts exists; no onion-menu-provider.svelte.test.ts or eidos component test. - impact: Keyboard behavior is tested only on the radial nav pure functions (wrap, disabled skip). The critical interaction paths are untested: open/close trigger (A17 focus return, two-moments), drill navigation (drill-up via Backspace), focus management in SVG (real focus in sectors), outside-click dismissal (non-modal). This is SYS-3: interaction-heavy + jsdom-only + lacks a client test. - repro: grep -r 'test' src/uix/soma/components/onion-menu/ and src/uix/eidos/components/onion-menu/ — only nav test exists. - proposed-fix: Create src/uix/soma/components/onion-menu/onion-menu-provider.svelte.test.ts and src/uix/eidos/components/onion-menu/onion-menu.test.ts with at least: (1) trigger open/close + focus return; (2) ArrowRight/Left wrapping in a ring with disabled; (3) drill path transitions (Enter on branch); (4) Backspace drill-up; (5) outside-click close; (6) focus remains/restored through open/close cycle. Use client/Playwright if SVG focus testing requires interaction. - verify: [downgraded] Confirmed: only onion-menu-nav.test.ts (pure index math), geometry.test.ts and color.test.ts (pure engines) exist; no onion-menu-provider.svelte.test.ts nor an eidos .svelte test. The integration behavior lives entirely in onion-menu.svelte (openMenu/closeMenu focus-return at lines 268-281, drillUp Backspace 315-324, real DOM focus into SVG sectors via sectorEl().focus() 262/363, outside-click pointerdown dismissal 372-381, focusout Tab-out 335-340) and is UNTESTED. This is a real SYS-3 gap. Downgraded HIGH->MEDIUM per rubric: the highest-risk index math (wrap/disabled-skip/degenerate) IS covered by onion-menu-nav.test.ts; the untested surface is focus/dismissal/two-moments integration = 'missing test on a high-risk path' (MEDIUM). - fix-status: open ### LOW: E-bis Theming (magic literals) — onion-menu-002 - dimension: E-bis - rule: E-bis Theming (magic literals) - location: src/uix/eidos/components/onion-menu/onion-menu.css:172-173 - evidence: outline: 2px solid var(--focus-ring-color, currentColor); outline-offset: 2px; - impact: Hard-coded px values for focus ring width and offset are not derived from canonical tokens. These should reference --z-index-* or --spacing-* tokens so focus ring sizing is consistent across the design system. - repro: Read onion-menu.css lines 172-173 and check base.ts recipe tokens for onion-menu key — focus ring tokens are missing. - proposed-fix: Define or reference canonical tokens for focus ring: outline should use var(--focus-ring-width, 2px) and outline-offset should use var(--focus-ring-offset, 2px). Wire these from the recipe base.ts if they don't exist. - verify: [downgraded] Confirmed the literals exist - onion-menu.css:171-173: 'outline: 2px solid var(--focus-ring-color, currentColor); outline-offset: 2px;'. But this is the framework's CANONICAL focus-ring treatment, not drift: the color-engine hand-off (2026-06-01) establishes 'outline: 2px solid' as the canonical focus + forced-colors fallback ('outline: 2px solid Highlight'), and Button uses the same. The cited rule/fix is wrong - it claims --z-index-*/--spacing-* should size a focus ring, which is nonsensical. At most a cosmetic consistency nit, not a MEDIUM token-drift defect. - fix-status: open ### LOW: E-bis Theming (bare numbers in recipe) — onion-menu-003 - dimension: E-bis - rule: E-bis Theming (bare numbers in recipe) - location: src/uix/eidos/lib/recipes/base.ts:4372-4376 - evidence: 'sector-ring-width': '2.5', 'sector-hover-ring-width': '2', 'muted-opacity': '0.85', 'hover-ring-opacity': '0.65' - impact: Token values are bare numbers without units or as decimal opacity values without a wrapping token reference. Recipe tokens should consistently reference named scales or declare full CSS values. - repro: Check base.ts lines 4372-4376 and verify the tokens are used correctly in onion-menu.css (e.g., stroke-width: var(--onion-menu-sector-ring-width) should resolve to a valid CSS value). - proposed-fix: Either fully qualify with units ('2.5px' for ring-width) or wrap opacity in a token lookup. Consider defining canonical --opacity-muted, --opacity-hover-ring as theme tokens that the recipe can then reference. Or emit the values as they are consumed (px for widths, unitless for opacity). - verify: [downgraded] Mixed claim. The ring-width tokens base.ts:4372-4373 'sector-ring-width':'2.5' / 'sector-hover-ring-width':'2' are consumed as SVG stroke-width: var(--onion-menu-sector-ring-width) (onion-menu.css:53,86,102) - unitless user-space lengths are VALID for SVG stroke-width, NOT a defect; the candidate's 'add px' fix is wrong. The opacity decimals base.ts:4375-4376 'muted-opacity':'0.85' / 'hover-ring-opacity':'0.65' are the only substantive part: a canonical numeric scale --opacity-{0..100} (Tailwind step-5) exists and is widely consumed, so these could reference var(--opacity-85)/var(--opacity-65). Real but minor; the candidate over-broadens and misprescribes. - fix-status: open ### LOW: C DOM-selector (querySelector safety) — onion-menu-005 - dimension: C - rule: C DOM-selector (querySelector safety) - location: src/uix/eidos/components/onion-menu/onion-menu.svelte:112-113 - evidence: const ring = surfaceEl?.querySelectorAll( `[data-onion-menu-item][data-depth="${level + 1}"]` ); - impact: The interpolation is numeric-only (level + 1 produces a number), so CSS.escape is not needed here. However, the selector construction is fragile: it relies on DOM order matching the index array exactly. If the SVG structure changes (conditional rendering, key changes), the nth-child relationship breaks silently. - repro: Read onion-menu.svelte line 111-115 and trace how sectors are inserted/removed — order assumption is implicit in the array access at line 115 (ring?.[index]). - proposed-fix: Consider using a Map stored during render instead of querying by index. Alternatively, add a data-sector-id that is both numeric and query-stable (e.g., data-sector-id="${level}-${index}") and query by that instead of relying on order. - verify: [confirmed] Confirmed at LOW (the candidate's own severity). onion-menu.svelte:112-113: surfaceEl?.querySelectorAll(`[data-onion-menu-item][data-depth="${level + 1}"]`). The interpolated value is numeric (level+1, internal counter), never consumer-derived, so CSS.escape is correctly NOT required - the candidate concedes this. Only the DOM-order reliance (ring?.[index]) is a design note, and lines 107-116 explicitly justify it (avoids bind:this into a computed key + a ref-map timing race). Borderline a styleObservation; no actual injection/fragility defect. - fix-status: open ## No-findings dimensions A, B, G ## Theming facts (E-bis) - magic z-index: none - magic literals: 2px focus ring width (line 172 onion-menu.css) → --onion-menu-focus-ring-width | 2px outline-offset (line 173 onion-menu.css) → --onion-menu-focus-ring-offset | '2.5' sector-ring-width in recipe (line 4372 base.ts) → should be '2.5px' or unitless opacity | '0.85' muted-opacity (line 4375 base.ts) → should wrap in --opacity-* token - undeclared parts: none - roles clean: true · variants clean: true - conformance: Morfo is 'as const satisfies Morfo' ✓; all parts match declared data/aria; naming uses --onion-menu-{slot} for public tokens ✓; no invented theme roles or sizes ✓ ## Tests (F) - exists: true · env: vitest (unit tests only — onion-menu-nav.test.ts has no @vitest-environment directive, defaults to node/jsdom) - covers: onionNavNext/Prev wrapping; onionNavFirst with disabled leading; disabled option skipping; degenerate cases (empty ring, all-disabled) - untested: Open/close trigger via Enter/Space/ArrowDown (keyboard); Focus return to trigger on close (A17); Drill into a branch (data-expanded toggle, drill-path mutation); Drill-up via Backspace (drill-path rollback); ArrowLeft/ArrowRight focus movement in the active ring; SVG sector focus() call and roving focus state sync; Outside-click dismissal while open (non-modal); Programmatic open state change (pendingFocus queueing); All perceptual signals (open/close/commit-select) routing correctly; Focus return when Menu is closed without selecting a leaf ## Style observations (non-blocking) - The component uses theme-driven colour derivation (color-mix) consistently for fills/text, avoiding hardcoded hex/rgb/oklch ✓ - CSS classes use .onion-menu-* prefix throughout; data-* attributes follow [data-onion-menu-*] and [data-onion-menu-{part}] correctly ✓ - The focus-visible ring styling is semantic (uses currentColor) but literals (2px) should be tokens ✓ - Sector animation uses a simple opacity keyframe with @media prefers-reduced-motion guard ✓