@ -2,8 +2,8 @@
title: Handoff — Abrir la jaula del color (`color` = sistema completo en todos los componentes)
type: process
audience: human + agent
status: en curso — Fase 0/1/2a/2b ✅ · Fase 3/ 4/5 pendientes
date: 2026-07-18
status: en curso — Fase 0/1/2a/2b ✅ · Fase 3 mayoría ✅ · cola de ~11 standalones + Fase 4/5 pendientes
date: 2026-07-18 (actualizado tras la sesión de continuación, 7 commits)
---
# Handoff — Abrir la jaula del color
@ -14,117 +14,217 @@ El prop `color` de un componente debe aceptar **todo el sistema de color del
ecosistema — por role, por intent, por paleta (33 escalas donantes) y por valor
(raw CSS) — en TODOS los componentes**. Decisión de diseño del usuario
(2026-07-18): revierte la restricción de THM-2 (2026-07-12) que dejaba los
controles semánticos en subconjuntos; **la jaula se abre siempre** .
controles semánticos en subconjuntos; **la jaula se abre siempre** , sin
excepciones (incluida la tinta de contenido).
Doctrina de respaldo: [`theming/reference.md §25` (THM-2) ](../theming/reference.md#L1465 )
+ [§39 ](../theming/reference.md#L1668 ) — `color` = IDENTIDAD (role/scale/custom),
desacoplada de la evaluación (que vive en `invalid` + el `intent` del evento).
## Estado — HECHO y verificado (type-clean, `check` = 50 errores baseline)
---
## ⚡ Empieza aquí (nueva sesión)
1. Lee este doc entero + la memoria `project-open-color-cage-2026-07-18` .
2. El motor y el patrón están LISTOS y probados. Lo que queda es ** ~11
componentes standalone HETEROGÉNEOS** (§Cola). NO son un batch uniforme: cada
uno rompe el patrón de una forma distinta y con su propia trampa.
3. Verifica el estado: `npm run check` (⚠️ el total oscila 76↔94, NO es baseline
estable; verifica **CERO errores en los componentes que toques** , no el total).
4. Arranca por los MENOS peligrosos de la cola (skeleton/spinner/textarea), no por
los portalados (color-picker/float-panel).
---
### Fase 0 — Motor (la costura compartida) ✅
## Estado — HECHO
### Motor (Fase 0) ✅ — la costura compartida, LISTA
- ** `ComponentColorProp` ** (`src/uix/eidos/lib/types.ts`) = `ComponentColor | (string & {})`
— el tipo canónico ABIERTO que todos los componentes deben usar.
- ** `resolveComponentColor(color)` ** (`src/uix/eidos/lib/component-color.ts`, NUEVO)
— el helper del wrapper: divide un valor en `{ dataColor, isCustom, customStyle }` .
Un nombre canónico (role/intent/escala) → `data-color` ; un valor CSS crudo →
`data-color-custom` + el seed `--color-custom` .
— el tipo canónico ABIERTO al que todos apuntan.
- ** `resolveComponentColor(color)` ** (`src/uix/eidos/lib/component-color.ts`) — el
helper del wrapper: `{ dataColor, isCustom, customStyle }` . Canónico → `data-color` ;
valor CSS crudo → `data-color-custom` + seed `--color-custom` .
- **Derivación custom compartida** (`render-css.ts` `renderSharedPaletteLayer` ):
bloque `[data-color-custom]` que deriva los 10 slots `--palette-*` desde
`var(--color-custom)` por `color-mix` (fórmula rfc-color-model §5). El forward
`renderRecipePaletteForward` se extendió a `[data-{c}][data-color], [data-{c}][data-color-custom]` .
→ **cualquier receta forward-ready gana el custom con CERO CSS** .
- Guard `recipe-css-contract.test.ts` actualizado (nueva forma del forward + invariante fila custom).
### Fase 1 + 2a — Color eidos-wrapper (14) ✅
El wrapper eidos estampa `data-color` directamente. Patrón: abrir el tipo a
`ComponentColorProp` , wrapper llama `resolveComponentColor` (estampa
`data-color` /`data-color-custom`/`style`), demo con picker completo + input custom.
- **radio-group** (piloto, visual) · checkbox · stepper · toggle-group (sentinels
`absent` /`unknown` preservados; regla hand-authored de su css extendida a
`[data-color-custom]` ) · select (por partes trigger+content) · badge · editable ·
file-upload · tag-group · tags-input · surface · avatar (cosmético) · **card** .
- **card** : era eidos-native (no soma-routed); arreglado un **bug real** — su
`isCanonicalColor` usaba solo `COLOR_ROLES` , así una escala como `teal` caía al
path custom y pintaba el CSS `teal` (#008080) en vez de la escala. Fix: incluir
`PALETTE_SCALES` . **PENDIENTE (menor)** : card sigue con custom *narrow* (srgb,
tokens `--card-color-custom` ); migrar a la costura genérica `--color-custom`
(oklab) borrando su bloque `[data-card][data-color-custom]` en card.css + los
tokens `color-custom` del recipe.
### Fase 2b — Color soma-routed (button/switch/toggle) ✅
`color` viaja por soma/morfo (eje `intent` + `color` ). Patrón morfo+soma limpio
(referencia: **button** ):
1. **Soma types** : `color?: ... | (string & {})` (ensanchar) + `colorCustom?: string` .
2. **Soma provider** : el resolver de `color` devuelve `undefined` cuando hay
`colorCustom` (→ `data-color` ausente); y el prop `colorCustom` se **anula con
intent no-neutral** (intent evaluativo gana y suprime el custom).
3. **Soma component** (`components/{c}.svelte`): destructurar `colorCustom` + `bindProps` .
4. **Morfo** (`morfo/components/{c}.ts`): declarar `data-color-custom` con
`value: v.propRef('colorCustom')` + `condition: prop-truthy 'colorCustom'` .
5. **Eidos wrapper** : `resolveComponentColor` → pasa `color={dataColor}` +
`colorCustom={isCustom ? color : undefined}` + `style` al `.Provider` .
- Verificado: escala → `data-color` ; custom → `data-color-custom` sin residual;
intent evaluativo → gana y suprime el custom.
### DOS bugs del framework encontrados y arreglados (importante)
1. ** `html-presence` con `v.literal('')` **: `html-presence` emite según la verdad
del VALOR (truthy→`''`, falsy→quita). Un literal vacío es falsy → el attr NUNCA
se emite. Card lo tenía igual (dormido, es eidos-native). Fix aplicado en los
morfos soma-routed: `value: v.propRef('colorCustom')` (como `data-disabled` ).
**PENDIENTE (menor)** : el morfo de card (`src/uix/morfo/components/card.ts`,
`data-color-custom` con `v.literal('')` ) tiene el bug latente — arreglarlo por
correctitud aunque card lo estampe directo.
2. **El custom pisaba al intent evaluativo** : el prop `colorCustom` no se anulaba
con intent no-neutral. Fix: `colorCustom: () => intent === 'neutral' ? opts.colorCustom.current : undefined` .
## PENDIENTE
### Fase 3 — Barrido con cableado (~35 `ColorRole` sin forward)
Componentes `ColorRole` (role+intent, SIN forward → no pintan escalas ni custom
hoy). Por componente: (a) declarar tokens `_palette-{slot}` en el recipe
(`lib/recipes/base.ts`, host default = su token de rol actual → behavior-preserving);
(b) que su `.css` lea `--{c}-palette-*` donde hoy usa `--color-{role}-*` ; (c) abrir
el tipo a `ComponentColorProp` ; (d) wrapper `resolveComponentColor` ; (e) picker en el demo.
**Candidatos** (censo `git grep 'export type \w*Color = ColorRole'` ): field, listbox,
link, table, calendar, combobox, mark, feed, highlight, drag-drop, month-grid,
number-field, search-field, textarea, time-field, time-picker, tree-grid, tree-view,
virtual-list, virtual-grid, year-grid, range-calendar, proof-of-human, skeleton,
spinner, clipboard, css-field, date-field, form, field-langs, float-panel,
grid-list, carousel, chronos, banner, + **radio-cards, timeline, button-group**
(reclasificados aquí desde Fase 2 por no ser forward-ready). **Candidato a Workflow**
(pipeline por componente; ficheros disjuntos → paralelo seguro).
### Fase 4 — Tinta de contenido (code, display, heading, label)
`[data-color-custom]` deriva los 10 slots `--palette-*` desde `var(--color-custom)`
por `color-mix` . El forward `renderRecipePaletteForward` emite
`[data-{c}][data-color], [data-{c}][data-color-custom]` → **toda receta con
tokens `_palette-*` gana roles + 33 escalas + custom con CERO CSS extra**.
- Guard `recipe-css-contract.test.ts` con la forma del forward + invariante custom.
### Fase 1/2a/2b ✅ (commit `149c0fef6` , pre-continuación)
- **Eidos-wrapper (14)** : radio-group·checkbox·stepper·toggle-group·select·badge·
editable·file-upload·tag-group·tags-input·surface·avatar·card. Ref: **checkbox** .
- **Soma-routed (button/switch/toggle)** : `color` por soma/morfo, dos ejes
intent+color. Ref: **button** . (2 bugs de framework arreglados: `html-presence`
con `v.literal('')` → usar `v.propRef` ; custom pisaba intent evaluativo → anular.)
### Fase 3 — familias (esta sesión, 7 commits) ✅
| Commit | Componentes |
|---|---|
| `861427fe1` | **Field family** (field base + search/password/mask/number/css/color/date/date-range/time-field) + **calendar** + **month-grid** + **time-picker/time-range-picker** + fix regresión runtime + 9 fixes de tipo (tests switch/toggle `colorCustom` ) |
| `4adcc4c7f` | sweep custom-threading: 12 sub-partes date/time (trigger/calendar/clock/input/root) por `resolveComponentColor` |
| `889b03dae` | **year-grid** + **range-calendar** |
| `c5699bd01` | **familia lista/data-grid** (10): table·tree-grid·tree-view·virtual-list·virtual-grid·feed·drag-drop·grid-list·clipboard·carousel |
| `528ddaa7f` | **listbox** + **timeline** + **combobox** |
| `1efa03439` | **link** + **mark** (tinta de contenido) |
| `28aacf092` | **button-group** + **highlight** (delegan a Button/Mark) |
Cada familia verificada en Chrome (role · escala · custom → token compartido exacto).
---
## Los PATRONES (elige el correcto por componente)
Antes de tocar un componente, identifica su patrón:
**A. Eidos-wrapper (estampa `data-color` directo).** Ref: `checkbox` . Ya tiene
forward-ready recipe → solo tipo + wrapper `resolveComponentColor` .
**B. Soma-routed (`color` por soma+morfo).** Ref: `button` . Toca soma types/provider/
component + morfo + wrapper. Dos ejes intent+color.
**C. Mini-recipe (cascada `--_X-accent` per-rol → forward).** Ref: `month-grid` . El
patrón dominante de Fase 3:
1. **Recipe** (`lib/recipes/base.ts`): añade tokens `_palette-{slot}` (host =
token de rol actual, normalmente primary → behavior-preserving).
2. **CSS** : quita las decls base `--_X-accent-*` + los bloques per-rol
`[data-X][data-color='rol']` ; renombra los consumidores
`var(--_X-accent-*)` → `var(--_X-palette-*)` .
3. **types** : `XColor` → `ComponentColorProp` .
4. **wrapper** : `resolveComponentColor` (estampa el trío + `style` ).
5. `npm run generate:eidos-css` + verifica el forward en `generated/base.css` .
**D. Tinta de contenido (`color:`/`background:` per-rol directo).** Ref: `link`
(1 slot text) / `mark` (element+text). Reemplaza la cascada `color: var(--color-{rol}-text)`
por `color: var(--_X-palette-text)` + recipe `_palette-text` .
**E. Delegante (pasa el color a un hijo ya abierto).** Ref: `button-group`
(contexto→Button) / `highlight` (→Mark). Solo tipo + wrapper.
### ⚠️ TRAMPAS (todas cazadas esta sesión — NO repetir)
1. ** `parts:` SOLO se lee en la forma `declarations: [...]` **, no en `{ value, scope }`
(`render-css.ts` `expandToDeclarationsWithParts` l.2183). Si la cascada vive en
`[data-X-root]` o `[data-X-content]` (no el provider bare), el recipe necesita
`'_palette-slot': { parts: ['root'], declarations: [{ value, scope: 'host' }] }` .
Verifica con `grep '\[data-X...\]\[data-color=' X.css` DÓNDE se estampa data-color.
2. **Verifica `recipe-exists` FIABLE antes de añadir** (grep `^\t'?X'?: \{` ). Varios
componentes YA tienen recipe (z-index/otros): drag-drop, combobox, timeline,
color-picker, float-panel. **FUSIONA en el existente, NO dupliques la clave**
(clave duplicada = el último gana, silencioso; svelte-check lo marca).
3. **Declara SOLO los slots con consumidor real** (`grep 'var(--_X-palette-slot)'`).
Slots sin `var()` consumidor = token de recipe HUÉRFANO → falla el orphan test.
Si TODO estaba muerto (virtual-list/grid) → NO pongas recipe (tipo abierto +
wrapper inerte basta).
4. **Trampa `--x: inherit`** : un custom-prop con valor `inherit` NO guarda el token
`inherit` ; hace que el prop herede del padre (→ inválido). Para "default = inherit"
usa una regla `[data-X][data-color],[…custom]{ color: var(--_X-palette-text) }` y
deja la base en `color: inherit` (patrón de `mark` ).
5. **Cross-portal** : si el componente RE-DECLARA su accent en 2 scopes (trigger +
contenido portalado), el forward en el root NO alcanza el portal. Requiere data-color
+ forward en el scope del portal (color-picker cae aquí — ver §Cola).
6. ** `accent` bare vs `accent-soft` **: renombra `-soft` PRIMERO, luego el bare
`var(--_X-accent)` (con `)` de cierre) → evita el solape de prefijo.
---
## Cola — ~11 standalones HETEROGÉNEOS (lo que falta)
Cada uno es trabajo INDIVIDUAL con su análisis y verificación propia. Orden
sugerido: los menos peligrosos primero.
### Patrón D/simple (menos peligrosos — empieza aquí)
- **skeleton, spinner, textarea, metrics, form** — tienen cascada per-rol pero cada
uno pone una PROPIEDAD distinta (`color`/`background`/`border`) directa. Analiza
qué propiedad tinta cada uno y a qué slot mapea (`grep 'data-color=' + var(--color-`).
spinner además tiene `| 'inherit'` en su tipo (conservar como unión aditiva).
- **field-langs** — `FieldLangsColor = ColorRole` , no estampa data-color (¿compone
Field?); investigar cómo colorea.
- **radio-cards** — `AffirmativeColorRole` ; compone RadioGroup (ya abierto). Probable
delegante (patrón E) o mini-recipe.
- **image** — `ImagePlaceholderColor = ColorRole | (string & {})` (ya semi-abierto);
el placeholder. Poco trabajo.
### Patrón C con TRAMPA (compound/portalado — CUIDADO, requieren verificación)
- **color-picker** — cascada 1 slot (accent→border) en `[data-color-picker]` , PERO
**re-declara `--_color-picker-accent` en 2 scopes** (trigger l.28 + contenido
portalado l.99). El forward en el root no alcanza el portal → el contenido
portalado necesita su propio data-color+forward. Compound (root+trigger+
channel-input estampan data-color; las partes threading raw necesitan
resolveComponentColor como el sweep de combobox). Recipe YA existe (fusionar).
**Intentado y revertido esta sesión** por el matiz cross-portal.
- **float-panel** — cascada 1 slot (accent→border) en `[data-float-panel-content]`
(parte → `parts: ['content']` ). PERO su default base es
`--_float-panel-accent: var(--float-panel-accent)` y ** `--float-panel-accent` NO
existe en el recipe** → el comportamiento actual es "sin acento salvo data-color".
Un host `primary-border` lo cambiaría a "siempre primary" (cambio silencioso).
Verifica primero el default REAL (¿el content-wrapper siempre estampa data-color
vía `ctx?.color` ? ¿ctx.color default 'primary'?). Recipe YA existe (fusionar).
**Intentado y revertido esta sesión.**
### Especiales
- **proof-of-human** — fixed-tone. Sus literales de color/font-size en
`clock.css` /`rotate-align.css` YA fallan 2 tests del contract (raw-color +
font-size) — añadir a `FIXED_TONE_COMPONENTS` (recipe-css-contract.test.ts) o
tokenizar. Su `color` es accesorio.
- **chronos** — en `WIP_TRACKS` (excluido del contract); scheduler sin commitear.
- **card-group** — el `.svelte` raíz NO tiene prop `color` (sus items componen Card,
ya abierto). `card-group-item` sí; revisar si necesita apertura.
---
## Fase 4 — Tinta de contenido (code, display, heading, label)
Otro eje (`primary|secondary|muted|disabled|on-solid`). Abrir como unión que
conserva su eje: `ComponentColorProp | 'muted' | 'disabled' | 'on-solid'` ; cablear
el color de texto al slot `text` de la paleta.
el color de texto al slot `text` de la paleta (patrón D, como link/mark). Nota:
link/mark YA se hicieron con este patrón — reúsalo.
## Fase 5 — Guard estructural + docs
- **Guard** : test que falla si algún tipo `*Color` es más estrecho que
`ComponentColorProp` (salvo la unión aditiva de tinta: `| 'muted' | ...` ). Ancla:
`scripts/component-audit.ts:1327` o `recipe-css-contract.test.ts` . Esto ENFORCEA
"la jaula siempre abierta" y cierra la iniciativa.
- **Docs** : `reference.md §25` + tracker `continue-cleanroom-fixes-2026-07.md §THM-2`
(registrar la reversión) + changelog.
### Fase 5 — Guard estructural + docs
- Test que falla si algún tipo `*Color` es más estrecho que `ComponentColorProp`
(ancla: `scripts/component-audit.ts:1327` o `recipe-css-contract.test.ts` ).
- Docs: `reference.md §25` + tracker `continue-cleanroom-fixes-2026-07.md §THM-2`
(registrar la reversión) + changelog + los 2 bugs de morfo runtime.
---
## Operativa (verificada esta sesión)
## Cómo continuar (operativa verificada)
- **Dev server** : `preview_start` con la config `verify` de `.claude/launch.json`
(puerto 5201). El server del otro chat (5180) puede estar caído.
- **Staleness de morfo** : HMR NO recarga `morfo/components/*.ts` fiablemente
(compile cacheado por WeakMap) → **reiniciar el dev server** tras tocar un morfo.
- **Verificación** : `npm run check` (baseline = **50 errores** , todos ajenos —
menubar/alpha/temas/demos; cualquier cifra > 50 es regresión propia). Navegador
con componente real + probe `getComputedStyle` de `--{c}-palette-solid` /
`data-color` / `data-color-custom` (la preview en segundo plano está suspendida;
usar Chrome real y foregroundear para captura). `npm run generate:eidos-css` tras
tocar recipes; NO hace falta tras tocar solo `.svelte` /`.ts`/morfo.
- **Patrones de referencia** (copiar de estos):
- eidos-wrapper: `checkbox` (types + wrapper + demo).
- soma-routed: `button` (soma types/provider/component + morfo + wrapper) — el
más completo, con el intent-gate y el fix de html-presence.
- Picker de demo compartido: `web/routes/uix/lib/PalettePicker.svelte` (ahora con
input custom); los demos que no lo usan inlinean chips + `<input type="color">` .
## Deuda menor anotada
- card custom → genérico (migrar a `--color-custom` /oklab + borrar bloque propio).
- morfo de card: `v.literal('')` → `v.propRef('colorCustom')` (bug latente).
- +2 warnings a11y en los inputs custom de los demos switch/toggle (asociación label).
(:5201). Reiniciar tras tocar un **morfo** (HMR no recarga `morfo/*.ts` fiable —
compile cacheado por WeakMap).
- ** ⚠️ `check` NO es baseline estable**: la caché incremental de svelte-check aflora
intermitentemente errores `any` PRE-EXISTENTES de demos ajenos (heroscrolling/
animations/palabras/alpha/temas): oscila **76↔94** . La verificación FIABLE es
**CERO errores en los componentes TOCADOS** (`grep 'ERROR "src...components/X'`),
no el total.
- **Tras tocar un recipe** : `npm run generate:eidos-css` + verifica el forward
emite en `generated/base.css` (`grep '\[data-X...\]\[data-color\],'`). NO hace
falta tras solo `.svelte` /`.ts`.
- **Contract test** : `npx vitest run src/uix/eidos/recipe-css-contract.test.ts` —
26/28 pasan; los 2 fallos son proof-of-human (pre-existente). >2 fallos = tu
cambio (orphan/phantom/forward).
- **Navegador** : probe `getComputedStyle` de `--{c}-palette-{slot}` sobre el
elemento que estampa data-color; fuerza `data-color='risk'` y compara con un
`<div data-color='risk'>` probe (`--palette-{slot}`); custom con
`style.setProperty('--color-custom', '#00b894')` → espera `color-mix` .
### ⚠️ ÍNDICE GIT COMPARTIDO
El working tree tiene WIP de OTROS tracks sin commitear. **Al commitear** :
`git reset -q` + `git add` de rutas EXPLÍCITAS + `git diff --cached --name-only`
para verificar ANTES de `git commit -F -` (backticks en el mensaje → usar `-F` , no `-m` ).
EXCLUIR siempre:
- `menubar` (eidos/soma/morfo), `dropdown-menu` , `palabras` , `words` — track palabras.
- ** `soma/components/virtual-list/*` + `web/routes/uix/components/virtual-list/+page.svelte` **
— el track de chat (`chat-*`) extiende VirtualList ("anchor end/chat mode"), AJENO
al color. Yo solo toco `eidos/components/virtual-list/*` .
- `web/routes/alpha/` , `.claude/settings.local.json` .
- Cambios de comentario "Words"→"Palabras" en css ajenos (p.ej. color-picker.css).
## Referencias de patrón (copiar de estos)
- Mini-recipe (C): **month-grid** (recipe `_palette-*` + css + types + wrapper).
- Con `parts` : **combobox** (`parts: ['control','input','trigger','content']`) /
**table** (`parts: ['root']`).
- Tinta (D): **link** (1 slot) / **mark** (element+text, regla colored-only).
- Delegante (E): **button-group** / **highlight** .
- Sweep de sub-partes que threading raw: workflow con ref `select-trigger.svelte` .