|
|
|
|
# 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) <!-- id: popover-001 -->
|
|
|
|
|
- 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 sets `type` + `aria-label`)
|
|
|
|
|
- evidence:
|
|
|
|
|
```ts
|
|
|
|
|
this.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
|
|
|
|
|
}));
|
|
|
|
|
```
|
|
|
|
|
The morfo Close declares `type` (literal 'button') and `aria-label` (`v.commonRef('buttons.close')`,
|
|
|
|
|
morfo:209-216); with `syncAttrs: true` the 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.CLOSE` and the morfo `commonRef` ever 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-label` from the Close props; let `syncAttrs` own them (the morfo already
|
|
|
|
|
declares both). Keep only `onclick`.
|
|
|
|
|
- fix-status: open
|
|
|
|
|
|
|
|
|
|
### MEDIUM: `content-z: '75'` / `overlay-z: '60'` magic z-index literals <!-- id: popover-002 -->
|
|
|
|
|
- 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 after `select.content-z: '80'`
|
|
|
|
|
and `dialog.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 <!-- id: popover-003 -->
|
|
|
|
|
- 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 `onInteractOutside` trigger-exclusion bounce guard (:559-568, a documented
|
|
|
|
|
past bug), the Escape path, FocusScope trap+return (jsdom can't), or the `save`/`fail`/`cancel` close causes
|
|
|
|
|
(only `dismiss` is 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 via `contains()`; `hoverTimer` cleaned in `$effect` return (A6). Clean.
|
|
|
|
|
- **C (selectors):** uses `contains(triggerNode, target)` — no `querySelector` with 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 via `syncAttrs` (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.
|