# 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 ``**, 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.