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.
83 lines
9.6 KiB
83 lines
9.6 KiB
# 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 <!-- id: 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 <!-- id: 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 <!-- id: 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 <!-- id: 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<SVGPathElement>(
|
|
`[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<key, element> 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<SVGPathElement>(`[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 ✓
|