# Audit: navigation-menu audit-version: 1 audited-at: 2026-06-26 scope: ['soma', 'sema'] (SCOPE-DRIFT → SYS-1) method: adversarially-verified workflow (analyze → refute); HIGH/CRITICAL personally re-verified against cited code by the lead. provider: src/uix/soma/components/navigation-menu/navigation-menu-provider.svelte.ts ## Summary Counts (post-verification): CRITICAL 0 · HIGH 1 · MEDIUM 3 · LOW 0. systemic hits: SYS-1 (scope-drift: morfo omits 'eidos' despite eidos recipe); SYS-3 (jsdom-only test, keyboard routes untested); SYS-6 (undeclared eidos parts: data-size, data-depth, data-floating-gap). ## Findings ### HIGH: A6 — navigation-menu-006 - dimension: B - rule: A6 - location: src/uix/soma/components/navigation-menu/navigation-menu-provider.svelte.ts:523-527 - evidence: `openedAtEffect = $effect.root(() => { $effect(() => { if (this.isOpen) this.lastOpenedAt = Date.now(); }); });` — the disposer RETURNED by `$effect.root` is stored in the `openedAtEffect` field and NEVER invoked. NavigationMenuTriggerProvider has no teardown effect/`$effect(() => () => openedAtEffect())` and no dispose() (grep 'dispose|destroy' in this file: only this one match). - impact: A6 leak: `$effect.root` deliberately creates a DETACHED, non-auto-disposing reactive root. Each NavigationMenuTriggerProvider instance leaks one root permanently subscribed to `this.isOpen` (which reads provider.opts.value via item.isOpen). Triggers mount/unmount with dynamic nav / route changes; every destroyed trigger leaves a live tracking root — accumulating reactive subscriptions for the page lifetime. Contrast: every other `$effect.root` in the codebase is in test files where the returned cleanup IS called. - repro: Render a NavigationMenu, dynamically add/remove Trigger items (or navigate between routes that mount different nav menus) — each removed trigger leaves an undisposed $effect.root subscribed to isOpen; reactive roots accumulate without bound. - proposed-fix: Drop `$effect.root` entirely — the provider constructor already runs in a tracking context (the SAME constructor pattern uses bare `$effect` at lines 99 and 109 of the root provider, which auto-dispose). Replace with a bare `$effect(() => { if (this.isOpen) this.lastOpenedAt = Date.now(); })` in the constructor (or class field run during construction), which auto-cleans on unmount. If `$effect.root` must be retained, wire its disposer into an `$effect(() => () => this.openedAtEffect())` teardown. - verify: [verifier-added] added by adversarial verify pass - fix-status: fixed (98954a7c) ### MEDIUM: behavioral — navigation-menu-001 - dimension: C - rule: behavioral - location: src/uix/soma/components/navigation-menu/navigation-menu-provider.svelte.ts:567 - evidence: const content = this.provider.opts.ref.current?.querySelector(`#${this.item.contentId.current}`); - impact: contentId is user-derived (from optional id prop in types.ts line 139) and not CSS-escaped. Special characters like ':' or '[' can break the selector or inject CSS selector logic. - repro: Pass id='nav-content:test' or id='nav-content[special]' to NavigationMenuContent; ArrowDown keyboard navigation will fail to find the content. - proposed-fix: Use CSS.escape(this.item.contentId.current) before interpolating into the selector: `#${CSS.escape(this.item.contentId.current)}` - verify: [confirmed] Line 567: `querySelector(`#${this.item.contentId.current}`)` — no CSS.escape. contentId derives from Content's `id?: string` consumer prop (types.ts:139 'DOM id. Auto-generated when omitted'). The project's OWN convention escapes id selectors: combobox-provider.svelte.ts:418 does `#${CSS.escape(this.provider.highlightedId)}`, and 7 other providers (tabs/listbox/radio-group/grid-list/command/tree-grid/tree-view) wrap data-value interpolations in CSS.escape. navigation-menu is the lone unescaped `#`-id selector. Real dimension-C fragility; HIGH per 'untrusted/consumer-derived selector' rule. ArrowDown-into-content nav silently fails for an id containing ':' '[' etc. - fix-status: open ### MEDIUM: SYS-3 — navigation-menu-004 - dimension: F - rule: SYS-3 - location: src/uix/soma/components/navigation-menu/navigation-menu-provider.svelte.test.ts:1 - evidence: @vitest-environment jsdom; tests cover ArrowDown, ArrowRight, but do not test Home, End, Escape, ArrowLeft, or loop=true wrapping behavior. No client/Playwright test exists. - impact: Critical keyboard navigation paths (Home/End/loop wrap) are untested in jsdom. Interaction-heavy component with high-risk keyboard behavior only validated in headless environment; edge cases (wrapping, boundary conditions) undetected. - proposed-fix: Add tests for all keyboard routes (Home → first item, End → last item, Escape when open, ArrowLeft, loop=true wrapping at boundaries). Consider a client test for real DOM focus + browser event dispatch. - verify: [downgraded] CONFIRMED as a gap but DOWNGRADED HIGH→MEDIUM. Test is jsdom-only (line 1 `@vitest-environment jsdom`), no client/Playwright variant exists (only navigation-menu-provider.svelte.test.ts). It exercises ArrowRight (handleListKeydown→close+focus, line 268) and ArrowDown (trigger→content focus, line 211) + open/close/skip-delay timers, but NOT Home/End/Escape/loop-wrap. This is SYS-3 (interaction-heavy + jsdom-only + kbd partially untested). Severity is MEDIUM per the rubric ('missing test on a high-risk path' = MEDIUM), not HIGH: the untested loop math at provider.svelte:336-345 is actually CORRECT — modulo `(i+1)%n` / `(i-1+n)%n` with a `currentIndex===-1` early-return guard (line 325), so the SYS-4 n-2 off-by branch does NOT exist here. No latent bug behind the missing tests, only coverage debt. - fix-status: open ### MEDIUM: SYS-1 — navigation-menu-005 - dimension: A - rule: SYS-1 - location: src/uix/morfo/components/navigation-menu.ts:7 - evidence: scope: ['soma', 'sema'] but src/uix/eidos/components/navigation-menu/ directory exists with CSS and component files (navigation-menu.css, navigation-menu.svelte, etc.) - impact: Scope drift: morfo declares only soma + sema participation, but eidos has full recipes and visual styling. If a consumer or validator only reads morfo, they won't know eidos is active. - proposed-fix: Add 'eidos' to morfo scope: scope: ['soma', 'sema', 'eidos'] - verify: [confirmed] CONFIRMED SYS-1 at MEDIUM. navigation-menu.ts:7 `scope: ['soma', 'sema']` omits 'eidos', yet a full eidos recipe dir exists (navigation-menu.css 310 lines + navigation-menu.svelte + types.ts NavigationMenuSize). Systemic: calendar/color-picker/combobox/command/date-picker morfos likewise scope `['soma','sema']` despite having eidos dirs. MEDIUM is the right severity — informational drift between morfo declaration and the active eidos layer, no user-visible/behavioral consequence. - fix-status: open ## No-findings dimensions B, G ## Theming facts (E-bis) - magic z-index: 1 at line 285 (indicator z-index should be --z-index-* or declared per spec) - magic literals: 0.5rem chevron size (line 135-136) | -1px transform (line 140) | 2px transform (line 146) | -4px scale transform (line 236) | 0.985 scale (line 236) | 1px outline-offset (lines 121, 209) | 12px slide distance (lines 261-270) | 2px block-size indicator (line 288) - undeclared parts: data-size | data-depth | data-floating-gap - roles clean: true · variants clean: true ## Tests (F) - exists: true · env: jsdom - covers: timer scheduling; open/close state sync; trigger-content linkage; keyboard focus navigation (ArrowDown, ArrowRight); accessibility props emission - untested: Home key navigation; End key navigation; Escape key close; ArrowLeft navigation; loop=true wrapping behavior at boundaries; RTL (dir=rtl) keyboard mapping; content focus delegation on Enter; hover-triggered skip-delay window; click-race suppression (lastOpenedAt timing) ## Style observations (non-blocking) - Responsive size tokens (sm/md/lg) implemented cleanly via CSS custom properties per data-size - Animation easing (ease-out, ease-default) properly referenced from design tokens - Chevron indicator rotates smoothly with transition; uses currentColor for theming flexibility - Content panel z-index hardcoded but references --navigation-menu-content-z (indicates planning for future token) - Trigger + Link share identical chrome (text color, hover, active states) — intentional design