refactor(eidos)!: un eje de capa = un token publico + una ranura, y Fab deja de acunar los suyos

BREAKING: se retiran `--fab-offset` y `--fab-z`. No hay shim (regla del tier).
Rompe a quien los sobreescriba en CSS de app; el sustituto es `--affix-offset`
y la ranura `--_affix-z`.

Salio de auditar lo de ayer. `Affix` hacia que el prop `offset` escribiera el
MISMO nombre que la capa lee, y eso trajo dos problemas a la vez:

1. Un ciclo. `offset="var(--affix-offset)"` producia
   `--affix-offset: var(--affix-offset)` — custom property auto-referencial, que
   CSS resuelve a guaranteed-invalid, matando el calc() de los insets y dejandolos
   en `auto`. Medido: la caja aterrizaba a 324px del borde que debia tocar. Y la
   demo ofrecia ese valor COMO UNO DE LOS TRES CHIPS de `offset`, con la
   intencion de decir «el default».

2. Redundancia. Como el prop pisaba el token publico, Fab no podia usarlo y tenia
   que acunar `--fab-offset` / `--fab-z`, cuyo UNICO lector era el puente que los
   traducia de vuelta a los nombres de la capa. Un vocabulario paralelo para
   valores que la capa ya posee — justo la deriva que la capa venia a borrar.

La forma correcta ya la habla el arbol (`code.css` x6, `display.css` x6,
`calendar-select` x4, `button` x2, `dialog`, `kbd`): DOS nombres por eje.

  --affix-offset / --affix-z    publico, en :root, desde recipes/base.ts.
                                el default temeable, un retoque para todos.
  --_affix-offset / --_affix-z  la RANURA de override. Ahi escribe el wrapper
                                desde el prop, y ahi escribe un consumidor en su
                                puente cuando su default difiere.

Consumo: `var(--_affix-offset, var(--affix-offset))`. Con la ranura distinta del
token, el ciclo se vuelve imposible POR FORMA, no por aviso: escrito a mano,
`--_affix-offset: var(--affix-offset)` ahora resuelve a 16px en vez de morir.

El puente de Fab baja a dos lineas y pierde toda traduccion de tokens:

  [data-fab][data-affix-placement] {
    position: fixed;                    /* gana el empate con [data-button] */
    --_affix-z: var(--z-index-sticky);  /* el UNICO valor en que difiere */
  }

Lo que Fab necesitaba no era un token propio sino un VALOR distinto: se queda en
la banda `sticky` y no en el peldano `affix` (150), para que un menu o un dialogo
sigan abriendose por encima del FAB.

La regla que esto fija para consumidores futuros queda escrita en los dos README:
un eje de capa tiene dos nombres —default publico y ranura— y el consumidor
escribe la ranura. Acunar `--{componente}-{eje}` al lado recrea la deriva.

Verificado midiendo. Affix: `default` sin style inline y 16px, `0px` a 0, `2rem`
a 32. Fab: `--fab-offset` y `--fab-z` resuelven a cadena vacia (retirados), y las
esquinas siguen a 16px, `fixed`, z 100 via la ranura; `static` sigue `relative` /
`auto`. Identico a antes byte a byte en comportamiento.

component:audit affix PASS 0/0 · fab PASS 0/2 · check 69 = base intacta ·
rtl 0/178 · blocks 0 · eidos-lint invalid 0 · suite eidos 151/151 · smoke 311/311.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-dir-prefs
dev 2 months ago
parent 1fc0a7a96b
commit c945be23b6

