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.
8.4 KiB
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.rootis stored in theopenedAtEffectfield 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.rootdeliberately creates a DETACHED, non-auto-disposing reactive root. Each NavigationMenuTriggerProvider instance leaks one root permanently subscribed tothis.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.rootin 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.rootentirely — the provider constructor already runs in a tracking context (the SAME constructor pattern uses bare$effectat 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.rootmust 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)%nwith acurrentIndex===-1early-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