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

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

  • 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

Powered by TurnKey Linux.