# Audit: combobox 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/combobox/combobox-provider.svelte.ts (present) - morfo: src/uix/morfo/components/combobox.ts (present) - recipe: src/uix/eidos/components/combobox/combobox.css (558L) + lib/recipes/base.ts §combobox (present) - demo: web/routes/uix/components/combobox (present, not inspected this pass) - test: src/uix/soma/components/combobox/combobox-provider.svelte.test.ts (present, jsdom) - readme: present (not inspected this pass) ## Summary Combobox is well-built in several respects: the A33 `labelRegistry` clone-reassign incident is fixed (now `SvelteMap`), dimension C is clean (`CSS.escape` on the highlighted-id query + the shared `resolveListItemEl` helper escapes `data-value`), dismissal excludes both trigger and input (A22), and the inputValue auto-sync / mode-reset effects are loop-safe (`untrack` + guards). The headline defect is a **focus-strategy violation (A17)**: the morfo declares `aria-activedescendant` (virtual focus) but the provider moves **real DOM focus** onto items (`dom.focus(items[0])`), mixing the two strategies and breaking the APG combobox contract (focus must stay on the textbox). Plus a `translationRef` misuse, the systemic A31 + magic-z-index, and a jsdom-only test gap right where the focus bug lives. Counts: CRITICAL 0 · HIGH 2 · MEDIUM 3 · LOW 0. ## Findings ### HIGH: Mixes virtual focus (`aria-activedescendant`) with real DOM focus (`.focus()` on items) — A17 violation + breaks APG combobox - dimension: B, A - rule: A17 ("Never mix [virtual + DOM focus] in the same component. If the trigger has aria-activedescendant, items must NOT call .focus()") + APG Combobox pattern (focus stays on the textbox) + morfo↔code drift - location: morfo combobox.ts:106 (Input declares `aria-activedescendant`) vs provider:392, 408, 661, 684 (`this.provider.soma.dom.focus(...)` on items / input) + the items carry `tabindex: -1` (:803) so they take focus. - evidence: the Input keydown opens then `dom.focus(items[0])` AND sets `highlightedId` (:387-394); the Content keydown computes `currentIndex` from `highlightedId` (virtual) yet also `dom.focus(target)` (:684, real). So the component simultaneously (a) advertises `aria-activedescendant` on the input and (b) moves real focus to the option. The morfo's `aria-activedescendant` declaration says "virtual"; the code does "real". - impact: with focus moved onto an option, the input's `aria-activedescendant` is meaningless (it only applies while focus is on the input). Screen readers get a contradictory model (an option both has DOM focus and is the input's active descendant). APG's combobox keeps focus on the textbox; moving it to options is the listbox-with-DOM-focus pattern — picking one is required. - repro: open the listbox, ArrowDown, inspect `document.activeElement` (an option) while the input still has `aria-activedescendant` set to that option. - proposed-fix: commit to virtual focus (the morfo's declared model): keep DOM focus on the input, drive selection purely via `highlightedId`/`data-highlighted` + `scrollIntoView`, never `dom.focus(item)`. (Or, if real focus is intended, drop `aria-activedescendant` from the morfo and the highlightedId machinery — but that diverges from APG combobox.) - fix-status: fixed (bac67315) ### HIGH: Tests pin selection logic only — keyboard/focus (where the A17 bug lives), dismissal-exclusion, custom-value, backspace untested; jsdom-only - dimension: F - rule: dimension-F honesty check - location: combobox-provider.svelte.test.ts (3 tests: single-select, multi toggle + inputValue, getItems disabled-filter) - evidence: no test exercises Input/Content `onkeydown` (the focus moves in combobox-001), the dismissal trigger/input exclusion (`isValidEvent` + `onInteractOutside`, :594-626), `commitCustomValue`, backspace-remove-last, or the inputValue auto-sync effect. `@vitest-environment jsdom` (:1) — focus isn't faithful, so even a keyboard test here couldn't validate the A17 behavior. - impact: the focus-strategy bug and every keyboard path ship unverified. - proposed-fix: add provider tests for the keyboard routes + dismissal exclusion + custom value; add a client/Playwright test that asserts `activeElement` stays on the input (the A17 contract). - fix-status: open ### MEDIUM: `aria-label` parts pass the full idlangref as the `translationRef` key instead of the `texts` key - dimension: A - rule: A3 / A34 (translation namespace) + consistency with the canonical `v.translationRef(key, fallback)` form - location: morfo combobox.ts:142, 168, 291, 323 — e.g. `v.translationRef('#?components.combobox.toggle|Toggle')` while `texts.toggle = '#?components.combobox.toggle|Toggle'` (:11) is declared. - evidence: every other component references a `texts` entry by its KEY (Dialog: `v.translationRef('content.roledescription', 'dialog window')`). Combobox passes the resolved absolute idlangref string as the key, so the `texts` entries (`toggle`/`clear`/`selectedTags`/`selectedTagRemove`) are declared but never referenced by key — the indirection is bypassed. - impact: at best redundant (the string happens to be a valid idlangref so it may resolve); at worst a missing-key lookup. Either way it's inconsistent with the framework's pattern and leaves the `texts` keys dead. Smoke's translation-key check should be confirmed against these (see VALIDATOR_GAPS). - proposed-fix: use `v.translationRef('toggle', 'Toggle')` etc., referencing the `texts` keys. - fix-status: open ### MEDIUM: Per-item `isSelected` reads the whole `value` array through the provider (A31 O(N²)) - dimension: B - rule: A31 - location: combobox-provider.svelte.ts:782 (`isSelected = $derived.by(() => this.provider.isSelected(value))`) → :182-184 (`.includes`) - evidence: identical to select-007 — each Item's derivation calls a provider method reading the shared `value` array (`.includes`, O(N)); on value change all N items re-derive → O(N²). - impact: degrades on large multi-select lists (the A31 "5 fine / 30 hangs" class). - proposed-fix: lift `selectedSet = new SvelteSet(value)` on the provider; item derivation does `.has(value)`. (Shared with Select — see SHARED_EXTRACTION / systemic A31-LIST-SELECTION.) - fix-status: open ### MEDIUM: `content-z: '80'` magic z-index literal - dimension: E-bis - rule: THEMING §35 Bloque C (z-index magic → `--z-index-*` tokens) - location: lib/recipes/base.ts:4254 (`'content-z': '80'`); combobox.css:261. - evidence: bare `80` (same value as `select.content-z`). Fourth overlay with a hardcoded content-z (select 80, dialog 70, popover 75/60, combobox 80) → SYSTEMIC z-ladder finding. - impact: see select-009 / popover-002. - proposed-fix: tokenize to the canonical overlay z-ladder. - fix-status: open ## No-findings dimensions - **C (selectors):** the Enter handler uses `#${CSS.escape(highlightedId)}` (:417-419) and `resolveItemEl` delegates to `resolveListItemEl` which escapes `data-value` (list-selection.ts:145). No injection. (This is the safe pattern Select's `scrollSelectedIntoView` fails to use — see select-001.) - **A33:** `labelRegistry` is `SvelteMap` (:97) — the documented A33 clone-reassign incident is resolved. - **B (loop-safety):** inputValue auto-sync (:151-160) and mode-switch reset (:166-175) effects guard writes and use `untrack`; no feedback loop. Dismissal excludes trigger + input (A22, :594-626). - **D (frontier, severe half):** provider imports only soma layers + morfo — zero eidos imports. - **E-bis (colors):** combobox.css has no raw hex / raw px-rem font literals in the scanned surface (only the z-index literal above). (Full per-selector morfo-part diff not exhaustively run this pass — the CSS is 558L.) ## Style observations (opinion, non-blocking) - The A17 violation is the single most important fix: the morfo already commits to virtual focus, so aligning the provider to it (rather than the reverse) keeps the APG-correct model and removes the real-focus paths. - The `list-selection.ts` extraction (shared with Select) is a good example of dimension-G done right — the selection state machine is one pure, testable module. The remaining duplication (the directional-nav index math) is the part still copied per-component (see select-003 / SHARED_EXTRACTION).