fix(uix): fase A cierre — los seis rojos del censo morfo:check, adjudicados

Cada uno leído antes de tocarlo; tres clases distintas:

- combobox.input y gradient-builder.track: el morfo declara el data-* SIN
  value (soma posee el valor — el idioma que el propio trigger de combobox
  documenta) y nadie lo escribía. El comentario del input además afirmaba
  que la bolsa lo resolvía, cosa que una declaración sin fuente no puede
  hacer. Escritura manual, como sus hermanos.
- menu-dial.trigger: componente de scope eidos (contrato espejado a mano);
  el trigger estampaba marker y aria pero olvidó data-state — la lista de
  al lado sí lo lleva. Espejo del idioma existente.
- calendar (data-calendar-surface) y fab (data-fab-size): attrs visuales
  de wrapper / hooks compartidos de eidos, sancionados FUERA del morfo por
  la doctrina firmada (precedente: data-combobox-control). El instrumento
  no conocía la doctrina: nace scripts/eidos-only-attrs.ts como lista
  ÚNICA que consumen morfo-check y eidos-lint-all, para que los dos
  instrumentos no puedan divergir.
- palabras.handle: el grip porta el escape del focus-scope a propósito
  (comentario in situ con la razón); la declaración alcanza a la
  implementación sancionada.

Los otros cuatro rojos del censo previo no eran contrato: accordion,
background y link eran timeouts de compilación fría (pasan en caliente) y
color-field lo sanó la unificación de la bolsa por sí sola.

Verificación: morfo:check 160/161 con los seis en PASS — el único rojo
restante (natural-time-picker) es la clase flaky del instrumento «demo que
navega bajo el eval» (no falló en ninguna de las dos pasadas previas; la
ruta verificada a mano en navegador con su contrato completo) · schema
sweep 89/89 · eidos:lint exit 0 · combobox 11/11 · palabras 84/84.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alpha-0.1-background
dev 1 month ago
parent 3ca138e913
commit 9dffa541ef

