--- title: Plan — Contraste Stage 2 (generador by-construction) · verificado para ejecutar en sesión aparte type: process audience: human + agent status: EJECUTADO Y CERRADO 2026-07-20 — 6 decisiones cerradas; el solver resultó INNECESARIO (el morph HEREDA el contraste, no lo computa). Entregado F0 + guard CI; solver descartado. Ver §OUTCOME abajo + changelog §45. date: 2026-07-19 related: docs/next-features.md §1 · docs/theming/reference.md §40 · docs/theming/changelog.md §44 · docs/rfcs/rfc-color-engine.md §6/§8/§9/§10/§11 --- # Plan — Contraste Stage 2: el generador by-construction ## ✅ OUTCOME (2026-07-20) — ejecutado; el solver era innecesario Las 6 decisiones se cerraron (D1 banda `text·11` blanda ≈APCA 60 · D2 *medido-pasa* · D3 flip-only · D4 post-pass opt-in · D5 runtime-first · D6 tabla-como-datos ya). Al ejecutar F0 se **refutó la premisa** del solver: el morph de plantilla **hereda** el contraste del texto (steps 11/12 = curva-L del donante *verbatim*; contraste ~invariante al croma bajo gamut-mapping), así que toda escala desde donantes §40-compliant cumple **por construcción**. Medido en 3 bancos (base, leave-one-out, 45 semillas OOD): **0 fallos del gate duro `text-strong·12`, min WCAG 9.7:1**. El solver de luminancia (F1) **no tenía nada que resolver** → descartado por especulativo. F2/F3 no aplican (F3 ya estaba gated-off por D2). **Entregado:** `$color` `CONTRAST_PAIRS` (`arts/color/contrast-contract.ts`) · `scripts/contrast-audit.ts` refactorizado (muere la `PAIRS` pre-veredicto + banco generado) · guard de CI `eidos/lib/contrast-invariant.test.ts` que bloquea la herencia. Doctrina: `reference.md §40`; crónica: `changelog.md §45`. Lo de abajo es el plan ORIGINAL tal como se verificó el 2026-07-19 — se conserva como registro de razonamiento (útil si aparece un donante custom NO-compliant, el único escenario donde un solver aportaría). ## Objetivo en una frase Que las escalas **generadas desde semilla** cumplan el contrato de contraste ratificado (§40) **por construcción** — resolviendo la luminancia de los steps de texto — en vez de heredarlo del donante por morph; y, **solo si el usuario lo aprueba** (gate), extenderlo al tema base migrándolo a semillas. ## ⚡ Empieza aquí (sesión nueva) 1. Lee este doc ENTERO + `reference.md §40` (la doctrina ratificada) + `rfc-color-engine.md §6/§8/§9/§10/§11`. 2. **NO toques código hasta resolver las 6 decisiones del usuario (§Decisiones).** La primera (target de `text·11`) define qué significa "resolver" — nada se puede especificar antes. 3. El terreno está VERIFICADO (2026-07-19, workflow de 7 agentes contra el código real) — los anclajes de §Terreno son fiables; aun así, re-verifica los `file:line` que uses (el árbol es compartido y se mueve). --- ## Decisiones del usuario (ABIERTAS — por orden de consecuencia) **D1 · Target de `text·11` para escalas GENERADAS.** (a) blando ≈APCA 60 — paridad visual con las 33 escalas enviadas (el contrato Radix que Stage 1 ratificó); (b) duro WCAG 4.5 — más accesible pero visiblemente más oscuro en tonos turbios, rompe la paridad recién ratificada. Si (a): ¿solo suelo, o banda (tope superior para que `text·11` siga leyéndose "secundario" vs `text-strong`)? *Define qué resuelve el solver; bloquea todo lo demás.* **D2 · Semántica de cobertura del BASE + tolerancia ΔE.** (a) medido-pasa: el base queda VERBATIM como ground-truth (`rfc §12`: "the 33 hex are ground-truth, not deleted") y su garantía es por MEDICIÓN (hoy pasa; el harness la vigila); (b) construido-pasa: regenerar el base desde semillas aceptando drift ΔE (por-step ΔE2000, `rfc §11` Fase 3 — **fijar el número de tolerancia**, el RFC no lo da) + re-ratificar TODOS los pins de valor (§Guards). *Esto ES la decisión del gate; (b) cambia los píxeles del look.* **D3 · Modo on-solid.** (a) flip-only (default actual: light-ink-first, cae a tinta oscura solo si falla AMBOS floors — `on-solid.ts`; el RFC RECHAZÓ en canonización la alternativa que cambiaba el look, rfc §8:487-494); (b) añadir un modo opt-in "oscurece el solid hasta que la tinta blanca pase" — sacrifica la fidelidad de marca en semillas claras (clase amber/yellow/lime) porque `solid·9` está anclado EXACTO a la semilla (`generate.ts:89`). ¿Debe existir siquiera? *Recomendación: (a); el flip YA es el mecanismo by-construction.* **D4 · Colocación y alcance del solver.** (a) dentro de `generateScale` — lo heredan TODOS los consumidores, incluido el output vivo de `applyColorScheme` (Fase 4-bis SHIPPED → **los temas white-label existentes cambian de píxeles el día que aterrice**: cambio visible, no refactor interno); (b) post-pass opt-in compuesto por `buildScheme` + `generatePalette` (+ la futura expansión config). Y alcance: ¿solo la capa de 9 roles de `buildScheme`, o también el full-33 de `generatePalette`? *Ojo además al mandato de calibración del RFC (§6:257-261): el generador debe reproducir las escalas curadas dentro de tolerancia — reproducir-ground-truth y resolver-contraste son objetivos OPUESTOS; un opt-in evita el choque.* **D5 · Tipos de Fase 0 ahora o después.** `ColorScaleSeed`/`ColorScaleSource` NO existen (solo prosa del RFC §5/§11; `ThemeColorSet.scales` sigue siendo 12-hex — `config-types.ts:1015`). (a) Stage 2 runtime-first contra los dos caminos de semilla que EXISTEN (`applyColorScheme` + `generatePalette`), tipos después; (b) aterrizar Fase 0 primero (autorar semillas en config) — arrastra la decisión de persistencia: `persistence.ts:48-52` RECHAZA versión ≠ 1 sin lector de migración; ¿unión-mantiene-v1 o bump+lector-v1 (que no existe)? **D6 · Normalización de la tabla de pares.** `scripts/contrast-audit.ts:38-46` aún codifica la propuesta PRE-veredicto (4.5 duro en `text·11`) — un solver que la consuma tal cual CONTRADICE los veredictos del 2026-07-19. ¿Re-codificar §40 como MÓDULO DE DATOS compartido (solver + auditoría consumen UNA fuente) ahora, o junto con Stage 2? *Recomendación: ahora — es F0 del plan y no depende de D1.* --- ## El terreno (VERIFICADO 2026-07-19 — qué existe de verdad) **El generador** (`src/arts/color/generate.ts`): template morph puro. `pickNearestTemplate` elige donante por proximidad del solid (L ponderada ×2 + hue; ignora chroma); `generateScale` copia la curva-L del donante VERBATIM en 11 de 12 steps, re-matiza el hue conservando el drift relativo, rescala chroma multiplicativamente (`seedC/tplSolidC`), y devuelve la semilla EXACTA en el solid (`:89 — "exact brand anchor"`). **Cero contraste en la generación**; el contraste entra solo en el pick post-hoc `pickOnSolid` (APCA≥60 ∧ WCAG≥3, light-ink-first) + `onSolidWcagRatio` (cross-check) + `alphaOverBackground` (inversa de compositing en sRGB gamma). `scheme.ts:11-12` lo dice explícito: "HCT's tone→contrast is NOT ported". **Caminos de semilla HOY (los bancos de validación):** - `ActiveEidos.applyColorScheme(seed)` (§26; Fase 4-bis ✅) → `buildScheme` regenera SOLO la capa de binding de roles (`--primitive-{role}-*`, ≤9 roles) como bloque override gestionado; las 33 `--scale-*` NO se re-emiten (actúan de donantes). - `generatePalette` (full 33 desde anclas+carácter) → demos `/temas/paleta` y `/temas/estudio`. **Este es el banco del solver full-palette.** - Config-authored seeds: **IMPOSIBLE hoy** (tipos de Fase 0 sin implementar; el stub `src/uix/eidos/COLOR_ENGINE_RFC.md:4` SOBREAFIRMA que existen — ignóralo, el RFC real es `docs/rfcs/rfc-color-engine.md`). - El path build/estático NO ejecuta el generador (emite hex verbatim; rfc §6.1:280 lo admite). "Isomorfo" es propiedad del módulo, no comportamiento actual del build. **El base** (792 hex = 33 escalas × 12 × 2 modos, en DOS ficheros): `themes/base.ts:47-217/219-389` (12 escalas/modo, 288 hex) + `themes/color-scales.ts:33-81` (`RADIX_EXTRA_*`, 21 escalas/modo, 504 hex, spread en base.ts:216/388). La paleta es NUESTRA (sembrada de Radix v3; **gold/bronze/steel/fuchsia ya se autoraron DESDE SEMILLAS offline con `generateScale`** — sus semillas están documentadas en color-scales.ts:13-15; las Radix-seeded no tienen semilla registrada → ¿derivar-inversa, pin como hex, o re-sembrar? — sub-decisión de D2). Los semantic tokens (surface/content/ border/focus) referencian `--primitive-*` → CABALGAN la migración solos, salvo 3 literales autorados: `content.onSolid` (#ffffff), `onSolidContrast` (#1c1917) (anclas deliberadas del flip APCA) y `surface.backdrop`. **El contrato downstream:** NOMBRES congelados (rfc §10; `contract.ts` parsea solo nombres) → recipes/TSC/CSS de componente/purge/API `color` intactos. PERO hay **pins de VALOR** diseñados para dispararse conscientemente si los valores driftan (§Guards). La emisión foundation solo lleva las ~9 escalas referenciadas por roles; el full-33 es opt-in (`generated/palette.css`). **La matemática (restricciones del solver):** - La inversión "Y objetivo → L OKLCH" NO es cerrada: **bisección iterativa contra el sRGB GAMUT-MAPEADO** (todo apcaLc/wcag pasa por `oklchToGammaRgb`; el mapping reduce chroma a L fija, `convert.ts:91-103`). Sondeo empírico: Y post-map es no-monótona en L cerca de la frontera de gamut (~2% de dip); monótona en el rango útil de texto — la bisección converge (~40 evals/step). - **El solver debe emitir HEX**: `alphaColorOverBackground` devuelve null con no-hex y la rampa alfa degrada EN SILENCIO al fallback color-mix (`render-css.ts:2608-2656`). Mover la L de un step cambia todas las alfas derivadas — se re-ejecutan, no se pinnean. - **Color de medición (decisión técnica a fijar):** ¿contraste sobre el sRGB mapeado (lo que mide el código hoy) o sobre el `oklch()` wide que renderiza un display P3? Divergen tras la reducción de chroma. **El spec REAL del solver (derivado de la doctrina ratificada, NO de la propuesta pre-veredicto):** - ÚNICO floor duro ratificado: **`text-strong·12` ≥ 4.5 WCAG sobre track·1 / bg·2 / element·3, por modo** (formular como "resolver contra el extremo vinculante"; la auditoría midió 1 y 3 — 2 queda acotado entre ambos; ambos modos resuelven independiente, curvas-L distintas). - `text·11`: lo que salga de D1 (blando ≈APCA 60 vs duro 4.5). - on-solid: el flip ES el mecanismo (D3); `solid·9` NO se mueve (ancla de marca). - Steps 1–8/10: SIN floors ratificados — no se resuelven (el carácter del donante se conserva; "¿reemplazar la curva-L o desviación mínima?" — restringir a desviación mínima respecto al donante para no tirar el carácter perceptual que el morph existe para conservar). - Bordes decorativos y foco: FUERA del solver (exentos §40 / eje de config §32). **Nota §9 (para no repetir trabajo):** 3 de las 4 filas de limpieza del RFC §9 YA están arregladas en código (primary purple + loss plum `base.ts:23,44`; tertiary indigo `:32`; border 6→7 `render-css.ts:102`) y el doc-drift alineado. Lo que queda de §9 = la re-autoría a semillas en sí (rfc §11 Fase 3). Al pasar por el RFC, marcar la tabla §9 como resuelta-en-código. --- ## Plan por fases (cada una verificable y commiteable sola) **F0 — La tabla ratificada como datos (no depende de D1; recomendado hacerla ya).** Módulo de datos con la tabla de pares POST-veredicto (§40): floor duro `text-strong·12`, tier blando `text·11` (parametrizado por D1), floors on-solid, exenciones. `scripts/contrast-audit.ts` pasa a consumirla (muere su constante PAIRS pre-veredicto) y el futuro solver consume LA MISMA fuente. Extender la auditoría para correr también sobre **output GENERADO** (hoy solo importa las escalas estáticas del base) — ese es el harness de regresión de todo Stage 2. **F1 — El solver (tras D1/D3/D4).** En `$color` (junto a `generateScale`), como post-pass componible (o integrado, según D4): para cada modo, bisección de la L de los steps 11/12 contra el sRGB gamut-mapeado hasta cumplir la tabla de F0, con desviación mínima respecto a la curva del donante; salida hex. Tests puros en `color.test.ts` (converge, respeta ancla del solid, hex out, casos frontera de gamut: semillas alta-chroma clase amber/yellow). **F2 — Validación en los bancos de semilla (SIN tocar el base).** - `generatePalette` → `/temas/paleta` y `/temas/estudio`: full-33 generado con solver, auditoría F0 en verde sobre el output, y **verificación EN NAVEGADOR** (lección dura del track: la verificación de cascada/percepción de color exige navegador, no lectura de código). - `applyColorScheme`: si D4=(a), documentar el cambio visible en white-label ANTES de aterrizar; si (b), demo con el opt-in activado. **F3 — (GATED por D2=(b)) Migración base→seeds.** Re-autorar los DOS ficheros (base.ts + color-scales.ts) como semillas (las 4 ya-sembradas primero — sus semillas están documentadas); tolerancia ΔE fijada en D2; los 3 literales autorados (onSolid/onSolidContrast/backdrop) se quedan a mano salvo decisión contraria. **Protocolo de re-ratificación de pins** (§Guards) + pase visual en navegador side-by-side (light+dark, `/uix/components/*` + `/temas/grafito`) con reporte ΔE — el vehículo del "look-approval" del usuario, no un diff de texto. --- ## Guards y verificación (qué se dispara y qué no) **NO se disparan con cambio de valores** (estructurales): `contract.ts` (solo nombres) · `recipe-css-contract.test.ts` (30/30, 0 hex) · checks de morfo · el parity guard §8 (`active-eidos-config.test.ts:1558-1581` — auto-consistente, recomputa del config vivo). **SÍ se disparan — POR DISEÑO, exigen re-ratificación consciente (no mecánica):** - Pins de hex literal: `active-eidos-config.test.ts:1481,1485,1588-1589` · `active-eidos.test.ts:73,78`. - El flip-set de polaridad pinneado: `active-eidos-config.test.ts:1525-1542` (['amber','cyan','gold','lime','mint','orange','sky','yellow']) — su comentario MANDA actualización consciente si un step-9 cruza el floor. - `generated-css.test.ts:8-15`: `generated/base.css` byte-exacto → regenerar con `npm run generate:eidos-css` es paso OBLIGATORIO de cualquier cambio de valor (un CI rojo aquí es "falta regenerar", no "contrato roto"). **Operativa** (heredada del track de color, sigue vigente): árbol COMPARTIDO (chat/palabras editan `base.ts` de recipes en vivo — ojo: ese es `lib/recipes/base.ts`, distinto de `lib/themes/base.ts`, pero el stage se hace igual con rutas explícitas y `git reset -q` antes) · `npm run check` baseline ~76-77 inestable → verificar CERO errores en tocados · contract test como gate. --- ## Posicionamiento honesto (corregido tras verificación) Para no repetir sobreafirmaciones: el propio RFC (§14, 2026-06-04) registra a **Radix con APCA y P3** — la posición del framework es "**on par with Radix/M3 en fidelidad de color**"; los exclusivos que el repo reivindica son **TSC + sema**, no el output de color. Nuestra paleta ENVIADA es sRGB-equivalente (los `oklch()` son siblings de hex sRGB); el wide-gamut real vive solo en esquemas generados. Lo que Stage 2 añade de verdad: **garantía por construcción (mecanismo clase-M3) decidida con APCA en vez de deltas de tono HCT** — con D2 también en el base, algo que ni Radix (curación) ni Tailwind/Chakra (sin lógica de contraste) hacen. Las caracterizaciones de competidores citan el RFC §14 (afirmación propia del repo, fechada) — si el plan las publica fuera, re-verificar externamente primero. ## Referencias - Doctrina: `docs/theming/reference.md §40` · chronicle `changelog.md §44`. - RFC: `docs/rfcs/rfc-color-engine.md` §5 (tipos) · §6 (generador+calibración) · §8 (on-solid) · §9 (limpieza, mayormente resuelta) · §10 (contrato congelado) · §11 (fases) · §12 (riesgos: "los 33 hex son ground-truth"). - Código: `src/arts/color/generate.ts` · `src/uix/eidos/lib/build-scheme.ts` · `src/uix/eidos/lib/generate-palette.ts` · `src/uix/eidos/lib/themes/base.ts` + `color-scales.ts` · `src/uix/eidos/lib/on-solid.ts` · `scripts/contrast-audit.ts`. - Registro: `docs/next-features.md §1` (Stage 1 DONE; Stage 2 = este plan). - Verificación de este plan: workflow 7 agentes 2026-07-19 (transcript en la sesión de origen; los anclajes file:line de arriba salen de ahí).