You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/docs/process/PLAN-affix.md

404 lines
28 KiB

feat(eidos): Affix — la pieza que Sticky no puede ser, y la capa que Fab y MenuDial ya habian copiado dos veces Un aviso al principio del documento se va con la pagina: `position: sticky` se despega en cuanto termina su contenedor. Medido el 2026-08-10 sobre el block `banner` — `Sticky edge="bottom"` acaba en `top: -8` sin `data-stuck`. No es un cableado roto: es que un elemento anclado al viewport es `fixed`, y eso es otro componente. Pero no es un componente nuevo: la capacidad YA existia dos veces. `fab.css` fija 4 esquinas, `menu-dial.css` nueve, y las dos ya divergian (`--fab-offset` publico vs `--_menu-dial-offset` interno; `var(--fab-z)` vs `var(--z-index-sticky, 1100)`, un fallback de 1100 sobre un token que vale 100). Y antes de eso existio entera: `air/layout/float`, el primitivo de 9 zonas que el refactor retiro sin sustituto — lo dicen los README de `float` y de `Fab`. Asi que `affix.css` no es un tercer ejemplar: es la CAPA, enganchada en `data-affix-placement` y no en `data-affix`. El split es portante — `morfo-check` selecciona `[data-affix]` en toda la pagina y valida cada match contra `affixMorfo`, asi que si `Fab` estampara la identidad quedaria soldado a este contrato. Estampando solo el gancho de capa, no. Es el patron de `lib/list-surface.css`: una fuente, cero nodos extra. Lo que salio midiendo, no razonando: - `--z-index-affix: 150`, peldano nuevo. Con la tira en `top` empataba con `--sticky-z-index` (100 los dos) y el empate lo rompe el ORDEN DEL DOM: el aviso va antes que la cabecera en el fuente, luego perdia. 30,7px de solape con el cromo encima — el fallo original reproducido por su sustituto. Un peldano toca DOS sitios: `STATIC_Z_INDEX` y la lista cerrada `Z_INDEX_KEYS`. - NO compone `<Box>`, aunque sea el patron de los primitivos de layout: `box.css` declara `position: var(--box-position, revert-layer)` a la misma especificidad que el gancho, y eidos no usa `@layer`. En el orden de carga equivocado, `position: static` — un `fixed` que no hace nada. - `stretch` es solo de los bordes de bloque. El rail del eje inline se envio y se retiro el mismo dia: `Affix` no dimensiona a su hijo, asi que era una caja invisible de 380px con el hijo de 47px arriba, identica a `top-start` en pantalla. El marco medido decia otra cosa; la pintura mandaba. - `env(safe-area-inset-*)` es fisico y los anclajes logicos: remapeado bajo `:dir(rtl)`. Emparejar `inset-inline-start` con `safe-area-inset-left` despeja la muesca equivocada en RTL apaisado — que es lo que hacen hoy `fab` y `menu-dial`, registrado. `banner` recupera lo que su README daba por imposible: `affix="top" | "bottom"`. La disposicion anterior decia «app-land, el canon ya trae Sticky» y nombraba un componente que no puede hacerlo. Verificado con recorrido real (342px en la demo, 1247px en la preview del block): desplazamiento 0 en los dos bordes, nueve zonas exactas, RTL espejando, el ancestro transformado derivando -342px como esta documentado, y `elementFromPoint` sobre la tira devolviendo el aviso y no el cromo. component:audit PASS 0/0 · morfo:check PASS · rtl 0/178 · smoke 311/311. Queda: migrar `Fab` y `MenuDial` a la capa. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
# PLAN — `Affix`: fijar contenido a un borde del viewport (componente de canon)
> # ✅ CERRADO — 2026-08-14
>
> **`Affix` está construido, verificado y en el árbol.** Lo que sigue es el plan
> ORIGINAL, conservado íntegro porque su razonamiento sigue siendo la explicación
> de por qué existe el componente. Lo que se envió NO es lo que proponía §2: las
> cuatro decisiones se firmaron con cambios sustanciales, y el delta está en
> **[§6, al final](#6-lo-que-se-construyó-realmente--delta-vs-este-plan)**.
> Lee §6 antes que §2 o construirás otra cosa.
>
> **Fuente viva del componente**:
> [`src/uix/eidos/components/affix/README.md`](../../src/uix/eidos/components/affix/README.md).
>
> **Deuda abierta que dejó**: migrar `Fab` y `MenuDial` a la capa compartida
> (registrada en los Gaps del README del componente).
---
> **Kickoff para sesión nueva** _(histórico — el plan ya se ejecutó)_: _"Lee
> `docs/process/PLAN-affix.md` y empieza por la fase 0."_ Este plan es
> autosuficiente: cada fase dice QUÉ leer, QUÉ producir y CON QUÉ guard se
> verifica.
>
> **Antes de escribir una línea**: las CUATRO decisiones de §2 son propuestas
> razonadas, no decisiones tomadas. Se presentan al usuario y se firman. Sin eso
> no se abre la fase 1.
---
## 0. PRECONDICIÓN — leer antes de concluir nada
> Regla férrea del usuario, dictada el 2026-08-10 tras una sesión entera de
> análisis erróneos. No es contexto opcional: es lo que se hace ANTES.
>
> 1. **Leer la documentación.** No ojearla, no «la parte relevante»: leerla.
> 2. **No sacar NINGUNA conclusión precipitada sin haber leído TODA la
> documentación** del eje que se toca.
> 3. **Contrastar TODAS las decisiones contra la documentación ANTES de emitir
> conclusiones** — no después, no cuando alguien lo pregunte.
**Leer a medias es no leer.** En la sesión que originó este plan se leyeron 120
de las 290 líneas de `theming/channels.md` y se afirmó que la doctrina «no
cubría» algo que estaba escrito en la línea 180.
**Y antes de PROPONER algo, leer lo que ya hay sobre ello.** Los dos errores más
caros de esa sesión fueron proponer soluciones que el repo ya tenía resueltas o
descartadas: la separación `Sticky`/`Affix` llevaba escrita en el dossier desde
julio, y una fila del ledger se «re-descubrió» peor de lo que su propia ficha ya
la tenía analizada. Antes de escribir una propuesta: el dossier, la ficha del
hallazgo, y el README del componente vecino.
### Lo que hay que haber leído para ESTE plan
| Documento | Por qué |
| ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| [`docs/building-a-component.md`](../building-a-component.md) | la puerta única; enlaza el doc de cada fase |
| [`guides/component-guide.md`](../guides/component-guide.md) §4–5 | compose-first y la clasificación componente / capa / átomo pasivo |
| [`guides/completion-checklist.md`](../guides/completion-checklist.md) | la matriz de aceptación (A-\* · E-\* · R-\* · D-\* · F-\*) |
| [`src/uix/eidos/components/README.md`](../../src/uix/eidos/components/README.md) | las 7 reglas duras de la forma canónica |
| **`src/uix/eidos/components/sticky/`** (README + `sticky.css` + su morfo) | es el VECINO: este componente existe porque `Sticky` no cubre el caso, y su recipe explica por qué usa inset físico |
| [`process/RESEARCH-blocks-references.md`](./RESEARCH-blocks-references.md) §sticky (F1.1) | el dossier ya separa `Affix` de `Sticky` y lista las brechas del segundo |
| [`theming/motion.md`](../theming/motion.md) | **sólo si se anima la entrada/salida**; entonces, entera |
⚠️ **Un gate de tipos NO verifica comportamiento.** `blocks:check` y
`svelte-check` dieron verde sobre un `edge="bottom"` que no hacía absolutamente
nada. Lo que verifica comportamiento está en §4.
---
## 1. Por qué existe — la necesidad está medida, no supuesta
El 2026-08-10, trabajando el block `banner`, el usuario pidió que el aviso
pudiera fijarse arriba **o abajo** de la página. El intento de resolverlo
componiendo el `Sticky` del canon falló, y la medición dice exactamente por qué:
| Intento | Resultado medido (`/blocks/banner/preview`, ventana 700px) |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Sticky edge="top"` | ✅ `position: sticky`, `top: 0`, `data-stuck` presente tras desplazar |
| `Sticky edge="bottom"` | ❌ la tira acaba en `top: -8` y `data-stuck` **ausente** — el prop no hace nada |
| `Box position="fixed"` en el block | ⚠️ se ancla al borde inferior, pero **queda por debajo del cromo de la página**: `Box` expone `position` e `inset` y NO `zIndex`, y `style=` está prohibido por B-3 |
**La causa de la segunda fila no es un cableado mal puesto**: `position: sticky`
se despega en cuanto termina su contenedor, y un aviso vive al PRINCIPIO del
documento, así que la página se lo lleva. Un elemento anclado a un borde del
viewport es `position: fixed`, y eso es otro componente.
El tercer intento se revirtió entero (`git checkout`) — está registrado aquí
porque **el apaño no debe volver**: un `fixed` sin z-index gestionado ni portal
es una versión ad-hoc de este componente dentro de un block, y la regla de
composición del proyecto dice que un primitivo que falta **se señala y se
construye**, nunca se inlinea ([`guides/component-guide.md`](../guides/component-guide.md) §4).
### La separación ya estaba documentada
El dossier de referencias lo dejó escrito en julio, en la ficha de `sticky`
(F1.1), y nadie lo leyó a tiempo:
> **Mantine Affix NO es comparable (es un slot fixed en Portal)**
y, entre las brechas del propio `sticky`:
> **borde bottom = centinela DISTINTO** (`threshold:[1]`, por ratio) — es un
> centinela por borde con configs diferentes
Es decir: la referencia separa las dos piezas a propósito. Nosotros tenemos
`Sticky` y no tenemos la otra.
### Por qué NO se resolvió metiendo un modo `fixed` en `Sticky`
Se analizó (2026-08-10) y el impacto de código era pequeño —sólo dos
consumidores (`site-header` y el layout de `/uix`), cero eventos en su morfo—
pero el doctrinal no:
1. **Rompe una promesa escrita en su propio recipe**: `data-stuck` + `data-edge`
espejan `@container scroll-state(stuck: top|bottom)` «para que los selectores
traduzcan mecánicamente a la query nativa cuando aterrice». Un modo `fixed`
no tiene equivalente en esa query.
2. **Fusiona lo que la referencia separa** (punto anterior).
3. **`data-stuck` y el centinela pierden significado**: en `fixed` el elemento
nunca se pega ni se despega.
4. Desaparece el hueco en el layout, el ancho colapsa sin insets inline, y el
`--sticky-z-index` está calibrado para un header pegado, no para algo que
flota sobre todo el contenido.
---
## 2. Fase 0 — las cuatro decisiones a FIRMAR
### D-AFX.1 · Membresía: eidos-native, no soma
**Propuesta**: morfo con `scope: ['eidos']` y **0 eventos**, como `mockup`
(`src/uix/morfo/components/mockup.ts` es el precedente vivo), más la sección
`## Passive justification` en el README que la regla F-1.5 exige.
**Motivo**: no tiene estado ni observa nada — es colocación. `Sticky` es soma
porque detecta el cruce con un IntersectionObserver; aquí no hay nada que
detectar. Si algún día debe anunciar que apareció, entonces sube a soma.
### D-AFX.2 · La superficie v1
**Propuesta**:
| Prop | Valores | Default | Para qué |
| -------- | -------------------------------------- | ----------- | -------------------------------------------- |
| `edge` | `top` \| `bottom` | `bottom` | el borde al que se ancla |
| `offset` | px | `0` | separación desde ese borde |
| `inline` | `full` \| `start` \| `center` \| `end` | `full` | cómo ocupa el eje inline |
| `z` | clave de la escala `--z-index-*` | por decidir | apilamiento, **tokenizado, nunca un número** |
| `portal` | boolean | `false` | ver D-AFX.3 |
Con eso, `banner` pide `edge="bottom" inline="full"` y un FAB pediría
`edge="bottom" inline="end"`. Mantine usa insets sueltos
(`position={{ bottom: 20, right: 20 }}`); esta superficie es más cerrada y
tokenizada, que es la línea del proyecto.
⚠️ **A decidir con ella**: si v1 cubre sólo el eje de bloque (`top`/`bottom`) o
también `left`/`right`. La necesidad medida sólo pide el primero.
### D-AFX.3 · El portal, opt-in y por defecto apagado
**Propuesta**: `portal={false}` por defecto, componiendo el `Portal` que ya
existe (`$soma/components/internal`, exportado con `PortalProps` /
`PortalTarget`).
**Motivo**: sólo hace falta para escapar de un ancestro con
`transform` / `filter` / `contain`, que es raro — pero cuando pasa, mata el
`fixed` **en silencio**, y ésa es justo la clase de fallo que debe tener una
salida documentada.
### D-AFX.4 · El nombre
**Propuesta**: `Affix`, que es el de la referencia que el dossier ya cita.
Alternativas: `Pinned`, `Fixed`. ⚠️ Ojo: **AntD llama `Affix` a otra cosa**
(un sticky con `target`), así que la comparativa del README tiene que
desambiguarlo explícitamente o el nombre confunde.
---
## 3. Fases 1–8 (la ruta canónica)
Leer primero [`docs/building-a-component.md`](../building-a-component.md) — es
la puerta única y enlaza el documento de cada fase.
| Fase | Produce | Guard |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| **1 · Morfo** | `morfo/components/affix.ts` — una parte (`Provider`), `data-affix` + `data-edge` + `data-inline`. Cero eventos, justificados en la cabecera | `validateMorfo` · `morfo:vocabulary` |
| **2 · Soma** | **no aplica** si se firma D-AFX.1 | — |
| **3 · Sema** | `expression: 'family-default'` explícito — no hay ocurrencia que expresar | `morfo:vocabulary` |
| **4 · Eidos** | `eidos/components/affix/` — `affix.svelte` + `types.ts` + `index.ts`; compone `Portal` cuando se pide | `component-api-contract` · audit `E-*` |
| **5 · Recipe** | `affix.css` — `position: fixed`, inset **lógico** en el eje inline (aquí no hay observer que obligue a lo físico, al revés que en `Sticky`, cuyo recipe explica por qué él sí) y `z-index` desde la escala tokenizada | `generate:eidos-css` · `recipe-css-contract` · audit `R-*` |
| **6 · Demo** | `web/routes/uix/components/affix/+page.svelte`, plantilla v2 de 9 pestañas. **Con recorrido de scroll real** — la preview del banner tenía 16px y por eso no se veía nada | audit `D-*` · `smoke` |
| **7 · README** | Baseline · Comparativa ≥3 refs · Decisiones · Gaps · `## Passive justification` · **y la sección que más falta: `Affix` vs `Sticky`, cuándo cada uno** | audit `F-*` |
| **8 · Aceptación** | nada nuevo: pasa | `component:audit --only affix` · `morfo:check` · `eidos-lint` · `rtl:check` |
---
## 4. Verificación específica (además de los guards)
Lo que falló en el intento del banner, para que no se repita:
1. **Scroll largo real** en los dos bordes, midiendo la posición en tres puntos.
Un gate de tipos NO verifica comportamiento: `blocks:check` y `svelte-check`
dieron verde sobre un `edge="bottom"` que no hacía nada.
2. **Z-index computado del elemento frente al del cromo de la página** — el
fallo exacto que el usuario vio en pantalla.
3. **Captura de los dos bordes, y MIRARLA** antes de decir que está.
4. **Ancestro con `transform`** en la demo: comprobar que sin `portal` falla y
con `portal` funciona, que es la razón de ser del prop.
5. Claro/oscuro y RTL (`inline="start"`/`"end"` deben espejar).
---
## 5. Qué desbloquea
- **`banner`** recupera `sticky` / `offset` / `edge` componiendo `Affix` para
`bottom` y `Sticky` para `top` — diez minutos cuando la pieza exista. La
necesidad y el precedente están en el ledger del tier
(`AUDIT-blocks-ledger.md`), donde `site-header` ya expone esa costura.
- **La categoría «cookie consent»** del dossier, que ≥2 referencias shippean y
el tier no puede hoy.
- El gap de `Box` sin `zIndex` deja de ser urgente: quien necesite fijar algo
compone `Affix` en vez de pelearse con el primitivo de layout.
---
## 6. Lo que se construyó realmente — delta vs este plan
Ejecutado el 2026-08-14. Las cuatro decisiones se firmaron una a una; tres
cambiaron de forma durante la firma, y dos cosas más se descubrieron MIDIENDO,
no razonando. Esto es el registro, no un resumen.
### El hallazgo que reencuadró todo
**Este plan no cita `Fab` ni `MenuDial`, y las dos ya fijaban al viewport.**
`fab.css` tiene 4 esquinas + `static`; `menu-dial.css` tiene **9 zonas**, insets
lógicos, `env(safe-area-inset-*)` y centrado por márgenes auto. Y la pieza ya
había existido: el morfo de `float` dice _«NOT the legacy `air/Float` 9-zone
external placement primitive»_ y el README de `Fab` que _«the old
`air/layout/float` … was removed in the refactor»_. La capacidad no desapareció:
se re-inlineó dos veces, divergiendo (`--fab-offset` público vs
`--_menu-dial-offset` interno; `var(--fab-z)` vs `var(--z-index-sticky, 1100)`,
un fallback de 1100 sobre un token que vale 100).
Por eso `Affix` **no** es un tercer ejemplar: es una **capa compartida**.
### Las cuatro decisiones, como quedaron firmadas
| Decisión | Lo que proponía §2 | Lo firmado |
| ---------------------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **D-AFX.1** membresía | eidos-native, 0 eventos, precedente `mockup` | **igual**, con el precedente cambiado a `Fab` (mismo scope, 0 eventos, ya `fixed`) y añadiendo al README la sección `## Audit exceptions` (E-2.2 · D-3.1 · R-1.1), que el plan no preveía |
| **D-AFX.2** superficie | props `edge` · `offset` · `inline` · `z` · `portal`, con el componente como único consumidor | **rehecha**: capa compartida `affix.css` enganchada en `data-affix-placement` + componente público. Superficie final: `placement` (9 zonas) · `stretch` · `offset`. El mecanismo es el de `lib/list-surface.css`, NO anidar componentes — envolver `Fab`/`MenuDial` metería un nodo y separaría el `fixed` del elemento sobre el que están calibrados |
| **D-AFX.3** portal | `portal={false}` opt-in | **descartado en v1** (CLAUDE.md §2, sin caso real). Registrado en Gaps con su motivo; la demo exhibe el fallo en vivo con el control «transformed ancestor» |
| **D-AFX.4** nombre | `Affix` | **igual**. Verificado que `Float` y `Pin` están ocupados en el árbol, y que «affix» ya existe como palabra del framework — pero sólo en prosa y en UN token (`--field-affix-color`); el contrato DOM del campo es `data-field-prefix`/`-suffix`. Colisión documental, no mecánica |
### Lo que este plan NO podía prever, y salió midiendo
1. **La z: peldaño nuevo en el canon.** Con la tira en `top`, `--affix-z` empataba
con `--sticky-z-index` (ambos 100) y el empate lo rompe el ORDEN DEL DOM — un
aviso va antes que la cabecera en el fuente, así que perdía: **30,7px de
solape, cabecera encima**, o sea el fallo del §1 reproducido por su propio
sustituto. Firmado `--z-index-affix: 150` (cromo fijado al viewport ≠ cromo
sticky a su contenedor). ⚠️ Añadir un peldaño toca **dos** sitios:
`STATIC_Z_INDEX` y la lista cerrada `Z_INDEX_KEYS` — sólo el valor tira 34
tests con `unknown token key`.
2. **`Affix` NO compone `<Box>`**, aunque sea el patrón de todos los primitivos
de layout: `box.css` declara `position: var(--box-position, revert-layer)` a
la MISMA especificidad (0,1,0) que el gancho de la capa, y eidos no usa
`@layer`, así que `revert-layer` se comporta como `revert`. En un orden de
carga donde `box.css` llegue después, el affix computaría `position: static`
— un `fixed` que no hace nada, justo lo que este componente viene a terminar.
3. **`stretch` es sólo de los bordes de bloque.** La versión simétrica (raíl a
toda la altura en `left`/`right`) se envió y se retiró el mismo día: `Affix`
no dimensiona a su hijo, así que el raíl era una caja invisible de 380px con
un hijo de 47px arriba — **idéntico a `top-start` en pantalla**. El marco
medía bien; la pintura no. Registrado en Gaps.
4. **Un default de demo puede matar el control principal.** Con `stretch` ON,
`top-start`/`top-center`/`top-end` son la misma tira: seis de las nueve zonas
dejaban de cambiar nada (D-7.1). La demo arranca con `stretch` OFF y hijo
compacto.
### El §4 de este plan, ejecutado
| Verificación pedida | Resultado |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
| Scroll largo real, tres puntos, dos bordes | ✅ 342px en la demo · `bottom-center` y `top-center` con desplazamiento **0** en los tres puntos, ancho = ancho del host |
| Z computada frente al cromo | ✅ affix 150 vs cabecera pegajosa 100 · `elementFromPoint` sobre la tira devuelve el aviso |
| Captura de los dos bordes, y MIRARLA | ⚠️ el agente no pudo (panel Browser sin componer frames); **comprobado por el autor** |
| Ancestro con `transform` | ✅ deriva −342px sobre 342px de scroll — se va del todo pese al `fixed`. En la demo como control |
| Claro/oscuro y RTL | ✅ `top-start` pasa de `left: 0` a `right: 0` en RTL; `env()` remapeado por `:dir(rtl)` a ranuras lógicas |
Y una trampa que el §4 no pedía: el primer harness de la demo puso `contain:
layout` y `overflow: auto` en el MISMO elemento, con lo que el bloque contenedor
del `fixed` ERA el scroller y la tira se iba con el contenido (0 → 171 → 342).
Separado en dos elementos.
### Lo que quedó abierto
- **Migrar `Fab` y `MenuDial`** a la capa (el autor lo acotó fuera: «adapta sólo
banner momentáneamente»). Ahí caen también su defecto RTL de safe-area inline
(emparejan `inset-inline-start` con `env(safe-area-inset-left)`, que despeja la
muesca equivocada en RTL apaisado) y el fallback `1100` de `menu-dial`.
- **`banner`**: envía `affix="top" | "bottom"` + `affixOffset`. Su README tenía
la disposición contraria («app-land, el canon ya trae `Sticky`») y se corrigió:
nombraba un componente que NO puede hacerlo.
docs(eidos): la doctrina alcanza a la jornada — dos guards nuevos, el patron de capa, y EID-3 deja de estar pendiente Barrido de lo que la sesion cambio y la documentacion todavia no decia. `testing-and-tooling.md` — los dos guards nuevos entran en la tabla de «que atrapa cada script», con su reparto explicito: `layer:check` mira el valor computado (quien gana la cascada) y declara su hueco (la geometria, porque `getComputedStyle` da el valor USADO y un `inset: auto` se lee como pixeles); `shared-layer-contract.test.ts` mira el texto, y existe por lo que el navegador no puede ver — un `env()` ya sustituido devuelve `"0px"` en escritorio. `component-guide.md` fila RTL — deja de remitir a «EID-3 exception» y enuncia la regla: dos rejillas NOMBRADAS, `Position` fisica y `LogicalPosition` logica, y la pregunta que decide entre ellas («¿tiene que voltearse para un lector de derecha a izquierda?»). Estrechar con `Extract<>`, nunca redeclarar. `canon/recipe-contract.md` — la fila de z-index flotante distinguia mal: la banda `--z-index-overlay-*` es de overlays PORTALED. El cromo fijado al viewport que no portala es otra cosa y tiene su peldano (`affix`, 150). Y el item 9 del checklist de recetas decia «si flota → una rung de overlay», que era incompleto. `eidos/components/README.md` — el patron de CAPA COMPARTIDA, que no estaba escrito en ningun sitio pese a tener dos ejemplares vivos (`list-surface` y `affix`). Sus cuatro reglas, tres de ellas aprendidas rompiendose: enganchar en el attr de capa y no en la identidad (o `morfo-check` suelda al consumidor a un contrato ajeno), un eje = token publico + ranura, el puente reafirma `position` si el primitivo compuesto declara uno, y se guarda con dos redes porque ninguna basta sola. `audit-active-uix.md` — EID-3 pasa a RESUELTO, conservando el hallazgo original debajo. Era un P3 de julio cerrado «como excepcion» pero marcado en su propia tabla resumen como «pendiente de doctrina explicita». Ya no lo esta. `PLAN-affix.md` §7 — lo que vino DESPUES de cerrar el plan, que es casi todo lo interesante: las dos migraciones y sus dos lecciones, el peldano de z, el patron de tokens, la canonizacion de la rejilla y los dos guards. Ninguna estaba prevista en el plan; todas salieron de auditar lo construido. docs:check 0/627. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
---
## 7. Lo que vino DESPUÉS de cerrar el plan (2026-08-14 → 15)
El §6 se escribió al cerrar. Lo que sigue salió de auditar lo construido, y
ninguna de las cuatro cosas estaba prevista aquí.
### La capa se cobró sus tres consumidores
`Fab` (2026-08-14) y `MenuDial` (2026-08-15) borraron sus copias privadas — 4 y
9 zonas — e importan la capa estampando `data-affix-placement`. Con eso las TRES
copias de la geometría de viewport (`air/layout/float` la tuvo primero) son una.
Dos aprendizajes que sólo aparecen migrando:
- **El puente reafirma `position` si el primitivo compuesto declara uno.** La
base de la capa es (0,1,0) y `button.css` declara `position: relative` sobre
`[data-button]` al mismo peso, con los dos ficheros code-split: el orden de
carga decide. `Fab` lo debe; `MenuDial` no, porque no compone nada en el marco.
- **`data-placement` no siempre es del posicionado.** En `MenuDial` tiene un
segundo lector —el arco deriva de la zona su apertura y su span— así que se
queda, y el gancho de capa viaja aparte. En `Fab`, que no tenía ese segundo
lector, desapareció.
### Un peldaño nuevo en el canon de z
`--z-index-affix: 150`. Con la tira en `top`, la banda `sticky` EMPATA con la de
`Sticky`, y el empate lo rompe el orden del DOM: un aviso va antes que la
cabecera en el fuente, luego perdía — 30,7px de solape, el cromo encima, o sea
el fallo del §1 reproducido por su propio sustituto. ⚠️ Añadir un peldaño toca
DOS sitios: `STATIC_Z_INDEX` y la lista cerrada `Z_INDEX_KEYS`.
### Un eje de capa = un token público + una ranura
Mientras el prop escribía el MISMO nombre que la capa lee, `offset="var(--affix-offset)"`
producía una custom property auto-referencial: ciclo, _guaranteed-invalid_,
`calc()` muerto, insets a `auto` — la caja a 324px del borde que debía tocar, y
la demo ofrecía ese valor como uno de sus chips. La forma correcta ya la hablaba
el árbol (`code.css` ×6, `display.css` ×6): `var(--_x, var(--x))`. Al separarlos
se retiraron `--fab-offset` y `--fab-z`, cuyo único lector era el puente que los
traducía de vuelta. **Regla**: un consumidor escribe la ranura, no acuña un token
propio.
### La rejilla de colocación se canonizó — y cerró EID-3
`lib/types.ts` ya tenía `Position`, la rejilla FÍSICA. La LÓGICA estaba escrita
a mano **cinco** veces (affix · fab · menu-dial · onion-menu · badge de avatar),
coincidiendo por mantenimiento y no por contrato. Ahora hay `LogicalPosition`
junto a ella, ambas consts y ambas en `canon/vocabularies.md`. **No son dos
grafías: son dos comportamientos** — `top-left` nunca espeja, `top-start` sí
(medido: `left: 0` → `right: 0`). Eso cierra EID-3, que el audit de julio dejó
como excepción «pendiente de doctrina explícita».
### Y la capa dejó de no tener guard
`shared-layer-contract.test.ts` (texto) + `npm run layer:check` (valor
computado). Ambos verificados **por mutación** — ocho defectos inyectados, ocho
detectados —, porque un test en verde no prueba nada hasta que falla sobre lo que
dice atrapar. El hueco que NO cubren está escrito en la cabecera del script:
la geometría, porque `getComputedStyle` da el valor usado y un `inset: auto` se
lee como píxeles.
### Lo que sigue abierto
- **Test de geometría de la capa** — diferido al tercer consumidor, que ya está.
- Ajenos a este eje: los 6 fallos previos de `contracts.test.ts`, y `morfo:check`
en `fab` (`data-fab-size` sin declarar) y `menu-dial` (`data-state` ausente).
fix(morfo): affix deja de declarar lo que solo lee una capa, y el gancho pasa a nombre de CAPA Correccion doctrinal, senalada por el autor: **morfo es la capa declarativa ENTRE capas**. Un atributo que consume una sola no es contrato. `affixMorfo` declaraba `data-affix-placement` y `data-affix-stretch`. Los lee UNA: el CSS. No debian estar ahi — y la doctrina nombra la familia exacta (`morfo.md` §`undeclaredState`: «the escape hatch for attrs OUTSIDE the contract — a visual wrapper's `data-size`, a presentation flag like `data-sheet`»). Los declare porque `morfo-check` los exigia, que es la herramienta dictando la doctrina: el guard clasifica por PREFIJO DE NOMBRE, la doctrina clasifica por NATURALEZA, y las dos solo chocaban porque el gancho llevaba el nombre del componente. Y no era su nombre. Lo estampan tres —`Affix`, `Fab`, `MenuDial`—, asi que no es de ninguno: pasa a **`data-viewport-placement`** / `data-viewport-stretch`. La colision con el guard desaparece por construccion, sin excepcion que escribir. Es el mismo error que `--fab-offset` leido por la capa: nombrar por un participante algo que es de todos. `affixMorfo` se queda con lo que si es contrato: la identidad `data-affix`, que es como el DOM dice «esta caja es un Affix» a quien pregunte. TAMBIEN INTENTADO Y REVERTIDO, con su motivo, para que nadie lo reintente: mover el fichero a `eidos/lib/viewport-placement.css` junto a `list-surface.css`. `recipe-css-contract` lo tumbo y tenia razon — **el sistema de tokens esta indexado por componente**: toda clave de `recipes/base.ts` exige su `components/{c}/{c}.css`. `list-surface` puede vivir en `lib/` porque NO tiene clave de receta (sus `--list-*` viven dentro de su propio CSS, por `data-size`, no como defaults temeables en `:root`); los nuestros si lo son. La parte portante —«esto no es de nadie»— la lleva el nombre del gancho, no la carpeta. Queda escrito en la excepcion E-2.2 del README y en el PLAN. Sin cambio de comportamiento: 68 sustituciones de nombre, mismas reglas, mismos valores. Verificado en las tres rutas. check 69 = base intacta · component:audit affix 0/0 · fab 0/2 · menu-dial 0/0 · morfo:check affix PASS · layer:check 0/3 · contrato de capa 13/13 · recipe-css-contract + api-contract 50/50 · rtl 0/178 · smoke 311/311. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
### Corrección 2026-08-15 — el gancho no era de `Affix`, y el morfo no debía declararlo
Dos errores del mismo origen, corregidos tras una lectura del autor:
- **`affixMorfo` declaraba `data-affix-placement` y `data-affix-stretch`.** No
debía: los lee UNA capa, el CSS. Morfo es la capa declarativa **entre** capas;
un attr que sólo consume una no es contrato. La doctrina nombra la familia
exacta (`morfo.md` §`undeclaredState`: _«the escape hatch for attrs OUTSIDE the
contract — a visual wrapper's `data-size`»_). Los declaré porque `morfo-check`
los exigía — el guard clasificando por PREFIJO mientras la doctrina clasifica
por NATURALEZA.
- **El gancho se llamaba como el componente.** Lo estampan tres (`Affix`, `Fab`,
`MenuDial`): no es de ninguno. Pasa a **`data-viewport-placement`** /
`data-viewport-stretch`, nombre de CAPA. La colisión con `morfo-check`
desaparece por construcción, sin excepción en el guard.
Es el mismo error que `--fab-offset` leído por la capa: nombrar por un
participante algo que es de todos. `affixMorfo` se queda con lo que sí es
contrato — la identidad `data-affix`.
**Y un tercer intento que el guard revirtió**: mover el fichero a
`eidos/lib/viewport-placement.css`, junto a `list-surface.css`, para que su
ubicación dijera lo mismo que su nombre. `recipe-css-contract` lo tumbó, y con
razón — **el sistema de tokens está indexado por componente**: toda clave de
`recipes/base.ts` exige su `components/{c}/{c}.css`. `list-surface` puede vivir
en `lib/` porque NO tiene clave de receta (sus `--list-*` se definen dentro de su
propio CSS, por `data-size`, no como defaults temeables en `:root`). Los nuestros
sí lo son, así que necesitan clave, así que necesitan directorio de componente.
La parte portante —«esto no es de nadie»— la lleva el NOMBRE del gancho, no la
carpeta.

Powered by TurnKey Linux.