fix-status: open -> fixed (<commit>) for every HIGH whose code fix landed this
session, across 12 component reports:
- 92f988e7: listbox-001, grid-list-001/002, tree-view-002, tree-grid-001, tag-group-001 (SYS-7 A31)
- 98954a7c: alert-dialog-001, breadcrumb-001, navigation-menu-006, select-001, dialog-002
- db33f66c: select-002, dialog-001, collapsible-NEW-001
- bac67315: combobox-001, select-003
The HIGH tier is now code-complete; the 2 remaining HIGH (select-004,
combobox-002) are test-coverage gaps for Phase 6.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- repro: Build and check TypeScript diagnostics; createAttrs will not narrow string literal keys correctly
- proposed-fix: Change to: export const alertDialogMorfo = { ... } as const satisfies Morfo;
- verify: [confirmed] Read src/uix/morfo/components/alert-dialog.ts line 4 verbatim: `export const alertDialogMorfo: Morfo = {`. This is a bare type annotation, not the canonical `as const satisfies Morfo` form. The file ends at line 76 with `};` (no `as const satisfies Morfo` closer). Confirmed against the four baseline morfos which all use the canonical pattern: dialog.ts:15 `export const dialogMorfo = {` closing at :303 `} as const satisfies Morfo;`, popover.ts:4 closing at :251, drawer.ts:4 closing at :313, toggle.ts:22 closing at :143. The `: Morfo` annotation widens literal types (e.g. `kebab: 'alert-dialog'`, part kebabs `'provider'|'action'|'cancel'`, the `v.literal('button')` aria values) to their base types, degrading the exact-key narrowing compileMorfo/createAttrs depend on — exactly the rule-A violation described. Severity HIGH is appropriate: it is a contract-shape rule the morfo validators should catch.
@ -19,7 +19,7 @@ Counts (post-verification): CRITICAL 0 · HIGH 1 · MEDIUM 0 · LOW 0.
- repro: none (static violation)
- proposed-fix: Remove `archetype: 'item'` from the Item part definition (line 50). Item is a display container, not a selectable option.
- verify: [confirmed] CONFIRMED. breadcrumb.ts:48-56 declares the Item part with `archetype: 'item'` (line 50) on a display container `<li>` (`defaultElement: 'li'`, no role, not selectable). The runtime stamps `data-archetype='item'` on the element whenever the morfo sets it (morfo/types.ts:720 'the runtime emits `data-archetype="..."` on the part's DOM element via `partProps`'; README:177 same). That `[data-archetype='item']` selector triggers archetypes.css:139-153 — `cursor: pointer; user-select: none; min-block-size: var(--list-item-height, auto); padding-block: var(--list-item-py, var(--space-1-5))` — PLUS the hover/highlight band at archetypes.css:161-171 (`background-color: var(--color-surface-raised)` painted on the WHOLE `<li>` on hover). breadcrumb.css:36-41 styles `[data-breadcrumb-item]` only with `display:inline-flex; align-items:center; min-inline-size:0` — it does NOT reset cursor/padding-block/hover, so the styling is unmitigated. This is the exact documented bug: Timeline.ts:88-91 ('No `archetype: 'item'` — that archetype is for selectable menu/listbox/option rows (it ships cursor:pointer, user-select:none, internal padding + a hover highlight). A timeline entry is a display `<li>`, structurally unique, so it omits the archetype.'), and Calendar.ts:247-249 / 270-273 + range-calendar.ts:285 repeat the same doctrine for display `<tr>`/`<td>`. Breadcrumb.Item is semantically identical to Timeline.Item. Real visible regression: a breadcrumb `<li>` gets `cursor:pointer`, `padding-block: var(--space-1-5)`, and a surface-raised hover band over the whole crumb row. HIGH is correct per the rubric's 'archetype-pulls-wrong-styling on a real part'. Fix: remove `archetype: 'item'` from breadcrumb.ts:50 (the Item is a display container; the interactive Link inside owns its own affordance).
- fix-status: open
- fix-status: fixed (98954a7c)
## No-findings dimensions
B: Behavior(soma): A35/A36 loops absent (effect mirrors current to item without cycle; no async state writes), B: A6 resource cleanup (no timers/listeners/observers), B: A31 O(N²) derivations absent, B: A30 child->parent id registration (uses $effect correctly), B: A33 state(Map/Set) cloning (no Maps/Sets used), C: DOM-selector (no querySelector with user values), D: Frontier (no soma->eidos imports), E-bis: Theming (recipe uses canonical --space-*, --font-size-*, --duration-* tokens; focus-ring uses canonical CSS var), F: Tests environment jsdom (provider test exists, covers label projection, current mirroring, ellipsis semantics), G: Passive component justified in README (zero state, zero keyboard nav, zero events by design)
@ -29,7 +29,7 @@ Counts (post-verification): CRITICAL 0 · HIGH 1 · MEDIUM 1 · LOW 0.
- repro: Open a Collapsible (expand — instant), then click the trigger to collapse. Measure the delay between click and `hidden`/`data-state='closed'` taking effect on the content. Expect ~240ms (the emerge `brief` hold) rather than near-zero. The expand direction does not exhibit the delay.
- proposed-fix: Apply the documented Checkbox/Toggle/Switch doctrine: change `collapse` to `sequence: 'post'` (collapsible.ts:48) so the handler flips `open=false` first and the exit signal then acknowledges the resolved state, matching how accordion's symmetric close event is handled (accordion close is post per accordion.ts pattern). If a visible-during-exit cue is genuinely wanted, drive it from eidos CSS keyed on `data-state='closed'` + a CSS exit transition rather than blocking the functional state behind the sema hold. Alternatively set state at the call-site in `toggle()` (collapsible-provider.svelte.ts:85-89) before `runtime.trigger`, which would make 'pre' tolerable (the RadioGroup/Tabs pattern).
- verify: [verifier-added → LEAD-CONFIRMED HIGH] The analyze agent (and the morfo's own inline comment) marked this CLEAN, assuming `'pre'` overlaps emit+handler so the exit plays during the hold. The verifier traced the actual runtime and proved it SERIALIZES (`runtime.svelte.ts:749-752: await runEmit(); await handler()`), so `open=false` is delayed the full ~240ms `brief` emerge hold. I initially LEANED toward MEDIUM (reasoning "collapse is emerge.dismiss 'pre', the canonical animate-before-hide pattern like dialog/popover"), but that masking only holds for components that animate on `data-event`/Presence DURING the hold (dialog does); collapsible's exit is keyed on `data-state='closed'`, which doesn't flip until AFTER the hold — so the 240ms is a real dead delay, not a filled exit animation. Restored to HIGH. Caveat: like the checkbox fix, the user-perceptible magnitude should be confirmed by a frame-by-frame browser measurement before/after; the MECHANISM (and the refuted morfo justification) are confirmed in code.
- fix-status: open
- fix-status: fixed (db33f66c)
## No-findings dimensions
Behavior(soma) [except the sequence-lag above], DOM-selector, Frontier, TSC, Theming, Tests
@ -18,7 +18,7 @@ Counts (post-verification): CRITICAL 0 · HIGH 2 · MEDIUM 1 · LOW 0.
- impact: For N rows, each row's derived calls O(N) .includes() method → O(N²) total. Works fine under ~15 items, becomes noticeably slow at 30+ items.
- proposed-fix: Lift a `Set<string>` on GridListProvider: `readonly selectedSet = $derived(() => new Set(this.opts.value.current))`. In isSelected, use `selectedSet.has(value)` for O(1). Update per-row derived to use the pre-computed set.
- verify: [confirmed] Confirmed A31 O(N²). Line 493: `readonly isSelected = $derived.by(() => this.provider.isSelected(this.opts.value.current))` per GridListRowProvider calls provider.isSelected (lines 150-152: `return this.opts.value.current.includes(value)`), a linear array scan reading GLOBAL selection state. Every row's derived depends on `this.opts.value.current`; selection replaces the array (line 172 `this.opts.value.current = next`), invalidating ALL row deriveds, each re-running O(N) .includes() → O(N²). The provider DID lift a Set for the roving target (line 144 `const selected = new Set(this.opts.value.current)` inside the single `rovingTargetEl` derivation) but did NOT lift one for the per-row isSelected path — fix is real and applicable. Matches SYS-7 / documented Listbox-rovingTarget incident.
- fix-status: open
- fix-status: fixed (92f988e7)
### HIGH: A31: Per-checkbox derived calling provider method that reads global state — grid-list-002 <!-- id: grid-list-002 -->
- dimension: B - Behavior (A31 reactivity)
@ -28,7 +28,7 @@ Counts (post-verification): CRITICAL 0 · HIGH 2 · MEDIUM 1 · LOW 0.
- impact: Per-checkbox derived re-runs and calls isSelected (array .includes), multiplied by number of checkboxes in the list.
- proposed-fix: Same fix as grid-list-001: consume the lifted selectedSet instead of calling isSelected.
- verify: [confirmed] Confirmed A31 on the checkbox part. Lines 612-615: `readonly isChecked = $derived.by(() => { if (!this.rowValue) return false; return this.provider.isSelected(this.rowValue) })` calls the same O(N) linear-scan isSelected (line 151). Additionally line 636, inside the `props` $derived (632-651): `const checked = this.rowValue ? this.provider.isSelected(this.rowValue) : false` — a SECOND read of the linear scan per checkbox. Both deriveds depend on `value.current` via isSelected and re-run for every checkbox on any selection mutation. Same lifted-Set fix as 001. HIGH per SYS-7.
- fix-status: open
- fix-status: fixed (92f988e7)
### MEDIUM: SYS-1: Scope-drift when eidos recipe directory exists but 'eidos' is omitted from morfo.sc — grid-list-003 <!-- id: grid-list-003 -->
@ -18,7 +18,7 @@ Counts (post-verification): CRITICAL 0 · HIGH 1 · MEDIUM 1 · LOW 0.
- impact: Multi-select listboxes with 30+ items + selections will experience quadratic re-derivation cost. Single-select mode is unaffected (selection array ≤ 1 item). Performance degradation not caught by existing tests which max 3 items.
- proposed-fix: Lift isSelected as a provider-level $derived returning a Set<string> keyed by value, similar to rovingTargetEl pattern (line 148-153). Items would then do O(1) .has() checks instead of O(N) .includes() calls. Alternatively, compute a derived Set at provider level and expose it for item consumption.
- verify: [confirmed] CONFIRMED HIGH. Re-read listbox-provider.svelte.ts. Line 413 (per-item): `readonly isSelected = $derived.by(() => this.provider.isSelected(this.opts.value.current));`. Line 157-158 (provider method): `isSelected(value: string): boolean { return this.opts.value.current.includes(value); }` — reads GLOBAL `opts.value` and does O(value.length) Array.includes(). This is the textbook A31 pattern: every `value` mutation invalidates all N items' `isSelected` derivations, each re-running an O(K) array search (K = selection size). Multi-select: N items x O(N) = O(N²). Single-select: K<=1 so effectively O(N), unaffected. The asymmetry is the proof this is a genuine miss, not a design choice: `rovingTargetEl` (lines 148-153) was deliberately LIFTED to ONE provider-level $derived building `new Set(this.opts.value.current)` with an explicit comment (141-146) documenting 'avoids an O(N²) cascade' — yet the sibling `isSelected` reactive path was left calling the O(N) provider method per-item. Matches the documented Listbox-rovingTarget incident exactly (same provider). Tests max out at 3 items (alpha/beta/gamma, lines 167-212) with no large-N/perf test, so the hazard is unguarded. Fix per candidate is correct; cleanest form: provider-level `readonly selectedSet = $derived(new Set(this.opts.value.current))` consulted via `.has()` from `isSelected()`, mirroring the existing line-151 Set.
- fix-status: open
- fix-status: fixed (92f988e7)
### MEDIUM: SYS-1: Scope drift — eidos recipe directory exists but morfo.scope does not declare 'eidos — listbox-002 <!-- id: listbox-002 -->
- 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.root` entirely — the provider constructor already runs in a tracking context (the SAME constructor pattern uses bare `$effect` at 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.root` must be retained, wire its disposer into an `$effect(() => () => this.openedAtEffect())` teardown.
- verify: [verifier-added] added by adversarial verify pass
@ -19,7 +19,7 @@ Counts (post-verification): CRITICAL 0 · HIGH 1 · MEDIUM 2 · LOW 1.
- repro: Render 50+ tags; select/deselect tags rapidly and observe all item deriveds (which feed aria-selected, data-state, data-highlighted, tabindex) re-computing in O(N²) time.
- proposed-fix: Lift a Set-based selection cache onto TagGroupProvider (e.g., readonly selectedSet = $derived.by(() => new Set(this.opts.value.current))). Each item's isSelected becomes this.selectedSet.has(value) (O(1)).
- verify: [confirmed] CONFIRMED. tag-group-provider.svelte.ts:321 `readonly isSelected = $derived.by(() => this.provider.isSelected(this.opts.value.current));` is a per-item $derived calling provider.isSelected (lines 115-117: `return this.opts.value.current.includes(value);`) which reads the GLOBAL selection array and scans it with .includes() (O(N)). With N items each re-deriving an O(N) scan on every selection change → O(N²). This is NOT a lifted Set — it's a raw .includes() over the shared array, matching the A31/SYS-7 pattern exactly. The derived feeds aria-selected, data-state, data-highlighted and tabindex (props derived lines 346-366), so the whole item prop set re-computes quadratically. Identical hazard duplicated in TagGroupLinkProvider.isSelected (line 410). Fix as proposed (lift `selectedSet = $derived.by(() => new Set(this.opts.value.current))`, item does .has()).
- impact: O(N²) reactivity: when value[] or expanded[] arrays change, every row's isSelected and isExpanded deriveds re-run, each calling provider.isSelected()/isExpanded() which do .includes() lookups on those arrays. With 50+ rows and 10+ selections, this causes quadratic re-derives and array scans, manifesting as lag on selection/expansion.
- proposed-fix: Lift a Set on the provider: `readonly selectedSet = $derived(new Set(this.opts.value.current))` and `readonly expandedSet = $derived(new Set(this.opts.expanded.current))`. Each row then does O(1) `.has()` instead of O(N) `.includes()`. Per-item derived becomes `readonly isSelected = $derived.by(() => this.provider.selectedSet.has(this.opts.value.current))` → O(1) per row.
- verify: [confirmed] CONFIRMED A31 O(N²). tree-grid-provider.svelte.ts:578-579 per-row `readonly isSelected = $derived.by(() => this.provider.isSelected(this.opts.value.current));` and `readonly isExpanded = $derived.by(() => this.provider.isExpanded(this.opts.value.current));`. The provider methods read GLOBAL arrays via O(N) `.includes()`: line 188-190 `isSelected(value){ return this.opts.value.current.includes(value); }` and line 147-149 `isExpanded(value){ return this.opts.expanded.current.includes(value); }`. When the global `value[]` or `expanded[]` array mutates, every row's two deriveds re-run, each scanning the array → quadratic. NOT a lifted Set: the only Set in the file is the single provider-level `rovingTargetEl` (line 141), unrelated to selection/expansion. Matches the documented Listbox/Command A31 shape exactly. HIGH justified (two O(N) deriveds per row; degrades at 30+ rows on each selection/expansion). Fix as proposed: lift `selectedSet`/`expandedSet` `$derived(new Set(...))` on the provider and have rows do O(1) `.has()`.
@ -19,7 +19,7 @@ Counts (post-verification): CRITICAL 0 · HIGH 1 · MEDIUM 3 · LOW 0.
- repro: Render a tree with 100+ branches, many expanded. Toggle expand/collapse on a branch and observe UI lag proportional to item count.
- proposed-fix: Lift expandedValue and selectedValue from arrays to Sets (or use SvelteSet for reactivity). Change `isExpanded(value)` to `return this.opts.expandedSet.current.has(value)` (O(1) instead of O(K)). Update TreeViewOpts interface and consumer code to pass Set state.
- verify: [confirmed] CONFIRMED A31 O(N²). Per-branch derived `isExpanded` (tree-view-provider.svelte.ts:351) = `$derived.by(() => this.provider.isExpanded(this.opts.value.current))`; the provider method (96-98) = `this.opts.expandedValue.current.includes(value)` — reads the GLOBAL expandedValue array. `toggleExpand` (104) assigns a fresh array `this.opts.expandedValue.current = next`, invalidating every branch's derived → all N branch deriveds re-run, each doing O(K) `.includes()` = O(N·K) per expand mutation. Identical pattern for `isSelected` (353 / 569 → method 145-147 `selectedValue.current.includes`). This is the documented A31 incident shape (Listbox rovingTarget, Command). NOT a lifted set — it is a genuine per-item derived calling a global-array-reading method. Fix as proposed: lift to a Set (`.has()` O(1)). Confirmed HIGH. (Note: `tabStopValue` at line 60 is a CORRECT single provider-level derived read O(1) per item via `this.provider.tabStopValue === value` — not part of this hazard.)
- fix-status: open
- fix-status: fixed (92f988e7)
### MEDIUM: Morfo declares contract for all attributes soma emits — tree-view-001 <!-- id: tree-view-001 -->