29 KiB
PLAN — Affix: fijar contenido a un borde del viewport (componente de canon)
✅ CERRADO — 2026-08-14
Affixestá 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. Lee §6 antes que §2 o construirás otra cosa.Handoff de la jornada, y lo que sigue abierto:
CONTINUE-affix.md— incluye el eje que este plan abrió sin preverlo (los 25 knobs visuales fuera del contrato).Fuente viva del componente:
src/uix/eidos/components/affix/README.md.Deuda abierta que dejó: migrar
FabyMenuDiala 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.mdy 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.
- Leer la documentación. No ojearla, no «la parte relevante»: leerla.
- No sacar NINGUNA conclusión precipitada sin haber leído TODA la documentación del eje que se toca.
- 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 |
la puerta única; enlaza el doc de cada fase |
guides/component-guide.md §4–5 |
compose-first y la clasificación componente / capa / átomo pasivo |
guides/completion-checklist.md |
la matriz de aceptación (A-* · E-* · R-* · D-* · F-*) |
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 §sticky (F1.1) |
el dossier ya separa Affix de Sticky y lista las brechas del segundo |
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 §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:
- Rompe una promesa escrita en su propio recipe:
data-stuck+data-edgeespejan@container scroll-state(stuck: top|bottom)«para que los selectores traduzcan mecánicamente a la query nativa cuando aterrice». Un modofixedno tiene equivalente en esa query. - Fusiona lo que la referencia separa (punto anterior).
data-stucky el centinela pierden significado: enfixedel elemento nunca se pega ni se despega.- Desaparece el hueco en el layout, el ancho colapsa sin insets inline, y el
--sticky-z-indexestá 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 — 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:
- Scroll largo real en los dos bordes, midiendo la posición en tres puntos.
Un gate de tipos NO verifica comportamiento:
blocks:checkysvelte-checkdieron verde sobre unedge="bottom"que no hacía nada. - Z-index computado del elemento frente al del cromo de la página — el fallo exacto que el usuario vio en pantalla.
- Captura de los dos bordes, y MIRARLA antes de decir que está.
- Ancestro con
transformen la demo: comprobar que sinportalfalla y conportalfunciona, que es la razón de ser del prop. - Claro/oscuro y RTL (
inline="start"/"end"deben espejar).
5. Qué desbloquea
bannerrecuperasticky/offset/edgecomponiendoAffixparabottomyStickyparatop— diez minutos cuando la pieza exista. La necesidad y el precedente están en el ledger del tier (AUDIT-blocks-ledger.md), dondesite-headerya expone esa costura.- La categoría «cookie consent» del dossier, que ≥2 referencias shippean y el tier no puede hoy.
- El gap de
BoxsinzIndexdeja de ser urgente: quien necesite fijar algo componeAffixen 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
- La z: peldaño nuevo en el canon. Con la tira en
top,--affix-zempataba 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_INDEXy la lista cerradaZ_INDEX_KEYS— sólo el valor tira 34 tests conunknown token key. AffixNO compone<Box>, aunque sea el patrón de todos los primitivos de layout:box.cssdeclaraposition: var(--box-position, revert-layer)a la MISMA especificidad (0,1,0) que el gancho de la capa, y eidos no usa@layer, así querevert-layerse comporta comorevert. En un orden de carga dondebox.cssllegue después, el affix computaríaposition: static— unfixedque no hace nada, justo lo que este componente viene a terminar.stretches sólo de los bordes de bloque. La versión simétrica (raíl a toda la altura enleft/right) se envió y se retiró el mismo día:Affixno dimensiona a su hijo, así que el raíl era una caja invisible de 380px con un hijo de 47px arriba — idéntico atop-starten pantalla. El marco medía bien; la pintura no. Registrado en Gaps.- Un default de demo puede matar el control principal. Con
stretchON,top-start/top-center/top-endson la misma tira: seis de las nueve zonas dejaban de cambiar nada (D-7.1). La demo arranca constretchOFF 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
FabyMenuDiala la capa (el autor lo acotó fuera: «adapta sólo banner momentáneamente»). Ahí caen también su defecto RTL de safe-area inline (emparejaninset-inline-startconenv(safe-area-inset-left), que despeja la muesca equivocada en RTL apaisado) y el fallback1100demenu-dial. banner: envíaaffix="top" | "bottom"+affixOffset. Su README tenía la disposición contraria («app-land, el canon ya traeSticky») y se corrigió: nombraba un componente que NO puede hacerlo.
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
positionsi el primitivo compuesto declara uno. La base de la capa es (0,1,0) ybutton.cssdeclaraposition: relativesobre[data-button]al mismo peso, con los dos ficheros code-split: el orden de carga decide.Fablo debe;MenuDialno, porque no compone nada en el marco. data-placementno siempre es del posicionado. EnMenuDialtiene 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. EnFab, 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, ymorfo:checkenfab(data-fab-sizesin declarar) ymenu-dial(data-stateausente).
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:
affixMorfodeclarabadata-affix-placementydata-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'sdata-size»). Los declaré porquemorfo-checklos 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 adata-viewport-placement/data-viewport-stretch, nombre de CAPA. La colisión conmorfo-checkdesaparece 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.
2026-08-15 (tarde) — la capa sale de components/ porque acoplaba a sus consumidores
El autor lo cortó en seco, y con razón: mientras la capa vivió en
components/affix/affix.css, Fab y MenuDial la importaban con
'../affix/affix.css' — un componente dependiendo del directorio de otro,
que es canon prohibido («un componente no es librería de otro»).
Mi primer intento de moverla a lib/ lo revertí por el motivo equivocado:
recipe-css-contract falló y dejé que el guard dictara la arquitectura, en vez
de preguntarme por qué list-surface sí puede vivir ahí. La respuesta era el
diseño entero: list-surface no tiene clave de receta. Declara sus --list-*
dentro de su propio CSS, componiendo primitivas ya temeables.
Aplicado: la capa declara ahora --viewport-placement-offset / -z sobre el
propio gancho, compuestos de var(--space-4) y var(--z-index-affix). Sin clave
en recipes/base.ts, sin exigencia de directorio de componente, sin
acoplamiento. Los tres consumidores importan '../../lib/viewport-placement.css'.
Y el retoque queda a la altura correcta: un tema mueve la escala de espacio y la escalera de z, no un alias por componente de ellas.