@ -22,38 +22,9 @@ import {
loadEventVocabulary
} from './eidos-event-vocabulary';
const EIDOS_ONLY_ATTRS = new Set([
'data-archetype',
'data-block',
'data-collapsed',
'data-combobox-control',
'data-color',
'data-columns',
'data-dragging',
// The sema stamp (`src/uix/sema/stamp.ts`), not a morfo part. Allowlisted
// STRUCTURALLY only — the VALUE is validated against the morfo catalogue
// by `lintEventValues` below.
'data-event',
'data-event-direction',
'data-event-family',
'data-event-id',
'data-event-intent',
'data-event-phase',
'data-fitted',
'data-icon-only',
'data-indicator-placement',
'data-motion',
'data-motion-disabled',
'data-position',
'data-ready',
'data-scrollable',
'data-sheet',
'data-size',
'data-floating-wrapper',
'data-svg',
'data-svg-direction',
'data-variant'
]);
// Shared with morfo-check so the two instruments cannot drift (P0 fase A,
// audit 2026-08-26) — the list lives in ./eidos-only-attrs.ts.
import { EIDOS_ONLY_ATTRS } from './eidos-only-attrs';
const RUNTIME_SIGNAL_ATTRS = new Set([
'data-disabled',

@ -0,0 +1,49 @@
/**
* Cross-cutting attrs the EIDOS layer owns — stamped by wrappers or shared
* styling hooks, deliberately NOT part of any morfo contract (the signed
* "visual wrapper attrs do not enter the morfo" doctrine; `data-combobox-
* control` set the precedent for hooks that squat a component's namespace).
*
* Consumed by BOTH instruments so they cannot drift apart (P0 fase A, audit
* 2026-08-26): `eidos-lint-all` classifies CSS selectors with it, and
* `morfo-check` skips these names in its undeclared-attr sweep.
*/
export const EIDOS_ONLY_ATTRS = new Set([
'data-archetype',
'data-block',
// Shared calendar-surface sizing hook (`src/uix/eidos/lib/calendar-surface.css`):
// calendar / month-grid / year-grid / range-calendar wrappers stamp it.
// Squats the calendar namespace the way data-combobox-control squats combobox's.
'data-calendar-surface',
'data-collapsed',
'data-combobox-control',
'data-color',
'data-columns',
'data-dragging',
// The sema stamp (`src/uix/sema/stamp.ts`), not a morfo part. Allowlisted
// STRUCTURALLY only — the VALUE is validated against the morfo catalogue
// by `lintEventValues` in eidos-lint-all.
'data-event',
'data-event-direction',
'data-event-family',
'data-event-id',
'data-event-intent',
'data-event-phase',
// Fab's wrapper visual axis (`fab.svelte` stamps it next to data-extended;
// the recipe sizes off it). Wrapper attr, not contract.
'data-fab-size',
'data-fitted',
'data-icon-only',
'data-indicator-placement',
'data-motion',
'data-motion-disabled',
'data-position',
'data-ready',
'data-scrollable',
'data-sheet',
'data-size',
'data-floating-wrapper',
'data-svg',
'data-svg-direction',
'data-variant'
]);

@ -22,6 +22,7 @@
*/
import { chromium, type Page } from 'playwright';
import { EIDOS_ONLY_ATTRS } from './eidos-only-attrs';
import { existsSync, readdirSync } from 'node:fs';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { dirname, join } from 'node:path';
@ -71,9 +72,7 @@ async function probePort(start: number, end: number): Promise<string | null> {
}
async function loadMorfos(): Promise<Morfo[]> {
const files = readdirSync(MORFOS_DIR).filter(
(f) => f.endsWith('.ts') && !f.endsWith('.test.ts')
);
const files = readdirSync(MORFOS_DIR).filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts'));
const out: Morfo[] = [];
for (const f of files) {
// Use file:// URL on Windows — absolute paths starting with "g:" are
@ -144,9 +143,7 @@ async function validateMorfoAgainstDom(page: Page, morfo: Morfo): Promise<Issue[
declared.add(partAttr); // the part-identifying attr itself
// Sibling parts' identifying attrs are legitimate (Link-is-Item pattern).
for (const siblingAttr of allPartAttrs) declared.add(siblingAttr);
const declaredByAttr = new Map<string, MorfoData>(
part.data.map((d) => [d.attr, d])
);
const declaredByAttr = new Map<string, MorfoData>(part.data.map((d) => [d.attr, d]));
for (const elAttrs of elementsAttrs) {
// (a) required attrs present
@ -177,6 +174,11 @@ async function validateMorfoAgainstDom(page: Page, morfo: Morfo): Promise<Issue[
if (!name.startsWith(`data-${morfo.kebab}`) && !name.startsWith('data-')) continue;
if (name.startsWith(`data-_`)) continue; // private escape hatch
if (declared.has(name)) continue;
// Eidos-owned cross-cutting hooks (wrapper visual attrs, shared
// styling surfaces) are sanctioned OUTSIDE the morfo — the signed
// wrapper-attrs doctrine. One list, shared with eidos-lint-all,
// so the two instruments agree (P0 fase A, audit 2026-08-26).
if (EIDOS_ONLY_ATTRS.has(name)) continue;
// Allow other components' part attrs (nested composition).
if (!name.startsWith(`data-${morfo.kebab}`)) continue;
issues.push({

@ -341,6 +341,7 @@
aria-haspopup="menu"
aria-expanded={open}
data-menu-dial-trigger=""
data-state={open ? 'open' : 'closed'}
onclick={toggle}
onkeydown={onTriggerKeydown}
icon={triggerIcon}

@ -1027,7 +1027,15 @@ export const palabrasMorfo = {
kind: 'public',
defaultElement: 'button',
optional: true,
data: [],
data: [
// The grip is a sanctioned carrier of the focus-scope escape hatch
// (see the component-level declaration): click opens the properties
// panel, so focus landing on the grip must not read as an external
// blur. The stamp shipped in `palabras-handle.svelte` with its
// reason; the declaration catches up (morfo:check, P0 fase A,
// audit 2026-08-26).
{ attr: 'data-palabras-external-tool' }
],
aria: []
},
{

@ -18,10 +18,7 @@ import { Soma } from '../../core/soma.svelte';
import { Presence } from '../../layers/presence.svelte';
import { Dismissal, type DismissalBehavior } from '../../layers/dismissal.svelte';
import {
computeListSelection,
resolveListItemEl
} from '../../layers/list-selection';
import { computeListSelection, resolveListItemEl } from '../../layers/list-selection';
import {
FloatingProvider,
FloatingContent,
@ -350,7 +347,7 @@ export class ComboboxProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
...this.runtimePart.props
// Raw, so no assertion leaves the root inheriting. The in-place half paints
// from it: the control's flex row (input / trigger / clear-button order) and
// the chip strip's asymmetric `padding-inline` + `margin-inline-start`. The
@ -381,7 +378,7 @@ export class ComboboxInputProvider {
owner: this,
// Per-part source for the morfo's `aria-activedescendant` (propRef
// 'highlightedId'). The component-level sources don't carry it, so
// register it here → `renderProps()` resolves it from the morfo
// register it here → `the render bag` resolves it from the morfo
// instead of the provider re-declaring it.
props: { highlightedId: () => this.provider.highlightedId || undefined }
});
@ -486,11 +483,16 @@ export class ComboboxInputProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
// Morfo-declared: role, aria-haspopup/expanded/controls/activedescendant/
// required/autocomplete, data-state — resolved against this part's
// registered sources (incl. the per-part `highlightedId`).
...this.runtimePart.renderProps(),
// Soma-only: what the morfo can't express.
// required/autocomplete — resolved against this part's registered
// sources (incl. the per-part `highlightedId`).
...this.runtimePart.props,
// Soma-only: what the morfo can't express, plus the data-* the morfo
// declares WITHOUT a value (soma owns the value — the trigger's own
// idiom; the old comment claimed the bag resolved data-state, which a
// valueless declaration cannot do. morfo:check caught the gap, P0
// fase A, audit 2026-08-26).
type: 'text' as const,
'data-state': getDataOpenClosed(this.provider.opts.open.current),
'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current),
disabled: this.provider.opts.disabled.current || undefined,
value: this.provider.opts.inputValue.current,
@ -505,7 +507,6 @@ export class ComboboxInputProvider {
interface ComboboxTriggerOpts extends WithRefOpts {}
export class ComboboxTriggerProvider {
readonly opts: ComboboxTriggerOpts;
readonly runtimePart: SomaRuntimePart<typeof comboboxMorfo, 'trigger'>;
static create(opts: ComboboxTriggerOpts) {
@ -533,7 +534,7 @@ export class ComboboxTriggerProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
// Morfo-declared: type, aria-label/haspopup/expanded/controls.
...this.runtimePart.renderProps(),
...this.runtimePart.props,
// Soma-only: native tabindex, the data-* the morfo declares WITHOUT a
// value (soma owns the value), the native disabled attr, the handler.
tabindex: -1,
@ -685,7 +686,7 @@ export class ComboboxContentProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
// Morfo-declared: role, aria-labelledby, aria-multiselectable.
...this.runtimePart.renderProps(),
...this.runtimePart.props,
// Soma-only: data-state (morfo declares it WITHOUT a value), the
// floating/presence/dismissal layer attrs, native tabindex.
'data-state': getDataOpenClosed(this.provider.opts.open.current),
@ -755,7 +756,7 @@ export class ComboboxItemProvider {
owner: this,
// Per-part state source for the morfo's `aria-selected` (stateRef
// 'checked'). Each Item carries its own selection state, so it's
// registered per-instance here → `renderProps()` resolves
// registered per-instance here → `the render bag` resolves
// aria-selected from the morfo instead of the provider re-declaring it.
states: { checked: () => this.isSelected }
});
@ -791,7 +792,7 @@ export class ComboboxItemProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
// Morfo-declared: role, aria-selected (from the per-part `checked` state).
...this.runtimePart.renderProps(),
...this.runtimePart.props,
// Soma-only: native tabindex + the data-* the morfo declares WITHOUT a
// value (soma owns value/label/highlighted/disabled/state) + handlers.
tabindex: this.opts.disabled.current ? undefined : -1,
@ -838,7 +839,7 @@ export class ComboboxGroupProvider {
context: ComboboxGroupProvider.ctx,
// Per-part source for the morfo's `aria-labelledby` (partRef
// 'group-heading'). The heading id is per-group-instance (set by the
// nested GroupHeading), so it's registered here → `renderProps()`
// nested GroupHeading), so it's registered here → `the render bag`
// resolves aria-labelledby + its part-present condition from the morfo.
parts: { 'group-heading': () => this.headingId.current || undefined }
});
@ -847,7 +848,7 @@ export class ComboboxGroupProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
// Morfo-declared: role, aria-labelledby (from the per-part heading id).
...this.runtimePart.renderProps()
...this.runtimePart.props
} as const)
);
}
@ -909,7 +910,7 @@ export class ComboboxSeparatorProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
// Morfo-declared: role. No soma-specific extras.
...this.runtimePart.renderProps()
...this.runtimePart.props
} as const)
);
}
@ -972,7 +973,7 @@ export class ComboboxSelectedTagsProvider {
readonly props = $derived.by(() =>
// Morfo-declared: role, aria-label (translationRef 'selected-tags'),
// data-empty (propRef 'selectedTagsEmpty'). No soma-specific extras.
this.runtimePart.assert({ ...this.runtimePart.renderProps() } as const)
this.runtimePart.assert({ ...this.runtimePart.props } as const)
);
}
@ -1065,7 +1066,7 @@ export class ComboboxSelectedTagRemoveProvider {
this.runtimePart.assert({
// Morfo-declared: type, aria-label (translationRef 'remove' — the morfo
// declares it; previously the provider never applied it).
...this.runtimePart.renderProps(),
...this.runtimePart.props,
// Soma-only: data-disabled (morfo declares it WITHOUT a value), the
// native disabled attr, the handler.
'data-disabled': boolToEmptyStrOrUndef(this.disabled),
@ -1106,7 +1107,7 @@ export class ComboboxClearButtonProvider {
this.runtimePart.assert({
// Morfo-declared: type, aria-label (translationRef 'clear'), data-disabled
// (propRef 'clearDisabled' = disabled || isEmpty).
...this.runtimePart.renderProps(),
...this.runtimePart.props,
// Soma-only: native tabindex + the native disabled attr + the handler.
tabindex: -1,
disabled: this.provider.opts.disabled.current || this.provider.isEmpty || undefined,

@ -257,7 +257,7 @@ export class GradientBuilderProvider {
/** Provider element attrs — morfo-resolved + `data-kind` (set imperatively). */
get props() {
return { ...this.runtimePart.renderProps(), 'data-kind': this.kind };
return { ...this.runtimePart.props, 'data-kind': this.kind };
}
/** Map a pointer x to a position 0..1 along the registered track. */
@ -312,7 +312,7 @@ export class GradientStopProvider {
context: GradientStopProvider.ctx,
syncAttrs: true,
// Per-stop sources for the morfo's value-bound aria (aria-valuenow /
// aria-valuetext / aria-label) — resolved via `renderProps()`.
// aria-valuetext / aria-label) — resolved via `the render bag`.
props: {
position: () => this.position,
valueText: () => this.valueText,
@ -342,7 +342,7 @@ export class GradientStopProvider {
/** Element attrs — morfo-resolved aria + state attrs + the drag/keyboard handlers. */
get props() {
return {
...this.runtimePart.renderProps(),
...this.runtimePart.props,
tabindex: 0,
'data-selected': boolToEmptyStrOrUndef(this.isSelected),
'data-dragging': boolToEmptyStrOrUndef(this.provider.activeDragIndex === this.index),
@ -450,7 +450,14 @@ export class GradientTrackProvider {
// click on the bare track inserts a stop at the pointer position. Clicks
// that land on a Stop are ignored here (the Stop owns select / edit / drag);
// the coordinate-less Add button stays as the fallback.
return { ...this.runtimePart.renderProps(), onclick: this.onclick };
return {
...this.runtimePart.props,
// The morfo declares `data-kind` WITHOUT a value — soma owns it, same
// idiom as preview's hand stamp. It was never written; morfo:check
// caught the required-attr gap (P0 fase A, audit 2026-08-26).
'data-kind': this.provider.kind,
onclick: this.onclick
};
}
private onclick = (e: MouseEvent): void => {

Loading…
Cancel
Save

Powered by TurnKey Linux.