| [`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é:
| `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.
| **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-*` |
| **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
| 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ó: