fix(uix): audit Phase 2 — 5 isolated HIGH findings

- alert-dialog: `: Morfo` -> `as const satisfies Morfo` (restores createAttrs
  key narrowing; the only morfo using the loose annotation). [alert-dialog-001]
- breadcrumb: drop `archetype:'item'` from the display <li> Item part — it
  pulled cursor:pointer + hover from archetypes.css onto a non-clickable
  container (false affordance). Matches Timeline's pattern. [breadcrumb-001]
- navigation-menu: replace the leaked `$effect.root` (never disposed) with a
  bare constructor `$effect` that auto-disposes with the component scope. [navigation-menu-006]
- select: `scrollSelectedIntoView` now reuses `resolveItemEl` (which CSS.escapes
  the consumer value) instead of an inline unescaped querySelector. [select-001]
- dialog: delete the dead `[data-dialog-trigger]` chrome envelope (the trigger
  composes <Button>, so it fought Button's recipe on the same element) + its
  orphaned `trigger-font-size` recipe token; regen eidos css. [dialog-002]

Verified: 12/12 provider tests, morfo:check pass, svelte-check 0 errors in
touched files, eidos recipe contract unchanged (3 failures pre-existing/palabras),
browser-verified dialog trigger (solid Button) + breadcrumb (li cursor:auto).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
active-uix
dev 3 months ago
parent 92f988e7e7
commit 98954a7c35

@ -13,45 +13,12 @@
*/
/* ── Trigger ───────────────────────────────────────────────────────────────
* Air's dialog relied on the Button recipe (`.air-button` class on the
* trigger). Eidos has no class composition — the trigger needs its own
* envelope here. Consumes Capa 2 tokens (color/space/radius/control-height)
* directly because the trigger is not a separate component with its own
* Capa 3, just a part of Dialog using the system surface.
* The trigger composes the framework `<Button>` (eidos `dialog-trigger.svelte`
* renders `<Button {...props}>` via soma's `child`), so ALL its chrome —
* layout, bg, hover, focus-ring, disabled — comes from the Button recipe. No
* bespoke `[data-dialog-trigger]` envelope here: it would paint the SAME
* element as `[data-button]` and fight it (the Close recipe is the model).
*/
[data-dialog-trigger] {
display: inline-flex;
align-items: center;
justify-content: center;
height: var(--control-height-md);
padding-inline: var(--space-3);
border: var(--border-width) solid var(--color-border-default);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
color: var(--color-content-primary);
font: inherit;
font-size: var(--dialog-trigger-font-size);
cursor: pointer;
transition:
background var(--duration-fast) var(--ease-default),
border-color var(--duration-fast) var(--ease-default);
}
[data-dialog-trigger]:hover:not([disabled]):not([data-disabled]) {
background: var(--color-surface-overlay);
border-color: var(--color-border-strong);
}
[data-dialog-trigger][data-state='open'] {
background: var(--color-surface-overlay);
border-color: var(--color-border-strong);
}
[data-dialog-trigger]:disabled,
[data-dialog-trigger][data-disabled] {
cursor: default;
opacity: var(--opacity-disabled);
}
/* ── Overlay ───────────────────────────────────────────────────────────── */

@ -984,7 +984,6 @@
--collapsible-transition-duration: var(--duration-normal);
--collapsible-transition-ease: var(--ease-default);
--collapsible-disabled-opacity: var(--opacity-disabled);
--dialog-trigger-font-size: var(--font-size-sm);
--dialog-overlay-bg: var(--color-overlay);
--dialog-overlay-opacity: 62%;
--dialog-overlay-blur: var(--blur-lg);

@ -724,7 +724,6 @@ export const THEME_BASE_RECIPE_TOKENS = {
'disabled-opacity': 'var(--opacity-disabled)'
},
dialog: {
'trigger-font-size': 'var(--font-size-sm)',
'overlay-bg': 'var(--color-overlay)',
'overlay-opacity': '62%',
'overlay-blur': 'var(--blur-lg)',

@ -1,7 +1,7 @@
import type { Morfo } from '../types';
import { v } from '../types';
export const alertDialogMorfo: Morfo = {
export const alertDialogMorfo = {
name: 'AlertDialog',
kebab: 'alert-dialog',
// Eidos wrapper added 2026-05-22 (`src/uix/eidos/components/alert-dialog/`).
@ -73,4 +73,4 @@ export const alertDialogMorfo: Morfo = {
]
}
]
};
} as const satisfies Morfo;

@ -45,9 +45,12 @@ export const breadcrumbMorfo = {
aria: []
},
{
// No `archetype: 'item'` — that archetype ships interactive row CSS
// (cursor:pointer, user-select:none, hover) via archetypes.css; a
// breadcrumb Item is a display `<li>` container (the Link inside it is
// the interactive part). Same reasoning as Timeline's Item.
name: 'Item',
kebab: 'item',
archetype: 'item',
kind: 'public',
defaultElement: 'li',
optional: false,

@ -501,6 +501,13 @@ export class NavigationMenuTriggerProvider {
}
});
this.item.triggerId.current = opts.id.current;
// Stamp the last `isOpen → true` time (see `lastOpenedAt`). A bare
// `$effect` in the constructor auto-disposes with the component scope —
// this was previously a leaked, never-disposed `$effect.root` (A6).
$effect(() => {
if (this.isOpen) this.lastOpenedAt = Date.now();
});
}
readonly isOpen = $derived.by(() => this.item.isOpen);
@ -513,19 +520,12 @@ export class NavigationMenuTriggerProvider {
* If a click arrives within ~250ms of the open we treat it as the
* same intent (commit the open) and ignore the close-toggle.
*
* `$effect` watches `isOpen` and stamps the time whenever it flips
* from false → true, regardless of whether the open came from hover
* scheduling, click, or keyboard. Tracked per-trigger because each
* The stamping `$effect` lives in the constructor (a bare, auto-disposed
* effect) — see there. Tracked per-trigger because each
* NavigationMenuTriggerProvider is an independent instance.
*/
private lastOpenedAt = 0;
openedAtEffect = $effect.root(() => {
$effect(() => {
if (this.isOpen) this.lastOpenedAt = Date.now();
});
});
readonly onpointerenter = () => {
if (this.opts.disabled.current) return;
if (!this.provider.opts.hoverEnabled.current) return;

@ -244,9 +244,9 @@ export class SelectProvider {
const selectedValue = this.opts.value.current[0];
if (!selectedValue) return;
const selectedEl = container.querySelector<HTMLElement>(
`[${attrs.item}][data-value="${selectedValue}"]`
);
// Reuse the shared resolver (it `CSS.escape`s the consumer value) instead
// of an inline querySelector — `selectedValue` is consumer-supplied (C).
const selectedEl = this.resolveItemEl(selectedValue);
if (!selectedEl) return;
this.soma.dom.scrollIntoView(selectedEl, { block: this.opts.scrollAlignment.current });

Loading…
Cancel
Save

Powered by TurnKey Linux.