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/navigation-menu.md

75 lines
8.4 KiB

# 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 <!-- id: 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 <!-- id: 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<HTMLElement>(`#${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 <!-- id: 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 <!-- id: 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

Powered by TurnKey Linux.