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

306 lines
23 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.

Powered by TurnKey Linux.