|
|
# 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.
|
|
|
>
|
|
|
> **Handoff de la jornada, y lo que sigue abierto**:
|
|
|
> [`CONTINUE-affix.md`](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`](../../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.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 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.
|