# PLAN — Efectos de texto (F5 del plan Scene/Ambient, desgajado por D9) > **Tipo**: plan de ejecución por fases (process — efímero, no fuente de verdad). > **Fecha**: 2026-07-12 · **Estado**: **PLAN COMPLETO — D-T1…D-T4 resueltas · FT0–FT7 EJECUTADAS (2026-07-12)**. > FT7: **6 demos** en `web/routes/uix/components/{kebab}/` (frame de 6 pestañas de los pasivos shipped — patrón box/format-number; cada prop pública = control vivo; snippet con paridad; sema empty-state justificado; CountUp con `demoLocale` propio por chips — convención service-demos). Matriz **141/0/0 con Demo ✓ y 0 warns en los 6** · check 0 propios · docs:check 0/0 · contracts 38/38. **Verificado en navegador**: 6 rutas SSR 200 + hidratadas; CountUp formatea por locale del ecosistema EN VIVO (`0,00` es-ES → chip en-US → `0.00`); TextGradient resuelve tokens→OKLCH del tema vía `var()` con `background-clip:text` + animación activa; TextCircular `role=img`+33 chars posicionados; TextFocus SIN botones falsos dentro y el auto-avance de `timers.interval` CORRE (palabra activa cambia — los timers no son rAF); TextBlur segmentos `aria-hidden` + SR completo + estado inicial sin flash; TextScramble SR surface correcta. Cero errores de consola. Los caminos rAF/IO-dependientes (count visual, flicker, marco) quedan bajo el pendiente visual de pestaña visible ya conocido (pane oculto suspende rAF/IO/measure). Ajuste de tipos post-demo: los 5 visuales aceptan `style` (fijar tokens por instancia); text-gradient fusiona su style interno con el del consumidor. > FT0–FT6: **los 6 componentes construidos y verdes en toda la maquinaria** — matriz component-audit **141/0/0** (los 6 PASS con README F-1 completo: Baseline/Comparativa/Gaps/Passive justification) · contracts 38/38 (enumeración morfo + scope) · check 0 errores propios. `CountUp` (servicio patrón FormatNumber: morfo 1-part, `uix.format.numbers` reactivo cada frame con fallback Intl, spring DHO analítico — el de `$motion` es element/CSS-bound, anotado —, IO/rAF vía `eidos.dom`, timers vía `eidos.timers`; `separator` del seed NO portado: el locale posee los separadores) · `TextGradient` (receta CSS + tokens públicos, stops `var()`-interpolados + `preset` → `--gradient-{name}` S9, borde interior `#000`→`--color-surface-default` token, keyframes R-4.5 anotados, `margin:0 auto` del seed fuera) · `TextCircular` (rAF vía dom, reduce-motion AÑADIDO — el seed no paraba nunca —, `currentColor` default, nota Aura en morfo/README) · `TextBlur` (**SR hardening**: texto real visually-hidden + segmentos aria-hidden; WAAPI directa = primitivo del driver waapi de $motion, migrará al dominio content cuando el RFC aterrice; IO vía dom) · `TextFocus` (**a11y fix**: fuera los `role="button"`+tabindex falsos del seed; `timers.interval` + `dom.measure` + `observeResize`; tokens border/glow; reduce = marco quieto; 1 `!important` anotado R-4.7) · `TextScramble` (**rediseño de medición**: cache de centros por carácter vía `dom.measure` invalidado por RO — el seed medía N rects POR pointermove; 1 lectura por move; SR hardening; `text: string` en vez de children — el split solo es honesto con texto plano, declarado). Extensión de vocabulario: `MorfoElement` + `'p'` (primer párrafo del catálogo). > **Origen**: `docs/process/PLAN-scene-ambient-pack.md` D9 — los 6 efectos de texto de > `web/routes/demos/animations/text/` NO van al pack Ambient: **envuelven contenido > real → superficie a11y = superficie de contrato → canon**, con destinos > heterogéneos. La colección seed queda intacta como referencia comparativa (D5/D7). ## Los 6 seeds, analizados | Seed | Sustrato | Qué hace | Interacción | A11y del seed | Ciudadanía a corregir | | --- | --- | --- | --- | --- | --- | | `count` (Count Up) | rAF spring analítico | Cuenta from→to al entrar en viewport | — | ✓ texto real (números) | `Intl.NumberFormat('en-US')` **hardcoded** → debe componer `uix.format.numbers` (locale-aware); `setTimeout`→`uix.timers`; IO→`$adom`; reduce ✓ ya salta al final | | `gradient` (Gradient Text) | CSS puro (`background-clip:text` + keyframes) | Gradiente animado sobre el texto (+ borde opcional) | pause-on-hover | ✓ texto real intacto | Colores crudos→tokens; `#000` del borde interior = phantom→token de superficie; `@keyframes`→R-4.5 (anotación functional / registro motion); reduce ✓ ya | | `blur` (Blur Text) | WAAPI por segmento | Entrada staggered blur→nítido por palabras/letras al entrar en viewport | — | ⚠️ trocea en spans (por letras fragmenta lectura SR) | **SR hardening**: `aria-label` en contenedor + segmentos `aria-hidden`; WAAPI/stagger vía dominio content de `$motion`; IO→`$adom`; reduce ✓ ya | | `circular` (Circular Text) | rAF + transforms | Caracteres en círculo girando; hover cambia velocidad | hover (slowDown/speedUp/pause/goBonkers) | ✓ `role="img"` + `aria-label` (buena base) | rAF→`dom.requestFrame`; escrituras transform→`dom.apply`; colores/tamaños crudos→tokens. **Nota Aura**: texto orbital alrededor de un shape redondo = pieza candidata de la periferia del indicador de agente | | `focus` (True Focus) | CSS transitions + JS | Palabras difuminadas salvo la activa; marco de esquinas viaja (auto o hover) | hover en modo manual | ⚠️ `role="button"` + `tabindex=0` en CADA palabra sin acción real = anti-patrón | Quitar role=button (o darle semántica real); `setInterval`→`uix.timers`; `getBoundingClientRect`→**`dom.measure`** (regla dura de layout reads); RO→`dom.observe`; border/glow crudos→tokens; reduce ✓ ya | | `scrambled` (Scrambled Text) | rAF + DOM textContent | Scramble de caracteres cerca del puntero | pointermove | ⚠️ SR puede leer los caracteres scrambled; split destruye el HTML original | **SR hardening** (aria-label + chars aria-hidden); **reflow storm**: mide `getBoundingClientRect` POR CARÁCTER en CADA pointermove → rediseño obligado: cache de rects vía `dom.measure` invalidado por RO; rAF→`dom.requestFrame`; pointer→`dom.listen` | Precedente real del canon para servicios: `formatNumberMorfo` — morfo de **1 part, `scope: ['eidos']`, sin CSS/eventos** («grep-friendly typed wrapper», `src/uix/morfo/components/format-number.ts`). Confirma que *morfo-first* aplica incluso a componentes de servicio, con coste mínimo. Los efectos con interacción (circular/focus/scrambled) añaden data-attrs de estado y, si procede, eventos sema. ## Decisiones (gate del usuario) | ID | Cuestión | Opciones analizadas | Recomendación | | --- | --- | --- | --- | | **D-T1** | Arquitectura por efecto | (a) canon completo ×6 (ruta 9 fases con soma+sema cada uno) · (b) familia "text effects" = wrappers eidos con morfo mínimo · (c) **mixto**: cada uno a su hogar natural — count=service component (patrón FormatNumber); gradient=eidos CSS con morfo mínimo; blur/circular/focus/scrambled=eidos decorativos con morfo (parts+data-attrs de estado) sin soma provider (no hay máquina de estados accesible que justifique soma; la interacción es decorativa) | **(c)** — (a) infla 4 componentes decorativos con capas vacías; (b) niega a count su hogar de servicio | | **D-T2** | count: ¿componente nuevo o prop de FormatNumber? | (a) `` hermano de FormatNumber que compone el MISMO formatter (`uix.format.numbers`) · (b) prop `animate` en FormatNumber | **(a)** — FormatNumber es puro/inmediato; animar es otra responsabilidad. Ambos comparten formatter (sin duplicar lógica de locale) | | **D-T3** | gradient: ¿API de colores cruda o sistema de gradientes del theming (S9)? | (a) `colors: string[]` con tokens resolubles (API del seed, theming vía tokens) · (b) integrar el vocabulario de gradientes S9 (presets canónicos) además de colores libres | **(b) con (a) dentro**: acepta stops libres (tokens o hex) Y presets del sistema de gradientes — canon presentado como opción, sin recortar la API del seed | | **D-T4** | Naming de la familia | (a) prefijo `Text*`: `TextBlur` / `TextCircular` / `TextFocus` / `TextGradient` / `TextScramble` + `CountUp` aparte (servicio) · (b) nombres del seed (BlurText, TrueFocus…) · (c) compound `Text.Blur` | **(a)** — agrupa en el catálogo junto al primitivo `Text`, evita el genérico "True/Up" del seed; count no es de la familia visual | | **D-T5** | Dónde vive la animación de cada uno | blur = **dominio content de motion** (entrada de contenido: consume `uix.motion` / WAAPI vía runtime, no keyframes CSS) · gradient = keyframes CSS de receta (loop ambiental, R-4.5) · circular/scrambled = rAF vía `dom.requestFrame` (estado continuo JS) · focus = CSS transitions + `uix.timers` · count = spring JS (¿reutilizar el spring de `$motion` si su API lo expone para valores numéricos? — se verifica en FT1; si no, spring local como el seed) | Se deriva de la doctrina motion (perceptual anchoring; animation = channel); no exige gate salvo desacuerdo | ## Fases (tras el gate) - **FT0 — Doctrina**: asentar D-T\* en este plan; verificar precedentes (spring de `$motion`, sistema de gradientes S9, wrappers eidos-only) leyendo el código real; actualizar `docs/README.md` si la familia merece fila propia. - **FT1 — `CountUp`** (servicio): morfo 1-part patrón FormatNumber · compone `uix.format.numbers` (locale reactivo del ecosistema, NO en-US fijo; el `separator` del seed se reevalúa contra la API real del formatter) · IO por `$adom` · `uix.timers` · spring (D-T5) · reduce = salto al final. - **FT2 — `TextGradient`** (eidos): receta CSS con tokens + R-4.5 · stops libres + presets S9 (D-T3) · borde con token de superficie · reduce ya en CSS. - **FT3 — `TextBlur`** (eidos + motion content): split words/letters con **SR hardening** (contenedor `aria-label`, segmentos `aria-hidden`) · stagger/WAAPI vía runtime motion · IO por `$adom`. - **FT4 — `TextCircular`** (eidos): rAF vía `dom.requestFrame` · hover states como data-attrs del morfo · tokens de tipografía/tamaño · conserva `role="img"`. Anotar la conexión Aura (texto orbital del indicador). - **FT5 — `TextFocus`** (eidos): `uix.timers` + `dom.measure` + `dom.observe` · fix a11y del role=button · border/glow por tokens. - **FT6 — `TextScramble`** (eidos): **cache de rects** (dom.measure + invalidación por RO — mata el reflow storm) · SR hardening · pointer por `dom.listen` · charset/duración/radio como props. - **FT7 — Cierre**: demos con profundidad DEMO_AUTHORING (cada prop = control vivo) · checklist de componente por cada uno · `npm run check` + vitest + eidos-lint + navegador · memoria + este plan con bloques EJECUTADA. - **Verify por fase**: check 0 errores propios · component:audit verde para los nuevos · demo montada en navegador · SR hardening revisado (árbol de accesibilidad en devtools). ## Fuera de alcance La fase Aura (el plan madre §F6.3) sigue sin construirse aquí; `TextCircular` solo deja anotada la pieza orbital. Los 4 efectos de imagen (D8) siguen fuera.