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

29 KiB

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. 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 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 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:

  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 — 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.

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).

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.

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.

Powered by TurnKey Linux.