fix(morfo): el eje intent/color — el morfo no puede nombrar lo que es de eidos

El `data-color` de los morfos declaraba enums de 3 a 8 valores cuando la prop
acepta 42. No era descuido: es que **el morfo no puede nombrar ese vocabulario**.

`COLOR_ROLES` y `PALETTE_SCALES` viven en eidos; `INTENTS` vive en
`src/uix/intent.ts`, propiedad de UIX y de ninguna capa. Y `src/uix/morfo/**`
no importa de eidos jamás — su único import cruzado es `../../intent`. Nadie
podía traer la lista real, así que se copió a mano un subconjunto y se
fosilizó: `<Switch color="teal">` siempre emitió `data-color="teal"`, un valor
que su propia declaración negaba.

## Dos ejes, no uno

- `data-color` = **direccionamiento de paleta**: qué fila de la tabla lee la
  instancia. `renderSharedPaletteLayer` emite un bloque por nombre canónico y
  el recipe consume los `--palette-*` resultantes. Soma lo resuelve aplicando
  la doctrina «el intent evaluativo gana a la jerarquía».
- `data-intent` = **estado evaluativo persistente**, cuyo gemelo transitorio es
  `data-event-intent`. Ya lo declaraban `knob`, `timeline` y `toast`.

Estaban fusionados en un solo atributo (`switch`/`toggle` llegaban a listar
`primary, secondary` junto a cuatro intents en el mismo enum), y por eso no se
podía asignar limpiamente a ninguna capa.

## Cambios

- `intentsExcept()` en `src/uix/intent.ts`: narra un subconjunto por EXCLUSIÓN,
  tipado contra `Intent`. Un componente toma los 6 o menos, pero nunca vuelve a
  escribir el vocabulario — si mañana entra un séptimo intent canónico llega
  solo a todo el que no lo haya rechazado. `knob` migrado (conjunto idéntico,
  verificado).
- `dialog` → `data-intent`. Aquí SÍ era intent mal etiquetado: 0 usos de
  `--palette-*`, sin `resolveComponentColor`, y reglas propias cuyos valores
  eran todos intents. El provider declaraba `intent` **y** `color` con la misma
  expresión exacta — una copia, no una traducción. Migrados morfo (×2),
  provider y las 4 reglas CSS.
- `switch`, `toggle`, `button`, `card`, `avatar`: enum de `data-color` fuera,
  `emit: 'value'` en su lugar. El DOM no se mueve: `enumValues` no lo consume
  nadie en runtime y `evalAttrPlan` hace `case 'enum': case 'string': return
  raw`. Ambos comprobados antes de tocar.
- De paso, los 10 morfos que escribían `expression:` con comilla doble en un
  corpus de 173 que usa comilla simple.

## Verificación

514 tests de morfo + contracts + eidos · 7/7 del provider de button (cubre
`data-color='primary'` por defecto y `'threat'` con intent evaluativo) ·
`eidos-lint` con `invalid: 0` en los seis componentes tocados · `check` sin
errores nuevos.

En navegador: `dialog` con `risk` y `threat` tiñe borde y título con los
valores exactos de `--color-risk-border` / `--color-threat-border`; `switch`
resuelve fila de paleta distinta para `neutral`, `primary`, `threat` y `teal`
— esta última una de las 33 escalas donantes que el enum viejo negaba. `rose`,
que no es canónica, sale vacía y sirvió de control negativo.

Nota: `chronos` ya declaraba la forma correcta (`emit: 'value'`, sin enum). Lo
tenía excluido de la auditoría, así que deduje desde cero un patrón que ya
estaba en el árbol.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-sec-dom
dev 2 months ago
parent 4e785d32bf
commit f8e35b8fd8

