fix(morfo,soma): `defaultElement` era un contrato que no vigilaba nadie

D7 y D8 no eran dos defectos: eran dos instancias de la misma clase. Un censo
del catalogo encontro que `defaultElement` MIENTE en decenas de partes, y es un
contrato — `assertContract`, las recetas y quien lee el morfo lo toman como el
tag que aterriza en el DOM.

D7 · cinco Labels, no dos
-------------------------

El morfo declara `label` y se renderizaba `<div>` en date-field,
date-range-field, time-field, time-range-field y color-picker.

La otra mitad de la fila era FALSA: decia que la doc promete un par `for`/`id`
que nadie emite. No existe tal promesa — `date-field/types.ts:158` dice lo
contrario y bien («Prefer `aria-labelledby` with a `DateField.Label`»). La fila
habia mezclado el hallazgo del `Field` generico, que si emite `for` condicional.
El nombre accesible ya estaba cableado por `aria-labelledby` desde el
`role="group"` (que es el mecanismo correcto: `for=` no puede apuntar a un
grupo) y el clic-a-enfocar ya lo hacia el provider.

Riesgo comprobado antes de tocar: `<label>` es `inline` y `<div>` es `block`.
Dos reciben `display: inline-flex` de `field.css`; los otros tres son items de
flex o grid y el navegador los blockifica. Medido despues en los cinco demos:
ninguno queda `inline`, ninguno arrastra un `for` que no pueda honrar, y la
maquetacion no se mueve.

D8 · los siete arrow, no uno
----------------------------

La sospecha de deuda sistemica era cierta. Los SIETE declaraban `div` y ninguno
renderiza un `div`: cinco componen `internal/arrow.svelte`, cuyo elemento de
parte es un `<span>`, y dos rinden un `<svg>` propio. Cada morfo declara ahora
lo que su componente renderiza.

El guard, y el falso verde que casi cuela
-----------------------------------------

Sin guard esto vuelve a derivar, asi que hay uno en `contracts.test.ts` con las
24 partes restantes enumeradas en `DEFAULT_ELEMENT_DEBT` — visible y sin poder
crecer — mas un detector anti-podredumbre que falla si una entrada de la lista
deja de estar rota.

El primer borrador se fue en FALSO VERDE. Tomaba «el tag del ultimo `{:else}`»,
y el `<svg>` decorativo de `internal/arrow.svelte` no lleva spread: la busqueda
fallaba, el tag volvia `undefined` y una regresion inyectada en `popover.arrow`
PASABA. Se descubrio inyectandola antes de fiarse. La regla correcta es que el
elemento de la parte es EL QUE DIFUNDE LOS PROPS. Con ella el guard encontro 8
violaciones mas que el censo a mano.

Segunda correccion: hay partes polimorficas por diseño — `media-player.media`
rinde `audio` o `video`, `breadcrumb.link` `span` o `a`. El guard lee TODAS las
ramas y da el contrato por bueno si el declarado esta entre ellas; leer solo la
primera las marcaba como mentira. Eso saco a `chat-composer.input` de la lista
de deuda, donde yo lo habia metido por error — lo dijo el propio detector.

Las 24 que quedan (D11) se parten en dos y por eso no van en bloque: unas el
morfo va detras del componente (arreglo mecanico), otras el componente va detras
del morfo con peso de a11y — `tree-view.item` declara `li` y rinde `div`,
`stepper.list` declara `ol`, `tooltip.trigger` declara `button`. Eso es
semantica de lista y de control: son decisiones, no ediciones.

Queda abierto D12, tambien del censo: `internal/arrow.svelte` dibuja poligono
MAS un contorno trazado, y las dos flechas propias solo el poligono. Dos de
siete flechas del sistema no tienen contorno. El arreglo es que compongan la
primitiva compartida, pero es un cambio visual y no se improvisa.

Verificado: 2240 tests del uix en verde (los 6 fallos de `contracts.test.ts` que
se ven en el arbol son de la otra sesion — waveform, media-player, audio-player,
menubar, aura, radio-group, tabs — y el guard nuevo no esta entre ellos);
`check` 70 errores, linea base; `docs:check` 0/623.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-dir-prefs
dev 2 months ago
parent 94eb5fce59
commit 114a2dd633