@ -89,6 +89,25 @@ Two things came out of it:
left 0, top 162 = bottom 162 in a 380px host; `top-center` → 238 = 238.
The demo's `child` control exists to make the failure reproducible on purpose.
### Tokens — two names per axis, never one per consumer
| Name | Tier | Who writes it |
| -------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `--affix-offset` · `--affix-z` | **public**, `:root`, from `recipes/base.ts` | a theme. One retune reaches every consumer |
| `--_affix-offset` · `--_affix-z` | **override slot** | the wrapper, inline, from the `offset` prop; a consumer's bridge when its default differs |
Consumption is `var(--_affix-offset, var(--affix-offset))` — the idiom the tree
already speaks (`code.css` ×6, `display.css` ×6, `calendar-select` ×4,
`button` ×2, `dialog`, `kbd`).
**It is not decoration; a single tier was a live bug.** While the prop wrote the
same name the layer read, `offset="var(--affix-offset)"` produced a
self-referential custom property — a cycle, which CSS resolves to
_guaranteed-invalid_, killing the `calc()` and dropping the insets to `auto`.
Measured: the box landed **324px from the edge it was supposed to hug**, and the
demo shipped that value as a chip. Write slot ≠ read slot removes the failure by
shape rather than by warning.
### Why the attr split is load-bearing
`scripts/morfo-check.ts` selects `[data-affix]` page-wide and validates every
@ -110,13 +129,12 @@ stamp a layer attr on the element they already have, no wrapper node.
### What a consumer's bridge owes the layer (from `Fab`, migrated 2026-08-14)
Three lines, and one of them is not obvious:
Two lines, and neither is obvious:
```css
[data-fab][data-affix-placement] {
position: fixed; /* see below — NOT redundant */
--affix-offset: var(--fab-offset); /* the consumer keeps its own public tokens */
--affix-z: var(--fab-z);
--_affix-z: var(--z-index-sticky); /* the ONE value this consumer differs on */
}
```
@ -129,10 +147,17 @@ consumer whose composed primitive already declares `position` owes its bridge
that line.** The eight inset rules still come from the layer — only this one
property is fought for locally.
The tokens stay the consumer's (`--fab-offset` / `--fab-z` remain Fab's public
API; `--fab-z` deliberately stays on the `sticky` band, not the `affix` rung, so
menus still open over a FAB). And `placement="static"` stamps NO hook, so the
opt-out is an absence rather than a rule.
**A consumer mints NO tokens of its own** — the rule, and Fab broke it for a
day. It shipped `--fab-offset` / `--fab-z`, whose only reader was this bridge
translating them straight back into the layer's names: a parallel vocabulary for
values the layer already owns, which is exactly the drift the layer exists to
end. Both were retired on 2026-08-15. What survives is the ONE value Fab
genuinely differs on, written into the layer's **override slot**:
`--_affix-z: var(--z-index-sticky)`, so a menu or dialog still opens OVER a FAB
instead of under it.
The two tiers per axis are what make that possible — see «Tokens» above.
`placement="static"` stamps NO hook, so the opt-out is an absence, not a rule.
## Comparativa