@ -11,7 +11,7 @@
*
* Both attrs (`data-size` and `data-sheet`) are eidos-only — the morfo
* declares neither, because they are pure visual concerns. Soma's
* `data-state` / `data-last-action` / `data-color` continue to come
* `data-state` / `data-last-action` / `data-intent` continue to come
* from the morfo runtime.
*/
import { ActiveEidos } from '$uix/eidos';

@ -200,28 +200,28 @@
--_dialog-padding: var(--dialog-content-padding-sm);
}
/* ── Color (intent → palette) ──────────────────────────────────────────────
* Modal subset of the 8 system tokens (canon §3.1). Soma resolves the
* consumer's `intent` prop to `data-color`. The subset is intentionally
* tight: a dialog is not a hierarchy and irreversible loss should not be
* a dialog at all. Each intent tints the panel border and (for evaluative
* intents) the title color. The body background stays neutral so long-form
* content doesn't compete with the intent signal. */
/* ── Intent → palette ──────────────────────────────────────────────────────
* The morfo emits the consumer's `intent` verbatim as `data-intent`; eidos
* tints from it. The subset is intentionally tight: a dialog is not a
* hierarchy, so `primary`/`secondary` never apply, and irreversible loss
* should not be a dialog at all. Each intent tints the panel border and the
* title colour. The body background stays neutral so long-form content
* doesn't compete with the intent signal. */
/* neutral = the plane's own border (`--depth-modal-border`); no override needed. */
[data-dialog-content][data-color='risk'] {
[data-dialog-content][data-intent='risk'] {
border-color: var(--color-risk-border);
}
[data-dialog-content][data-color='risk'] [data-dialog-title] {
[data-dialog-content][data-intent='risk'] [data-dialog-title] {
color: var(--color-risk-text);
}
[data-dialog-content][data-color='threat'] {
[data-dialog-content][data-intent='threat'] {
border-color: var(--color-threat-border);
}
[data-dialog-content][data-color='threat'] [data-dialog-title] {
[data-dialog-content][data-intent='threat'] [data-dialog-title] {
color: var(--color-threat-text);
}

@ -8,3 +8,28 @@
export const INTENTS = ['neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss'] as const;
export type Intent = (typeof INTENTS)[number];
/**
* A component's evaluative surface, NARROWED by exclusion.
*
* A component takes the six intents or a subset of them — but it must never
* RESTATE the vocabulary. A hand-written `['neutral', 'affirm', 'risk']` is a
* second, silent copy of the canon: it drifts the moment the canon moves, and
* nothing breaks to tell you (a typo is just a string). Naming what the
* component EXCLUDES keeps the subset derived from `INTENTS`, so a new
* canonical intent reaches every component that didn't explicitly refuse it.
*
* values: INTENTS // the whole axis
* values: intentsExcept('fulfill', 'loss') // no completion / no removal
*
* The parameters are typed against `Intent`, so excluding something that is
* not an intent — or misspelling one — fails at compile time rather than
* quietly widening the set.
*/
export function intentsExcept<const E extends readonly Intent[]>(
...excluded: E
): readonly Exclude<Intent, E[number]>[] {
return INTENTS.filter(
(intent): intent is Exclude<Intent, E[number]> => !excluded.includes(intent)
);
}

@ -37,16 +37,10 @@ export const avatarMorfo = {
// to the custom path). Symmetric with `Avatar.Badge`,
// which already declared this attr as optional.
attr: 'data-color',
values: [
'primary',
'secondary',
'neutral',
'affirm',
'fulfill',
'risk',
'threat',
'loss'
],
// No `values`: the colour vocabulary (42 names) is owned by EIDOS and
// morfo never imports from it — see the note on `switch`'s identical
// declaration for the full reasoning.
emit: 'value',
value: v.propRef('color'),
severity: 'optional'
},
@ -130,16 +124,10 @@ export const avatarMorfo = {
},
{
attr: 'data-color',
values: [
'primary',
'secondary',
'neutral',
'affirm',
'fulfill',
'risk',
'threat',
'loss'
],
// No `values`: the colour vocabulary (42 names) is owned by EIDOS and
// morfo never imports from it — see the note on `switch`'s identical
// declaration for the full reasoning.
emit: 'value',
value: v.propRef('color'),
severity: 'optional'
},

@ -45,7 +45,7 @@ export const buttonMorfo = {
name: 'Button',
kebab: 'button',
scope: ['soma', 'sema', 'eidos'],
expression: "family-default",
expression: 'family-default',
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/button/',
texts: {
label: '#?components.button.label|Button',
@ -106,16 +106,10 @@ export const buttonMorfo = {
* Eidos materialises the cascade.
*/
attr: 'data-color',
values: [
'primary',
'secondary',
'neutral',
'affirm',
'fulfill',
'risk',
'threat',
'loss'
],
// No `values`: the colour vocabulary (42 names) is owned by EIDOS and
// morfo never imports from it — see the note on `switch`'s identical
// declaration for the full reasoning.
emit: 'value',
value: v.propRef('color'),
severity: 'optional'
},

@ -33,7 +33,7 @@ export const cardMorfo = {
// was declared with no layer able to emit it: contract surface that could never
// fire, while the README advertised it as a differentiator.
scope: ['soma', 'sema', 'eidos'],
expression: "family-default",
expression: 'family-default',
apg: 'none — surface primitive; the interactive variant carries the link/button semantics of its composed trigger (checkpoint C5, 2026-07-07)',
texts: {
label: '#?components.card.label|Card'
@ -78,16 +78,10 @@ export const cardMorfo = {
* intent is evaluative it wins over hierarchy override.
*/
attr: 'data-color',
values: [
'primary',
'secondary',
'neutral',
'affirm',
'fulfill',
'risk',
'threat',
'loss'
],
// No `values`: the colour vocabulary (42 names) is owned by EIDOS and
// morfo never imports from it — see the note on `switch`'s identical
// declaration for the full reasoning.
emit: 'value',
value: v.propRef('color'),
severity: 'optional'
},

@ -5,7 +5,7 @@ export const carouselMorfo = {
name: 'Carousel',
kebab: 'carousel',
scope: ['soma', 'sema', 'eidos'],
expression: "family-default",
expression: 'family-default',
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/carousel/',
texts: {
label: '#?components.carousel.label|Carousel',

@ -6,7 +6,7 @@ export const commandMorfo = {
kebab: 'command',
scope: ['soma', 'sema', 'eidos'],
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/combobox/',
expression: "family-default",
expression: 'family-default',
texts: {
label: '#?components.command.label|Command palette',
list: '#?components.command.list|Command palette'

@ -15,7 +15,7 @@ export const datePickerMorfo = {
// Identity wrapper over a composed Popover (§2): the Popover IS the dialog.
// This morfo declares no dialog semantics (no apg:dialog-modal, no role:dialog,
// no focus trap, no open/close events) — they belong to the composed Popover.
expression: "delegated",
expression: 'delegated',
texts: {
label: '#?components.date-picker.label|Date Picker',
trigger: '#?components.date-picker.trigger|Open calendar',

@ -12,7 +12,7 @@ export const dateRangePickerMorfo = {
kebab: 'date-range-picker',
scope: ['soma', 'sema', 'eidos'],
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/combobox/',
expression: "delegated",
expression: 'delegated',
texts: {
label: '#?components.date-range-picker.label|Date range picker',
trigger: '#?components.date-range-picker.trigger|Open range calendar',

@ -11,6 +11,7 @@
import type { Morfo } from '../types';
import { v } from '../types';
import { intentsExcept } from '../../intent';
export const dialogMorfo = {
name: 'Dialog',
@ -121,20 +122,21 @@ export const dialogMorfo = {
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' },
{
/**
* Resolved visual color — Modal subset of the 8 system
* tokens (anexo del libro §3.1). Soma resolves the
* consumer-passed `intent` prop into this value:
* intent='neutral' (default) → 'neutral'
* intent='threat' → 'threat'
* intent='risk' → 'risk'
* Modal does NOT expose `affirm` / `fulfill` / `loss` /
* `primary` / `secondary` — a dialog has no positive
* neutral palette, and irreversible loss should not be
* a dialog in the first place (refer to the canon).
* Persistent evaluative state — sema's transient twin is
* `data-event-intent`. This is the INTENT axis, not the colour
* axis: eidos tints from it, but the attribute carries the
* semantic value the consumer passed, not a palette choice.
* (It used to be emitted as `data-color` through a provider
* that renamed `intent` → `color` 1:1 — a rename that bought
* nothing and put a purely visual name on a semantic contract.)
*
* A dialog exposes neither `affirm` nor `fulfill` — it has no
* positive palette — nor `loss`: something irreversible should
* not be a dialog in the first place (refer to the canon).
*/
attr: 'data-color',
values: ['neutral', 'threat', 'risk'],
value: v.propRef('color'),
attr: 'data-intent',
values: intentsExcept('affirm', 'fulfill', 'loss'),
value: v.propRef('intent'),
severity: 'optional'
}
],
@ -175,9 +177,10 @@ export const dialogMorfo = {
data: [
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
{
attr: 'data-color',
values: ['neutral', 'threat', 'risk'],
value: v.propRef('color'),
// Same evaluative axis as the Provider — see the note there.
attr: 'data-intent',
values: intentsExcept('affirm', 'fulfill', 'loss'),
value: v.propRef('intent'),
severity: 'optional'
},
{

@ -5,7 +5,7 @@ export const feedMorfo = {
name: 'Feed',
kebab: 'feed',
scope: ['soma', 'sema', 'eidos'],
expression: "family-default",
expression: 'family-default',
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/feed/',
texts: {
label: '#?components.feed.label|Feed'

@ -5,7 +5,7 @@ export const gridListMorfo = {
name: 'GridList',
kebab: 'grid-list',
scope: ['soma', 'sema', 'eidos'],
expression: "family-default",
expression: 'family-default',
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/grid/',
texts: {
label: '#?components.grid-list.label|Grid list',

@ -14,6 +14,7 @@
import type { Morfo } from '../types';
import { v } from '../types';
import { intentsExcept } from '../../intent';
export const knobMorfo = {
name: 'Knob',
@ -48,7 +49,10 @@ export const knobMorfo = {
// omitted — matching timeline / toast which declare data-intent the same way.
{
attr: 'data-intent',
values: ['neutral', 'affirm', 'risk', 'threat'],
// A knob sets a value; it neither completes nor removes anything,
// so `fulfill` and `loss` have no reading here. Derived from the
// canon by exclusion — never a second copy of the vocabulary.
values: intentsExcept('fulfill', 'loss'),
value: v.propRef('intent'),
severity: 'optional'
}

@ -62,14 +62,26 @@ export const switchMorfo = {
{ attr: 'data-checked', value: v.stateRef('checked'), severity: 'optional' },
{
/**
* Resolved visual color (subset per Toggle/Switch row of
* guide §3.1). Computed in the soma provider from
* `intent` (semantic) + `color` (hierarchical override).
* Per doctrine: intent wins when evaluative; `color`
* applies only when intent='neutral'.
* Palette ADDRESSING — which row of the shared palette table the
* instance reads (`renderSharedPaletteLayer` emits one block per
* canonical name; the recipe consumes the resulting `--palette-*`
* slots). The soma provider computes it from `intent` (semantic)
* + `color` (hierarchical override): intent wins when evaluative,
* `color` applies only when intent='neutral'.
*
* NO `values` here, deliberately. The colour vocabulary is 42
* names (`COLOR_ROLES` + `PALETTE_SCALES`) and it is OWNED BY
* EIDOS — morfo never imports from eidos, so it cannot name that
* set without inverting the layer dependency. The enum that used
* to sit here was a hand-copied 6-name subset that fossilized:
* `<Switch color="teal">` has always emitted `data-color="teal"`,
* a value the declaration denied. `emit: 'value'` states what is
* true — the attr carries a free value soma resolves — and the
* emitted DOM is byte-identical (`evalAttrPlan` returns `raw` for
* both `enum` and `string` modes).
*/
attr: 'data-color',
values: ['primary', 'secondary', 'neutral', 'affirm', 'risk', 'threat'],
emit: 'value',
value: v.propRef('color'),
severity: 'optional'
},

@ -12,7 +12,7 @@ export const timePickerMorfo = {
kebab: 'time-picker',
scope: ['soma', 'sema', 'eidos'],
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/combobox/',
expression: "delegated",
expression: 'delegated',
texts: {
label: '#?components.time-picker.label|Time Picker',
trigger: '#?components.time-picker.trigger|Open clock',

@ -15,7 +15,7 @@ export const timeRangePickerMorfo = {
// Identity wrapper over a composed Popover (§2): the Popover IS the dialog.
// This morfo declares no dialog semantics (no apg:dialog-modal, no role:dialog,
// no focus trap, no open/close events) — they belong to the composed Popover.
expression: "delegated",
expression: 'delegated',
texts: {
label: '#?components.time-range-picker.label|Time Range Picker',
trigger: '#?components.time-range-picker.trigger|Open time range picker',

@ -69,13 +69,14 @@ export const toggleMorfo = {
* provider from `intent` (semantic) + `color`
* (hierarchical override). Per doctrine, intent wins
* when evaluative; `color` only applies when
* intent='neutral'. The full system has 8 tokens
* (primary, secondary, neutral, affirm, fulfill, risk,
* threat, loss) — toggle exposes 6 (no fulfill, no
* loss).
* intent='neutral'.
*
* No `values`: the colour vocabulary (42 names) is owned by
* EIDOS and morfo never imports from it — see the note on
* `switch`'s identical declaration for the full reasoning.
*/
attr: 'data-color',
values: ['primary', 'secondary', 'neutral', 'affirm', 'risk', 'threat'],
emit: 'value',
value: v.propRef('color'),
severity: 'optional'
},

@ -178,14 +178,12 @@ export class DialogProvider {
props: {
disabled: () => opts.disabled.current,
modal: () => opts.modal.current,
intent: () => opts.intent.current ?? 'neutral',
/**
* Resolved visual color. For Modal the subset is
* neutral / risk / threat; intent maps 1:1 to the palette
* because there is no hierarchical (`primary`/`secondary`)
* override at this layer per the canon.
*/
color: () => opts.intent.current ?? 'neutral'
// Feeds both consumers of the evaluative axis: the perceptual one
// (the morfo's `fromProp:intent` → `data-event-intent`) and the
// persistent one (`data-intent`, which eidos tints from). There is
// no hierarchical (`primary`/`secondary`) override at this layer
// per the canon, so no second prop is needed.
intent: () => opts.intent.current ?? 'neutral'
},
parts: {
trigger: () => this.triggerId.current,

@ -27,8 +27,9 @@ export type DialogProps = {
/** Disables the trigger and prevents open/close. @default false */
disabled?: boolean;
/**
* Doctrinal intent of the dialog (anexo §3.1, Modal subset). Drives the
* resolved `data-color` palette and per-event perceptual modulation
* Doctrinal intent of the dialog (anexo §3.1, Modal subset). Emitted
* verbatim as `data-intent` (which eidos tints from) and drives
* per-event perceptual modulation
* when the dialog's outcome is sensitive (e.g. `dismissWith('fail')`
* keeps its own `threat` regardless of this prop via the polymorphic
* `close` event's `signal.alert` concretion, but `open` and

Loading…
Cancel
Save

Powered by TurnKey Linux.