@ -50,8 +50,10 @@ Estados: `CONFIRMADO` (medido, abierto) · `ARREGLADO` (con sha) ·
| D4 | ColorPicker | `ARREGLADO` `f4e414e2e` | P0 | swatch y eyedropper commitean en silencio |
| D5 | picker-shell | `CONFIRMADO` | P1 | `mode='modal'` promete bloquear Escape y no lo bloquea |
| D6 | date/time-field | `CONFIRMADO` | P1 | `Home`/`End`/`PageUp`/`PageDown` documentados en 4 sitios, nunca construidos |
| D7 | date/time-field | `CONFIRMADO` | P1 | el Label renderiza `<div>` y el morfo declara `label` |
| D8 | context-menu | `CONFIRMADO` | P2 | `defaultElement` falso en `arrow` — posible deuda sistémica |
| D7 | 5 componentes de campo | `ARREGLADO` | P1 | el Label renderizaba `<div>` y el morfo declara `label` |
| D8 | los 7 `arrow` | `ARREGLADO` | P2 | `defaultElement` falso en el `arrow` — sistémico, confirmado |
| D11 | catálogo | `CONFIRMADO` | P1 | `defaultElement` miente en 24 partes más — enumeradas y guardadas |
| D12 | context-menu · link-preview | `CONFIRMADO` | P2 | sus flechas se dibujan SIN el trazo de contorno que llevan las otras cinco |
| D9 | chronos | `CONFIRMADO` | P2 | el morfo declara `handle-drag` y `handle-resize`; nadie los dispara |
| D10 | sema ↔ `$sound` | `CONFIRMADO` | P1 | los 3 resolvers de gesto: migración a medias del 2026-08-06 |
@ -128,20 +130,84 @@ existen en el código. Peor: **las tres páginas se contradicen** sobre qué har
(saltar de segmento, saltar al extremo del rango, o paginar el valor). No es
sólo una funcionalidad que falta: es una decisión que nadie tomó.
### D7 · El Label de date/time-field — `CONFIRMADO` · P1
[`date-field-label.svelte:32`](../../src/uix/soma/components/date-field/components/date-field-label.svelte)
renderiza `<div>`; el morfo declara `defaultElement: 'label'`
([`morfo/components/date-field.ts:145`](../../src/uix/morfo/components/date-field.ts)).
Y la doc promete un par `for`/`id` que nadie emite. Un `<div>` no asocia: el
campo se queda sin nombre accesible por esta vía.
### D8 · `defaultElement` falso en context-menu — `CONFIRMADO` · P2
[`morfo/components/context-menu.ts:345`](../../src/uix/morfo/components/context-menu.ts)
declara `defaultElement: 'div'` para `arrow`, y lo renderizado es
`<span>`/`<svg>`. Sospecha de **deuda sistémica en todos los `arrow`** del
catálogo — no verificada; verificarla es parte de cerrar esta fila.
### D7 · El Label de los campos renderizaba `<div>` — `ARREGLADO` (2026-08-12)
El morfo declara `defaultElement: 'label'` y el componente renderizaba `<div>`.
No eran dos componentes sino **cinco**: date-field, date-range-field, time-field,
time-range-field y color-picker.
⚠️ **La otra mitad de esta fila era falsa.** Decía «la doc promete un par
`for`/`id` que nadie emite»; no existe tal promesa en date-field —
[`types.ts:158`](../../src/uix/soma/components/date-field/types.ts) dice lo
contrario y bien: *«Prefer `aria-labelledby` with a `DateField.Label`»*. La fila
había mezclado el hallazgo del **`Field` genérico** (`field.md:19`, que sí emite
`for` condicional) con el date-field. El nombre accesible ya estaba cableado por
`aria-labelledby` desde el `role="group"` — que es el mecanismo correcto, porque
`for=` no puede apuntar a un grupo — y el clic-a-enfocar ya lo implementaba
`DateFieldLabelProvider.onclick` (enfoca el primer segmento).
Riesgo comprobado antes de tocar, no supuesto: `<label>` es `inline` por defecto
y `<div>` es `block`. Dos de los cinco reciben `display: inline-flex` de
`field.css` vía `fieldRole('label')`; los otros tres son ítems de flex o grid, y
el navegador los blockifica. Medido después en los cinco demos: ninguno queda en
`inline`, ninguno arrastra un `for` que no pueda honrar, y la maquetación no se
mueve.
### D8 · `defaultElement` falso en los `arrow` — `ARREGLADO` (2026-08-12)
La sospecha de deuda sistémica era cierta y peor de lo escrito: **los siete
`arrow` del catálogo declaraban `div` y ninguno renderiza un `div`.** Cinco
(combobox, dropdown-menu, popover, select, tooltip) componen
[`components/internal/arrow.svelte`](../../src/uix/soma/components/internal/arrow.svelte),
cuyo elemento de parte es un `<span>`; dos (context-menu, link-preview) rinden un
`<svg>` propio. Cada morfo declara ahora lo que su componente renderiza.
### D11 · `defaultElement` miente en 24 partes más — `CONFIRMADO` · P1
El censo que cerró D8 destapó la clase entera: **nada verificaba
`defaultElement`**, y es un contrato — `assertContract`, las recetas y quien lee
el morfo lo toman como el tag que aterriza en el DOM.
Hay un guard desde el 2026-08-12
([`contracts.test.ts`](../../src/uix/contracts.test.ts), «guards defaultElement
against the tag the soma part actually renders»), con las 24 restantes
enumeradas en `DEFAULT_ELEMENT_DEBT` para que la deuda sea visible y no crezca.
El guard lleva además un detector anti-podredumbre: si una entrada de la lista
deja de estar rota, falla pidiendo que se retire.
Las 24 se parten en dos, y por eso no se arreglan en bloque:
- **El componente renderiza lo más correcto y el morfo va detrás** —
`calendar.header` / `range-calendar.header` (`header`), `field.helper-text` /
`field.error-text` (`p`), los cuatro de `slider` (`span`), `chronos.event-chip`
(`button`)… Arreglo mecánico: poner al día el morfo.
- **El morfo declara lo más correcto y el componente va detrás, con peso de
a11y** — `tree-view.item` / `.branch` declaran `li` y rinden `div`,
`tree-view.branch-content` declara `ul`, `stepper.list` declara `ol`,
`file-upload.file-list` declara `ul`, `feed.article` declara `article`,
`tooltip.trigger` declara `button`. Rendirlos como `div` pierde la semántica
de lista / landmark / control que APG espera. **Son decisiones, no ediciones.**
⚠️ Notas de método, por si alguien reescribe el guard: (1) el elemento de la
parte es **el que difunde los props**, no «el tag del último `{:else}`» — el
primer borrador usó lo segundo y se fue en FALSO VERDE, porque el `<svg>`
decorativo de `internal/arrow.svelte` no lleva spread; se descubrió inyectando
una regresión antes de fiarse. (2) Hay partes **polimórficas por diseño**
(`media-player.media` rinde `audio` o `video`, `breadcrumb.link` `span` o `a`):
el guard lee TODAS las ramas y da el contrato por bueno si el declarado está
entre ellas.
### D12 · Dos flechas se dibujan distinto que las otras cinco — `CONFIRMADO` · P2
Salió al censar D8. `internal/arrow.svelte` dibuja `<polygon>` **más** un
`<path>` de contorno trazado; las flechas propias de
[`context-menu`](../../src/uix/soma/components/context-menu/components/context-menu-arrow.svelte)
y [`link-preview`](../../src/uix/soma/components/link-preview/components/link-preview-arrow.svelte)
dibujan sólo el polígono. Dos de siete flechas del sistema no tienen contorno.
El arreglo de referencia es que compongan la primitiva compartida — una flecha,
una forma — y entonces las siete declararían `span`. **Es un cambio visual en dos
componentes**, así que no se toca sin decisión: no se improvisa lo visual.
### D9 · Eventos de chronos sin emisor — `CONFIRMADO` · P2

