6.4 KiB
Audit: popover
audit-version: 1 audited-at: 2026-06-26 scope: ['soma', 'sema'] (eidos recipe present though 'eidos' not in morfo scope — systemic SCOPE-DRIFT) files:
- provider: src/uix/soma/components/popover/popover-provider.svelte.ts (present)
- morfo: src/uix/morfo/components/popover.ts (present)
- recipe: src/uix/eidos/components/popover/popover.css + lib/recipes/base.ts §popover (present)
- demo: web/routes/uix/components/popover (present, not inspected this pass)
- test: src/uix/soma/components/popover/popover-provider.svelte.test.ts (present, jsdom)
- readme: present (not inspected this pass)
Summary
The cleanest of the three overlays audited so far. Popover gets the hard parts right: correct two-moments
(present/close sequence 'pre', emit-before-state), modal-derived FocusScope/ScrollLock (A16), the
hover-bounce guards (re-fire-while-open no-op; modal+hover loop guard) that match the recorded popover fixes,
dismissal trigger-exclusion via contains() (no querySelector injection), timer cleanup (A6), a clean
eidos→soma frontier, NO role double-write (it lets the morfo own role), and a Close that composes <Button>
with no bespoke recipe. Only real defect: the Close button re-declares type/aria-label that syncAttrs
already writes — the same double-write anti-pattern the component's own Trigger comment warns against. Plus the
systemic magic z-index and a jsdom-only test gap on the dismissal/focus paths.
Counts: CRITICAL 0 · HIGH 0 · MEDIUM 3 · LOW 0.
Findings
MEDIUM: Close button re-declares type + aria-label already written by syncAttrs (double-write)
- dimension: D, A
- rule: active_architecture §7.7 (one authority per attribute) + the component's own discipline (trigger comment :423-426)
- location: popover-provider.svelte.ts:739-758 (Close registered
syncAttrs: true, then props setstype+aria-label) - evidence:
The morfo Close declaresthis.runtimePart = this.provider.runtime.part('close', { …, syncAttrs: true }); … readonly props = $derived.by(() => ({ ...this.runtimePart.props, type: 'button' as const, 'aria-label': this.provider.soma.langs.ts(POPOVER_LANGS.CLOSE), onclick: this.onclick }));type(literal 'button') andaria-label(v.commonRef('buttons.close'), morfo:209-216); withsyncAttrs: truethe runtime writes both. The props block writes them again. The Trigger comment a few hundred lines up explicitly says "Spreading them manually here would duplicate (and potentially desync) the runtime's authority" — the Close violates exactly that. - impact: benign today (both resolve to the same value), but it's duplicate authority + desync risk if
POPOVER_LANGS.CLOSEand the morfocommonRefever diverge, and an internal inconsistency in the component's own discipline. (Contrast Dialog where the analogous double-write IS a live bug — dialog-001.) - proposed-fix: drop
type/aria-labelfrom the Close props; letsyncAttrsown them (the morfo already declares both). Keep onlyonclick. - fix-status: open
MEDIUM: content-z: '75' / overlay-z: '60' magic z-index literals
- dimension: E-bis
- rule: THEMING §35 Bloque C (z-index magic →
--z-index-*tokens) - location: lib/recipes/base.ts:1753-1754 (
'content-z': '75','overlay-z': '60'); popover.css:53 + :63. - evidence: bare integers where
var(--z-index-*)tokens belong. Third instance afterselect.content-z: '80'anddialog.overlay-z: '70'→ confirms the SYSTEMIC z-ladder finding (see THEMING_COHERENCE / SUMMARY). - impact: the overlay stacking ladder lives as scattered per-recipe integers (60/70/75/80…) that can't be coordinated or reasoned about centrally.
- proposed-fix: introduce a canonical
--z-index-{overlay,popover,modal}ladder and map all overlay recipes to it in one sweep. - fix-status: open
MEDIUM: Tests cover toggle/hover/labelling; dismissal trigger-exclusion, focus, and polymorphic close causes untested; jsdom-only
- dimension: F
- rule: dimension-F honesty check
- location: popover-provider.svelte.test.ts (3 tests: toggle, hover open/close via timers + emit, title/description labelling)
- evidence: no test exercises the
onInteractOutsidetrigger-exclusion bounce guard (:559-568, a documented past bug), the Escape path, FocusScope trap+return (jsdom can't), or thesave/fail/cancelclose causes (onlydismissis hit, via hover-close).@vitest-environment jsdom(:1). - impact: the bounce-guard regression and focus behavior would ship unverified.
- proposed-fix: add a provider test that fires an interact-outside landing on the trigger (asserts no double toggle), parametrize the close causes, and add a client/Playwright test for focus trap+return.
- fix-status: open
No-findings dimensions
- B (behavior): two-moments ordering correct; hover bounce guards present and correct (re-fire-while-open
no-op :284, modal+hover loop guard :405/:419); FocusScope/ScrollLock derive from
modal(A16); dismissal trigger-exclusion viacontains();hoverTimercleaned in$effectreturn (A6). Clean. - C (selectors): uses
contains(triggerNode, target)— noquerySelectorwith interpolated user values. Clean. - D (frontier, severe half): provider imports only soma layers + morfo — zero eidos imports.
- A (contract): the virtual Provider part declares empty
data(no unreachable-data issue, unlike Dialog); Trigger correctly lets the morfo own aria/type/data-state viasyncAttrs(no override, unlike Dialog's role). All CSS-targeted parts (title/description/arrow/overlay) are morfo-declared (no undeclared-part drift, unlike Select/Dialog). - E (TSC): no
data-color, no derived-token-at-root risk; clean.
Style observations (opinion, non-blocking)
- Popover is the positive reference among the overlays for: (a) Close-via-
<Button>with no bespoke recipe (the model Dialog's stale trigger envelope should follow), (b) letting the morfo own Trigger ARIA without a manual override (the model Dialog's role override violates), and (c) the documented hover-bounce guards. Worth citing in the SUMMARY as the "do it like Popover" baseline. - The one inconsistency (Close double-write, popover-001) is small but notable precisely because the same file's Trigger comment articulates the correct rule — fixing it makes the file self-consistent.