@ -13,13 +13,32 @@
* morfo contract — morfo-check skips attrs that
* do not start with the morfo's own kebab.
*
* `Fab` (4 zones) and `MenuDial` (9) each ship a private copy of these rules
* today, with divergent tokens (`--fab-offset` public vs `--_menu-dial-offset`
* internal; `var(--fab-z)` vs `var(--z-index-sticky, 1100)` — a 1100 fallback
* over a token that is 100). They migrate onto this file by importing it and
* stamping `data-affix-placement`, exactly the way the menu / listbox recipes
* consume `lib/list-surface.css`. Until then this is the single source and
* they are the registered debt (README → Gaps).
* `Fab` migrated onto it on 2026-08-14 (it imports this file and stamps the
* hook); `MenuDial` still ships its own 9-zone copy and is the registered debt
* (README → Gaps). Same shape the menu / listbox recipes use for
* `lib/list-surface.css`.
*
* TOKENS — two names per axis, and the split is the whole point:
*
* `--affix-offset` / `--affix-z` PUBLIC, emitted at `:root` from
* `recipes/base.ts`. The themeable default;
* one retune reaches every consumer.
* `--_affix-offset` / `--_affix-z` the OVERRIDE SLOT. The wrapper writes it
* inline from the `offset` prop; a consumer
* writes it in its bridge when its default
* differs (Fab keeps the `sticky` band that
* way, so menus still open over a FAB).
*
* A consumer therefore NEVER mints a `--{component}-offset` of its own. `Fab`
* did until 2026-08-15, and `--fab-offset` / `--fab-z` were tokens whose only
* reader was the bridge that translated them straight back — the exact drift
* this layer exists to end. They were retired with the two-tier read below.
*
* The two tiers also make a footgun impossible: while the prop wrote the SAME
* name the layer read, `offset="var(--affix-offset)"` produced a self-referential
* custom property — a cycle, resolved to guaranteed-invalid, which killed the
* `calc()` and dropped the insets to `auto`. Measured: the box landed 324px from
* the edge it was supposed to hug. Write slot ≠ read slot fixes it by shape.
*
* It POSITIONS; it does not decorate. Surface, padding and measure are the
* consumer's — a notice strip brings its own `Banner`, a FAB its own `Button`.
@ -27,7 +46,7 @@
[data-affix-placement] {
position: fixed;
z-index: var(--affix-z);
z-index: var(--_affix-z, var(--affix-z));
/*
* `env(safe-area-inset-*)` is PHYSICAL — the spec ships no logical variants
@ -58,27 +77,35 @@
/* ── Anchored edges ──────────────────────────────────────────────────── */
[data-affix-placement^='top-'] {
inset-block-start: calc(var(--affix-offset) + env(safe-area-inset-top, 0px));
inset-block-start: calc(
var(--_affix-offset, var(--affix-offset)) + env(safe-area-inset-top, 0px)
);
}
[data-affix-placement^='bottom-'] {
inset-block-end: calc(var(--affix-offset) + env(safe-area-inset-bottom, 0px));
inset-block-end: calc(
var(--_affix-offset, var(--affix-offset)) + env(safe-area-inset-bottom, 0px)
);
}
[data-affix-placement^='left-'] {
inset-inline-start: calc(var(--affix-offset) + var(--_affix-safe-inline-start));
inset-inline-start: calc(
var(--_affix-offset, var(--affix-offset)) + var(--_affix-safe-inline-start)
);
}
[data-affix-placement^='right-'] {
inset-inline-end: calc(var(--affix-offset) + var(--_affix-safe-inline-end));
inset-inline-end: calc(var(--_affix-offset, var(--affix-offset)) + var(--_affix-safe-inline-end));
}
[data-affix-placement$='-start'] {
inset-inline-start: calc(var(--affix-offset) + var(--_affix-safe-inline-start));
inset-inline-start: calc(
var(--_affix-offset, var(--affix-offset)) + var(--_affix-safe-inline-start)
);
}
[data-affix-placement$='-end'] {
inset-inline-end: calc(var(--affix-offset) + var(--_affix-safe-inline-end));
inset-inline-end: calc(var(--_affix-offset, var(--affix-offset)) + var(--_affix-safe-inline-end));
}
/* ── Centring on the free axis ───────────────────────────────────────── */

@ -34,8 +34,12 @@
const eidos = ActiveEidos.require();
const resolvedPlacement = $derived(eidos.resolve(placement, 'bottom-center'));
// The prop writes the OVERRIDE SLOT, never the public token it falls back to.
// Same name on both sides would make `offset="var(--affix-offset)"` a
// self-referential custom property — a cycle, guaranteed-invalid, insets to
// `auto`. Two tiers, no footgun. See `affix.css`.
const composedStyle = $derived(
composeInlineStyle(offset ? `--affix-offset: ${offset};` : undefined, style)
composeInlineStyle(offset ? `--_affix-offset: ${offset};` : undefined, style)
);
</script>

@ -53,7 +53,13 @@ export type AffixProps = Omit<HTMLAttributes<HTMLElement>, 'style' | 'children'>
/**
* Distance from the anchored viewport edge — any CSS length. Safe-area
* insets are added on top, so a bottom strip clears the home indicator
* without the consumer knowing it exists.
* without the consumer knowing it exists. No effect on `center`, which
* anchors to no edge.
*
* Omit it for the default — the value lands in the layer's override slot
* (`--_affix-offset`), which falls back to the public `--affix-offset`.
* Because the two names differ, even `offset="var(--affix-offset)"` resolves
* cleanly instead of building a self-referential cycle.
* @default var(--affix-offset) (≈ space-4)
*/
offset?: string;

@ -77,10 +77,18 @@ WAI-ARIA: a FAB is just a [Button](https://www.w3.org/WAI/ARIA/apg/patterns/butt
layer's base rule, and both files are code-split — on the wrong load order the
layer loses the tie and a floating FAB silently computes `relative`. The
INSETS still come from the layer; only that one property is fought for locally.
- **`--fab-offset` and `--fab-z` stay Fab's public API.** The bridge points the
layer at them rather than replacing them, so nothing a consumer overrides
changes. `--fab-z` deliberately stays on the `sticky` band and NOT on the new
`affix` rung (150): a menu or dialog must still open over the FAB.
- **`--fab-offset` and `--fab-z` were RETIRED** (2026-08-15, breaking for anyone
overriding them in app CSS — no shim, per the tier's rule). Their only reader
was the bridge, translating them straight back into the layer's names: a
parallel vocabulary for values the layer already owns. The offset default now
comes from `--affix-offset` (the same `--space-4`), and the `offset` prop
writes the layer's override slot `--_affix-offset`. What Fab genuinely needs
is not a token but a different VALUE, and that is the second bridge line:
`--_affix-z: var(--z-index-sticky)` keeps the FAB on the sticky band and NOT
on the `affix` rung (150), so a menu or dialog still opens over it.
**The rule this sets for every future consumer**: a layer axis has two names —
a public default and an override slot — and a consumer writes the slot. Minting
`--{component}-{axis}` alongside them recreates the drift the layer removed.
- **Hover lift via `translate`, not `transform`.** Button's active press-squeeze
animates `transform: scale(...)`; the FAB's lift uses the `translate` property
so the two compose instead of clobbering each other (same lesson as IconButton).

@ -116,10 +116,16 @@
* the FAB — same behaviour as before, expressed by absence instead of by a
* fifth rule.
*
* The tokens stay Fab's: `--fab-offset` and `--fab-z` remain its public API
* (and `--fab-z` deliberately stays on the `sticky` band, NOT the `affix` rung
* — a menu or dialog must still open over the FAB, which is the call the README
* already made). The bridge only tells the layer where to read them.
* Fab mints NO placement tokens of its own (2026-08-15). `--fab-offset` and
* `--fab-z` were retired: their only reader was this bridge, translating them
* straight back into the layer's names — a parallel vocabulary for values the
* layer already owns, which is the drift the layer exists to end. The offset
* default now comes from `--affix-offset` (same `--space-4`), and the one value
* Fab genuinely needs to differ on rides the layer's override SLOT below.
*
* `--_affix-z` is that difference, and it is deliberate: a FAB stays on the
* `sticky` band, NOT on the `affix` rung (150), so a menu or dialog still opens
* OVER it — the call the README already made.
*
* `position` is re-asserted here, and that is not redundancy: `button.css`
* declares `position: relative` on `[data-button]` at the SAME specificity
@ -131,8 +137,7 @@
*/
[data-fab][data-affix-placement] {
position: fixed;
--affix-offset: var(--fab-offset);
--affix-z: var(--fab-z);
--_affix-z: var(--z-index-sticky);
}
@media (prefers-reduced-motion: reduce) {

@ -42,7 +42,8 @@
const resolvedSize = $derived(eidos.resolve(size, 'md'));
const resolvedPlacement = $derived(eidos.resolve(placement, 'bottom-end'));
const composedStyle = $derived(
composeInlineStyle(offset ? `--fab-offset: ${offset};` : undefined, style)
// Writes the LAYER's override slot — Fab mints no offset token of its own.
composeInlineStyle(offset ? `--_affix-offset: ${offset};` : undefined, style)
);
</script>

@ -52,7 +52,9 @@ export type FabProps = Omit<
placement?: ResponsiveProp<FabPlacement>;
/**
* Distance from the viewport edges when floating (any CSS length). Ignored
* for `placement="static"`. @default var(--fab-offset) (≈ space-5)
* for `placement="static"`. Lands in the shared layer's override slot
* (`--_affix-offset`); Fab mints no offset token of its own.
* @default var(--affix-offset) (≈ space-4)
*/
offset?: string;
};

@ -3433,8 +3433,6 @@
--fab-icon-sm: 1.25rem;
--fab-icon-md: 1.5rem;
--fab-icon-lg: 1.75rem;
--fab-offset: var(--space-4);
--fab-z: var(--z-index-sticky);
--fab-shadow: var(--shadow-overlay);
--fab-lift: 2px;
--onion-menu-trigger-lift: var(--fab-lift);

@ -5033,8 +5033,11 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
'icon-sm': '1.25rem',
'icon-md': '1.5rem',
'icon-lg': '1.75rem',
offset: 'var(--space-4)',
z: 'var(--z-index-sticky)',
// NO `offset` / `z` here (retired 2026-08-15). Placement values belong to
// the shared affix layer: the default is `--affix-offset`, and the one
// value Fab differs on (the `sticky` band) rides `--_affix-z` in its
// bridge. Minting `--fab-offset` / `--fab-z` only created a parallel
// vocabulary whose sole reader was the bridge translating it back.
shadow: 'var(--shadow-overlay)',
lift: '2px'
},

@ -38,7 +38,7 @@
// anything, which is a control that lies (D-7.1). Turn it on to see the
// full-bleed strip; leave it off to read the grid.
let stretch = $state(false);
let offset = $state('0px');
let offset = $state<string | undefined>('0px');
/**
* Demo-only. Affix SIZES NOTHING — it anchors a box and the child decides how
* wide it is. That is invisible until you put the wrong child in a zone: a
@ -66,7 +66,17 @@
'bottom-center',
'bottom-end'
];
const offsets = ['0px', 'var(--affix-offset)', '2rem'];
// `undefined` = omit the prop, which is what "default" MEANS. This list used
// to offer `var(--affix-offset)` for that, and it was a live footgun: while
// the prop wrote the same name the layer read, that value made the custom
// property self-referential — a cycle, guaranteed-invalid, insets to `auto`,
// box 324px off. The two-tier token fixed the cause; this fixes the chip that
// taught it.
const offsets: { value: string | undefined; label: string }[] = [
{ value: undefined, label: 'default' },
{ value: '0px', label: '0px' },
{ value: '2rem', label: '2rem' }
];
// ── System (foundation) axes — applied to the stage ─────────────────────
let density = $state('comfortable');
@ -110,7 +120,7 @@
'<Affix',
placement !== 'bottom-center' && ` placement="${placement}"`,
stretch && ' stretch',
offset !== 'var(--affix-offset)' && ` offset="${offset}"`,
offset !== undefined && ` offset="${offset}"`,
'>',
' <Banner intent="risk">We use cookies.</Banner>',
'</Affix>'
@ -292,8 +302,10 @@
</span>
<span data-uix-chips role="radiogroup">
{#each offsets as o}
<button data-uix-chip data-active={offset === o} onclick={() => (offset = o)}
>{o}</button
<button
data-uix-chip
data-active={offset === o.value}
onclick={() => (offset = o.value)}>{o.label}</button
>
{/each}
</span>

@ -289,7 +289,7 @@
<tr><td class="name">extended</td><td class="type">boolean</td><td class="default">false</td><td>Pill with a visible label vs circular icon-only.</td></tr>
<tr><td class="name">size</td><td class="type">'sm' | 'md' | 'lg'</td><td class="default">'md'</td><td>FAB scale (≈ 40 / 56 / 72px) — its own, larger than a control.</td></tr>
<tr><td class="name">placement</td><td class="type">'bottom-end' | 'bottom-start' | 'top-end' | 'top-start' | 'static'</td><td class="default">'bottom-end'</td><td>Where it floats (fixed + safe-area). <code>static</code> = consumer positions it.</td></tr>
<tr><td class="name">offset</td><td class="type">string</td><td class="default">var(--fab-offset)</td><td>Distance from the edges when floating.</td></tr>
<tr><td class="name">offset</td><td class="type">string</td><td class="default">var(--affix-offset)</td><td>Distance from the edges when floating. Writes the shared layer's override slot; Fab mints no offset token of its own.</td></tr>
<tr><td class="name">icon</td><td class="type">Snippet</td><td class="default empty">—</td><td>The glyph (visible in both forms).</td></tr>
<tr><td class="name">variant</td><td class="type">ButtonVariant</td><td class="default">'solid'</td><td>Forwarded to Button (FAB defaults to the prominent solid).</td></tr>
<tr><td class="name">intent / color</td><td class="type">like Button</td><td class="default">neutral / primary</td><td>Forwarded — the screen's primary action.</td></tr>
@ -389,7 +389,7 @@
<tr><td class="name"><code>[data-fab]:hover:not([data-disabled])</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Hover lift via the <code>translate</code> property (composes with Button's press-squeeze).</td></tr>
<tr><td class="name"><code>[data-button][data-fab][data-fab-size='…']</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>FAB diameter + glyph size (out-specifies Button).</td></tr>
<tr><td class="name"><code>[data-fab][data-extended]</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Pill: auto width + label padding + gap.</td></tr>
<tr><td class="name"><code>[data-fab][data-affix-placement]</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>The BRIDGE to the shared layer (<code>components/affix/affix.css</code>), which owns the fixed positioning + safe-area insets. Three lines: it re-asserts <code>position</code> (Button declares <code>relative</code> at the same specificity, and both files are code-split) and points the layer at <code>--fab-offset</code> / <code>--fab-z</code>. <code>static</code> stamps no hook.</td></tr>
<tr><td class="name"><code>[data-fab][data-affix-placement]</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>The BRIDGE to the shared layer (<code>components/affix/affix.css</code>), which owns the fixed positioning + safe-area insets. Three lines: it re-asserts <code>position</code> (Button declares <code>relative</code> at the same specificity, and both files are code-split) and writes <code>--_affix-z</code>, the one value a FAB differs on (it stays on the sticky band so menus open over it). <code>--fab-offset</code> / <code>--fab-z</code> fueron retirados. <code>static</code> stamps no hook.</td></tr>
</tbody>
</table>
</div>

Loading…
Cancel
Save

Powered by TurnKey Linux.