@ -303,6 +303,122 @@ function collectMorfoDeclarationViolations(): string[] {
return violations.sort();
}
// D8: `defaultElement` is a CONTRACT — `assertContract`, the eidos recipes and
// anyone reading the morfo take it as the tag that lands in the DOM. Nothing
// enforced it, and a census on 2026-08-12 found 32 parts where it lied. Seven
// were the whole `arrow` family (all declaring `div`; five render the shared
// primitive's `<span>`, two an `<svg>` of their own) and five were field labels
// declaring `label` while rendering `<div>`. Both are fixed; the rest are in
// `DEFAULT_ELEMENT_DEBT` below because each needs a per-row decision about
// WHICH side is wrong, and several are a11y calls (a declared `li` rendered as
// a `div` loses the list semantics APG expects). Ledger: D8 / D11.
const DEFAULT_ELEMENT_DEBT = new Set<string>([
// The component renders the MORE correct element — the morfo is behind.
'calendar.header',
'range-calendar.header',
'command.separator',
'context-menu.trigger',
'field.helper-text',
'field.error-text',
'field.prefix',
'field.suffix',
'rating-group.item',
'slider.range',
'slider.secondary-range',
'slider.thumb',
'slider.tick',
'color-picker.value-swatch',
'chronos.event-chip',
'media-player.time',
'pin-input.cell',
// The morfo declares the more correct element — the COMPONENT is behind,
// and these carry a11y weight (list / landmark / button / control semantics).
'color-picker.channel-input',
'feed.article',
'file-upload.file-list',
'file-upload.item',
'stepper.list',
'stepper.item',
'tooltip.trigger',
'tree-view.branch',
'tree-view.branch-content',
'tree-view.branch-indicator',
'tree-view.item'
]);
/**
* The tag a soma part component renders as the PART element — the one that
* spreads the merged props. Follows one level of indirection (`<Arrow …>` →
* `components/internal/arrow.svelte`), so parts that delegate to a shared
* primitive are covered rather than silently skipped: that hole is what let the
* whole `arrow` family drift unnoticed.
*
* The rule is the SPREAD, not "the tag in the last `{:else}`". The first draft
* used the latter and went FALSE-GREEN: `internal/arrow.svelte`'s last `{:else}`
* holds a decorative `<svg {width} {height}>` with no spread, so the match
* failed, the tag came back `undefined`, and an injected regression on
* `popover.arrow` passed the guard. Caught by injecting one before trusting it.
*/
const PART_ELEMENT = /<([A-Za-z][A-Za-z0-9]*)\s+\{\.\.\.\w*[Pp]rops/g;
/**
* ALL of them, not the first: a part can be polymorphic by design —
* `media-player.media` renders `<audio>` or `<video>` by media kind,
* `breadcrumb.link` a `<span>` for the current page and an `<a>` otherwise.
* `defaultElement` is the DEFAULT, so the contract holds when the declared tag
* is among the branches; reading only the first would flag both as lies.
*/
function renderedTags(file: string, depth = 0): string[] {
if (!existsSync(file)) return [];
const source = readFileSync(file, 'utf8');
const out: string[] = [];
for (const [, tag] of source.matchAll(PART_ELEMENT)) {
if (tag[0] === tag[0].toLowerCase()) {
out.push(tag);
continue;
}
if (depth > 0) continue;
const spec = new RegExp(`import\\s+${tag}\\s+from\\s+'([^']+)'`).exec(source)?.[1];
if (spec) out.push(...renderedTags(join(dirname(file), spec), depth + 1));
}
return [...new Set(out)];
}
function collectDefaultElementViolations(): string[] {
const morfoDir = join(HERE, 'morfo', 'components');
const somaDir = join(HERE, 'soma', 'components');
const violations: string[] = [];
const fixed: string[] = [];
for (const file of listSourceFiles(morfoDir)) {
const component = basename(file, '.ts');
const source = stripComments(readFileSync(file, 'utf8'));
const parts = /kebab:\s*'([a-z0-9-]+)'[\s\S]{0,400}?defaultElement:\s*'([a-z0-9]+)'/g;
let match: RegExpExecArray | null;
while ((match = parts.exec(source))) {
const [, part, declared] = match;
const rendered = renderedTags(
join(somaDir, component, 'components', `${component}-${part}.svelte`)
);
if (rendered.length === 0) continue;
const id = `${component}.${part}`;
if (rendered.includes(declared)) {
if (DEFAULT_ELEMENT_DEBT.has(id)) fixed.push(id);
continue;
}
if (DEFAULT_ELEMENT_DEBT.has(id)) continue;
const shape = rendered.map((t) => `<${t}>`).join(' | ');
violations.push(`${id}: morfo says <${declared}>, component renders ${shape}`);
}
}
// A debt list that outlives its debt is a lie in the other direction.
for (const id of fixed.sort()) {
violations.push(`${id}: FIXED — remove it from DEFAULT_ELEMENT_DEBT`);
}
return violations.sort();
}
// A31: a per-item membership predicate (`isSelected` / `isItemChecked` /
// `isExpanded` / `isItemPressed` / …) must NOT read a reactive array with
// `.current.includes(...)` / `.indexOf(...)` — called once per item that is
@ -686,6 +802,17 @@ describe('UIX layer contracts', () => {
expect(violations, `morfo declaration drift:\n ${violations.join('\n ')}`).toEqual([]);
});
it('guards defaultElement against the tag the soma part actually renders (D8)', () => {
const violations = collectDefaultElementViolations();
expect(
violations,
`defaultElement lies about the rendered tag:\n ${violations.join('\n ')}\n\n` +
'Fix the side that is wrong — the morfo when the component renders the more\n' +
'correct element, the component when the morfo does. Do not widen the\n' +
'allow-list to make this pass.'
).toEqual([]);
});
it('guards per-item membership predicates against O(N²) .current.includes (A31)', () => {
const violations = collectA31Violations();
expect(

@ -281,7 +281,7 @@ export const comboboxMorfo = {
kebab: 'arrow',
archetype: 'arrow',
kind: 'public',
defaultElement: 'div',
defaultElement: 'span',
optional: true,
data: [],
aria: []

@ -342,7 +342,7 @@ export const contextMenuMorfo = {
kebab: 'arrow',
archetype: 'arrow',
kind: 'public',
defaultElement: 'div',
defaultElement: 'svg',
optional: true,
data: [],
aria: []

@ -358,7 +358,7 @@ export const dropdownMenuMorfo = {
kebab: 'arrow',
archetype: 'arrow',
kind: 'public',
defaultElement: 'div',
defaultElement: 'span',
optional: true,
data: [],
aria: []

@ -51,7 +51,7 @@ export const linkPreviewMorfo = {
kebab: 'arrow',
archetype: 'arrow',
kind: 'public',
defaultElement: 'div',
defaultElement: 'svg',
optional: true,
data: [],
aria: []

@ -180,7 +180,7 @@ export const popoverMorfo = {
kebab: 'arrow',
archetype: 'arrow',
kind: 'public',
defaultElement: 'div',
defaultElement: 'span',
optional: true,
data: [],
aria: []

@ -269,7 +269,7 @@ export const selectMorfo = {
kebab: 'arrow',
archetype: 'arrow',
kind: 'public',
defaultElement: 'div',
defaultElement: 'span',
optional: true,
data: [],
aria: []

@ -140,7 +140,7 @@ export const tooltipMorfo = {
kebab: 'arrow',
archetype: 'arrow',
kind: 'public',
defaultElement: 'div',
defaultElement: 'span',
optional: true,
data: [],
aria: []

@ -29,7 +29,7 @@
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
<label {...mergedProps}>
{@render children?.()}
</div>
</label>
{/if}

@ -29,7 +29,7 @@
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
<label {...mergedProps}>
{@render children?.()}
</div>
</label>
{/if}

@ -29,7 +29,7 @@
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
<label {...mergedProps}>
{@render children?.()}
</div>
</label>
{/if}

@ -29,7 +29,7 @@
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
<label {...mergedProps}>
{@render children?.()}
</div>
</label>
{/if}

@ -29,7 +29,7 @@
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
<label {...mergedProps}>
{@render children?.()}
</div>
</label>
{/if}

Loading…
Cancel
Save

Powered by TurnKey Linux.