|
|
# Audit: menu-dial
|
|
|
audit-version: 1
|
|
|
audited-at: 2026-06-26
|
|
|
scope: ['soma','sema','eidos'] (SCOPE-DRIFT → SYS-1)
|
|
|
method: adversarially-verified workflow (analyze → refute); HIGH/CRITICAL personally re-verified against cited code by the lead.
|
|
|
provider: G:/dev/svelte/vicen/src/uix/soma/components/menu-dial
|
|
|
|
|
|
## Summary
|
|
|
Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 3 · LOW 3.
|
|
|
systemic hits: SYS-1 (scope-drift: sema declared but not implemented); SYS-3 (keyboard/focus untested at component level, only nav logic tested); SYS-2 (magic z-index fallbacks, bare duration literals).
|
|
|
|
|
|
## Findings
|
|
|
### MEDIUM: THEMING — menu-dial-002 <!-- id: menu-dial-002 -->
|
|
|
- dimension: A
|
|
|
- rule: THEMING
|
|
|
- location: src/uix/morfo/components/menu-dial.ts:19
|
|
|
- evidence: Morfo declares scope: ['soma','sema','eidos'] but no recipe file found in src/uix/eidos/lib/recipes/base.ts or recipe subdirectory for menu-dial
|
|
|
- impact: If the morfo declares eidos in scope, the component requires either a recipe in base.ts or a recipe directory (src/uix/eidos/lib/recipes/menu-dial/), but neither exists. CSS is present but not declared in the recipe system.
|
|
|
- proposed-fix: Verify intent: if eidos styling is complete via CSS alone, document this exception. If tokens should be in base.ts, add a menu-dial recipe entry.
|
|
|
- verify: [confirmed] Confirmed via grep: `grep menu-dial base.ts` returns NOTHING — there is no `'menu-dial':` recipe key in src/uix/eidos/lib/recipes/base.ts. The morfo declares `scope: ['soma','sema','eidos']` (line 19). Crucially the TWIN component it repeatedly mirrors, `onion-menu`, DOES have a recipe block (base.ts:4368) whose header comment reads 'this owns the magic numbers that USED to be literals in onion-menu.css.' Menu-dial's CSS still carries those exact literals un-tokenized (28ms stagger, 2px glyph thickness — base.ts:4374 names onion's as `glyph-bar-thickness: '2px'`). So this is a genuine THEMING/recipe gap, not just a documentation note. Downgraded HIGH→MEDIUM: it's a token-coverage inconsistency vs the sibling, not a user-visible break. Confirmed at MEDIUM.
|
|
|
- fix-status: open
|
|
|
|
|
|
### MEDIUM: SYS-3 (keyboard route test coverage) — menu-dial-005 <!-- id: menu-dial-005 -->
|
|
|
- dimension: F
|
|
|
- rule: SYS-3 (keyboard route test coverage)
|
|
|
- location: src/uix/soma/components/menu-dial/menu-dial-nav.test.ts
|
|
|
- evidence: Only menu-dial-nav.test.ts exists (tests pure navigation logic). No provider test (no menu-dial-provider.svelte.test.ts or equivalent in eidos). The nav tests verify wrapping, disabled-skipping, Home/End but do NOT test keyboard routing through the soma/eidos integration: Enter/Space to open/close, Arrow routing through list while open, focus sync, dismiss on Escape.
|
|
|
- impact: Keyboard behavior (the primary interaction mode per APG) is untested at the component level. The nav helpers are tested in isolation but their integration with focus, dismissal, and the open/close state is not exercised.
|
|
|
- proposed-fix: Add a @vitest-environment=jsdom or Playwright client test file (e.g., menu-dial-provider.svelte.test.ts) covering: trigger keydown (Enter/Arrow to open), list keydown (Arrow/Home/End navigation, Escape to close), focus sync after open, Tab-out dismiss. Mark high-risk paths.
|
|
|
- verify: [confirmed] Confirmed: `ls src/uix/soma/components/menu-dial/` shows only `menu-dial-nav.test.ts` (verified, reads it) which tests the PURE `dialNav*` index math (count 4 wrap, disabled-skip [1,2], Home/End, degenerate empty/all-disabled) in isolation — no DOM, no @vitest-environment. There is NO provider/integration test anywhere (grep'd both soma and eidos menu-dial dirs). The integration surface is entirely untested: trigger keydown→open (runtime.keydown('trigger')), list Arrow/Home/End routing through focus (`focusActionAt` + actionEls() querySelectorAll), Escape→close, focusout/outside-pointerdown dismiss ($effect lines 254-291), focus-return-to-trigger (line 176), the pendingFocus $effect (270-279). This is a textbook SYS-3: interaction-heavy component, the easy pure-math path tested, every keyboard/focus/dismissal integration path untested. Confirmed MEDIUM.
|
|
|
- fix-status: open
|
|
|
|
|
|
### MEDIUM: THEMING (recipe ownership of magic numbers) / 2-of-3 — menu-dial-101 <!-- id: menu-dial-101 -->
|
|
|
- dimension: A, E-bis
|
|
|
- rule: THEMING (recipe ownership of magic numbers) / 2-of-3
|
|
|
- location: src/uix/eidos/components/menu-dial/menu-dial.css:25,291-292 vs src/uix/eidos/lib/recipes/base.ts:4368-4377
|
|
|
- evidence: menu-dial.css line 25 `--_menu-dial-stagger-step: 28ms;` and line 291-292 `block-size: 2px; margin-block-start: -1px;` (the glyph bar) are un-tokenized literals. The TWIN component onion-menu — which menu-dial's own comments say it 'mirrors' — moved exactly these into a base.ts recipe: base.ts:4374 `'glyph-bar-thickness': '2px'`, and onion's header (4364-4366) explicitly says the recipe 'owns the magic numbers that USED to be literals in onion-menu.css.' menu-dial has NO base.ts recipe block at all (grep 'menu-dial' base.ts → empty).
|
|
|
- impact: menu-dial regressed on the precise pattern the framework already established for its sibling: the speed-dial's tunable timing (28ms stagger) and glyph thickness (2px) are frozen in CSS with no recipe knob, so a theme cannot retune them and they can silently drift from onion-menu's tokenized equivalents. This is the concrete, code-grounded core of candidate findings 002+006.
|
|
|
- repro: grep -n "menu-dial" src/uix/eidos/lib/recipes/base.ts # → no matches; compare base.ts:4368 onion-menu block
|
|
|
- proposed-fix: Add a `'menu-dial':` block to base.ts recipes mirroring onion-menu: e.g. `'stagger-step': '28ms'`, `'glyph-bar-thickness': '2px'` (referencing the same source onion uses), and consume `var(--menu-dial-glyph-bar-thickness)` / `var(--menu-dial-stagger-step)` in the CSS.
|
|
|
- verify: [verifier-added] added by adversarial verify pass
|
|
|
- fix-status: open
|
|
|
|
|
|
### LOW: A22 (aria-controls, aria-labelledby) — menu-dial-003 <!-- id: menu-dial-003 -->
|
|
|
- dimension: A
|
|
|
- rule: A22 (aria-controls, aria-labelledby)
|
|
|
- location: src/uix/eidos/components/menu-dial/menu-dial.svelte:314-339
|
|
|
- evidence: Morfo declares aria-controls on trigger (line 84-88 of morfo: 'aria-controls: value: v.partRef("list")', recommended severity) and aria-labelledby on list (line 112-116: 'aria-labelledby: value: v.partRef("trigger")'), but neither is set in the Svelte implementation. Trigger has no aria-controls attribute; list has no aria-labelledby attribute.
|
|
|
- impact: Accessibility regression: screen readers cannot programmatically associate the trigger button with the menu it controls, violating ARIA authoring practices. The list is labeled aria-label=ariaLabel (same as trigger), creating ambiguity.
|
|
|
- proposed-fix: Generate unique IDs for trigger/list on mount. Add aria-controls=listId to Fab trigger and aria-labelledby=triggerId to list div. Both attributes marked as 'recommended', so failing to set them should be flagged but component is still functional.
|
|
|
- verify: [downgraded] Confirmed factually: morfo lines 83-88 declare trigger `aria-controls: v.partRef('list')` and lines 110-116 declare list `aria-labelledby: v.partRef('trigger')`. grep for `aria-controls|aria-labelledby|id=|useId` in the menu-dial eidos dir returns ZERO hits — neither attr is emitted, and no IDs are generated. menu-dial.svelte sets only `aria-haspopup='menu'` + `aria-expanded` on the Fab (lines 321-322) and `aria-label={ariaLabel}` on both trigger and list (lines 320, 335). HOWEVER both morfo declarations carry `severity: 'recommended'` (lines 87, 115) — NOT 'required'. The component is functional and named: trigger has aria-label + aria-haspopup + aria-expanded; list has aria-label + role='menu'. The sibling onion-menu also omits aria-controls (grep confirmed), so this is a family-wide pattern, not a unique regression. A missing 'recommended' attr on an otherwise-named control is a LOW/nice-to-have a11y nit, not a HIGH a11y break. Downgraded HIGH→LOW.
|
|
|
- fix-status: open
|
|
|
|
|
|
### LOW: TWO-MOMENTS (sequence pre vs post) — menu-dial-004 <!-- id: menu-dial-004 -->
|
|
|
- dimension: B
|
|
|
- rule: TWO-MOMENTS (sequence pre vs post)
|
|
|
- location: src/uix/eidos/components/menu-dial/menu-dial.svelte:169-177
|
|
|
- evidence: closeMenu() sets open=false (line 171) BEFORE calling runtime.trigger('close', ...) (line 175). Morfo declares close event has sequence='pre', meaning it should fire BEFORE state changes. The reactive data-state binding (line 334: data-state={open ? 'open' : 'closed'}) will update synchronously when open changes, moving the list to data-state='closed' before the event is emitted.
|
|
|
- impact: For 'pre' sequence events, the content (list with data-state) should still be marked as open when the event fires (for announcements, sounds), but the binding changes the state first. Same issue in openMenu (line 164).
|
|
|
- proposed-fix: Reorder: trigger the event BEFORE updating open state, or untrack the state update so the event fires first. e.g., `runtime.trigger('close', ...); open = false;` or use $effect to emit after measuring.
|
|
|
- verify: [downgraded] Factually correct: closeMenu (eidos line 169-177) sets `open = false` (line 171) BEFORE `runtime.trigger('close', ...)` (line 175); same for openMenu (line 164 before 166). The template binds `data-state={open ? 'open' : 'closed'}` reactively (line 334). BUT the impact is overstated for two reasons I verified: (1) runtime.svelte.ts:741-753 shows `sequence` ONLY orders `runEmit` vs an optional `handler` — menu-dial passes NO handler to trigger(), so 'pre' vs 'post' is functionally moot here; the morfo `commits` is also inert because eidos does not register the list via `runtime.part(...)`, it drives data-state itself via the Svelte binding. (2) The emit target is `listEl` (fallbackTarget, line 175), the list ELEMENT which persists in the DOM regardless of `data-state` — so the sema `data-event-*` stamp + hold are NOT lost; only the visual `data-state='closed'` flips one tick early. The perceptual signal still fires on a live element. This is a mild ordering smell, not a dropped-signal bug. Downgraded MEDIUM→LOW.
|
|
|
- fix-status: open
|
|
|
|
|
|
### LOW: THEMING (bare numbers in transition/animation) — menu-dial-006 <!-- id: menu-dial-006 -->
|
|
|
- dimension: E-bis
|
|
|
- rule: THEMING (bare numbers in transition/animation)
|
|
|
- location: src/uix/eidos/components/menu-dial/menu-dial.css:25,136,312
|
|
|
- evidence: Line 25: --_menu-dial-stagger-step: 28ms (bare millisecond value); line 136 & 312: fallback 150ms (bare ms in var() fallback); line 283 transition uses var(--duration-normal, 300ms) — fallback hardcoded.
|
|
|
- impact: Bare duration literals (28ms, 150ms, 300ms) are not canonical tokens. Should reference --duration-* scale (e.g., --duration-fast, --duration-normal). Fallbacks in var() are fragile.
|
|
|
- proposed-fix: Define --_menu-dial-stagger-step as a component-scoped alias of a canonical duration token (or expose it in recipes). Replace magic durations: 28ms should be a named token if used for stagger timing; 150ms/300ms should use canonical tokens (--duration-fast, --duration-normal, --duration-slow).
|
|
|
- verify: [downgraded] Split verdict on the cited literals. The `var(--duration-fast, 150ms)` (line 136) and `var(--duration-normal, 300ms)` (line 283) are NOT drift — they reference the canonical tokens (verified `--duration-fast`/`--ease-default`/`--ease-out` are defined in generated/base.css); the ms fallback is dead/redundant noise, and the IDENTICAL pattern appears in sibling onion-menu.css (lines 43, 182) — so flagging it as a defect here while the family uses it is inconsistent. The ONE genuine un-tokenized literal is `--_menu-dial-stagger-step: 28ms` (line 25): unlike onion-menu's `glyph-bar-thickness` it has no recipe token backing it, and it's a real animation-timing magic number that the recipe (absent, see 002) should own. So the finding has a kernel of truth (28ms) wrapped in two false flags (150ms/300ms fallbacks are token-backed). Downgraded MEDIUM→LOW and folded into 002's recipe gap.
|
|
|
- fix-status: open
|
|
|
|
|
|
## No-findings dimensions
|
|
|
C, D, G
|
|
|
|
|
|
## Theming facts (E-bis)
|
|
|
- magic z-index: src/uix/eidos/components/menu-dial/menu-dial.css:67: var(--z-index-sticky, 1100) | src/uix/eidos/components/menu-dial/menu-dial.css:310: calc(var(--z-index-sticky, 1100) - 1)
|
|
|
- magic literals: src/uix/eidos/components/menu-dial/menu-dial.css:25: 28ms (stagger-step) | src/uix/eidos/components/menu-dial/menu-dial.css:136: 150ms (visibility fallback) | src/uix/eidos/components/menu-dial/menu-dial.css:312: 150ms (animation fallback) | src/uix/eidos/components/menu-dial/menu-dial.css:283: 300ms (duration-normal fallback)
|
|
|
- undeclared parts: none
|
|
|
- roles clean: true · variants clean: true
|
|
|
|
|
|
## Tests (F)
|
|
|
- exists: true · env: node (pure nav logic, no vitest-environment annotation)
|
|
|
- covers: dialNavNext wrapping; dialNavPrev wrapping; dialNavFirst/Last; disabled skipping
|
|
|
- untested: keyboard route integration (Enter/Arrow/Escape on trigger/list); focus sync and focus return; open/close perceptual emit timing; dismiss on Tab-out; commit-select lifecycle; aria-label / aria-controls resolution
|
|
|
|
|
|
## Style observations (non-blocking)
|
|
|
- The CSS is well-structured with clear placement zones (9 fixed positions) and arc math (cos/sin layout). The stagger via CSS custom property --_menu-dial-i is elegant. Hover close delay (260ms) is reasonable for crossing the gap. Glyph rotation from + to × is smooth. No redundancy vs other components observed; this design is specific to radial fan layout.
|
|
|
- The arc geometry calculation in CSS (radius := max(r-clear, r-spread)) is mathematically sound and self-contained.
|