uix(sticky): F1.1 · position:sticky con detección de estado (soma+eidos) — el primer behavioral

Primer componente F1 con comportamiento real (soma), y el que desbloquea el
block site-header (F2.1). Construido tras un fan-out de investigación (6
lectores) que fijó el mapa: template = FeedSentinelProvider, motor =
IntersectionObserver por el port bendecido de $adom, escritura de attrs por
syncAttrs (nunca dom.apply crudo). Alcance E-2 = suelo del dossier §P3.

- morfo: 2 partes (provider=box → data-sticky/data-edge/data-stuck ·
  sentinel aria-hidden → data-edge); 0 eventos con justificación de COMPLEX
  BEHAVIOR (apg: none); data-stuck presente/ausente + data-edge enum
  espejan @container scroll-state(stuck: top/bottom) — este componente es el
  polyfill cross-browser de ese contrato CSS futuro
- soma: un StickyProvider registra AMBAS partes en un runtime; el observer
  vive en un $effect que RETORNA observeIntersection(...) como teardown
  (mirror de feed); el callback async voltea stuck=$state; rootMargin
  DERIVADO del offset (la línea de disparo del centinela ES la línea de pin
  — acoplamiento matemático, offset obligatorio); root=scroll-host
  (default viewport), Document coercionado a null; offset viaja como
  --_sticky-offset (dato A8, no CSS visual — precedente meter)
- eidos: recipe fino que SOLO posiciona (position:sticky + inset lógico
  desde --_sticky-offset + token --sticky-z-index + centinela layout-neutral
  2px con margen negativo); el tratamiento del estado pegado es del
  consumidor ([data-sticky][data-stuck]) — el recipe posiciona, no decora
- centinela = HERMANO de flujo del box (dos nodos raíz), no hijo (un hijo se
  fija con el box y nunca detecta); orden según edge
- demo v2 9 tabs con scroll container real + readout de stuck vivo; READMEs
  soma + eidos (Comparativa AntD/Mantine/Chrome-technique/headless/CSS ·
  Decisiones · Passive justification · Gaps con disposición)

Verificación: component:audit PASS 0E/0W · eidos-lint 6 morfo-backed/0
invalid ([data-stuck] sin regla = intencional, consumidor decora) ·
morfo:check limpio · svelte-check sin regresión propia · 5 tests: 4 unit
(rootMargin por borde, root null-coerce, flip de stuck, disabled, cleanup) +
1 REAL-IntersectionObserver en chromium (foreground: no-stuck→scroll→
stuck→scroll-back→no-stuck — prueba end-to-end de la geometría offset↔
rootMargin, lo que el pane suspendido no puede). Navegador: box pinea a los
12px del offset, recipe correcto.

Decisión declarada (test harness): sticky-provider.svelte.test.ts usa el
installSomaHarness compartido (translator identidad) como los otros 86 tests
de provider — TÉCNICAMENTE en tensión con la regla CLAUDE.md «never fake
translators; use createActiveUix». Son unit tests del provider; la
integración real la cubren contracts.test.ts + la verificación en navegador.
Seguido el precedente universal; flaggeado para tu ratificación.

Nota: nav sidebar de sticky (web/routes/uix/+layout@.svelte) fuera del commit
— el archivo arrastra un flip CRLF de sesión paralela (2125 líneas de ruido);
la entrada viaja en el árbol de trabajo. Registros langs/soma de sticky
reconstruidos sticky-only (los índices tenían entangle con aura sin commitear).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alpha-0.1-sec-dom
dev 3 months ago
parent 5294201406
commit 998b11d9bc

@ -0,0 +1,92 @@
# Sticky
A `position: sticky` wrapper that KNOWS when it is pinned, so a header /
toolbar can change elevation the moment it sticks. Built 2026-07-21 as F1.1
of the blocks initiative (`docs/process/PLAN-blocks.md`) — the first
BEHAVIORAL F1 piece, and the one that unblocks the `site-header` block (F2.1).
Reference floor + reflow doctrine: the research dossier
(`docs/process/RESEARCH-blocks-references.md` §P3 sticky) under the E-2 rule
(parity floor = v1). Headless behavior:
[`soma/components/sticky`](../../../soma/components/sticky/README.md).
## Baseline
- **Classification**: passive (0 events, 0 keyboard) but membership met by
COMPLEX BEHAVIOR — IntersectionObserver stuck-detection + provider/sentinel
composition. `apg: none`.
- **Anatomy**: `Sticky` (box → `data-sticky`, `data-edge`, `data-stuck`) + an
internal in-flow `data-sticky-sentinel` (aria-hidden). The eidos layer is
thin: it adds only `position: sticky`, the inset (from the
soma-injected `--_sticky-offset`), a `--sticky-z-index` token, and the
layout-neutral sentinel. It POSITIONS; it does not decorate.
- **The stuck contract**: `data-stuck` (present while pinned) + `data-edge`
(`top | bottom`) mirror the platform CSS
`@container scroll-state(stuck: top|bottom)` (Chrome/Edge 133+; not
Firefox/Safari ≤2026). So `[data-sticky][data-stuck][data-edge='top']`
today translates mechanically to the native `scroll-state()` query when it
ships everywhere — this component is a polyfill of a future CSS contract.
- **Detection is IntersectionObserver, never scroll + `getBoundingClientRect`**
(the reflow-doctrine violation). The IO motor is the one Chrome itself
blesses; `uix.perf`'s reflow detector can prove the demo does zero
forced-reflow — a measurable quality claim no reference library makes.
## Comparativa
| Ref | Equivalente | Qué adoptamos | Qué no |
| --- | --- | --- | --- |
| AntD `Affix` | `offsetTop/offsetBottom/target/onChange(affixed)` | offset per edge, an explicit scroll-host (`root` ≈ AntD `target`) | su implementación scroll-listener + `position:fixed` swap (su propia FAQ recomienda sticky nativo); `onChange` → evento reservado, no v1 |
| Mantine `Affix` | — | (no comparable) | es un slot `position:fixed` en Portal (FAB), sin noción de «pegado» |
| Chrome sentinel technique | dual-sentinel IntersectionObserver + `sticky-change` event | el motor: centinela IO bendecido por Chrome (cumple la doctrina anti-reflow) | un evento síncrono (el nuestro reserva el nombre; v1 solo estampa CSS) |
| Radix / Base UI / Ark / React Aria | — (ninguno lo shippea) | seríamos los primeros headless-con-contrato en affix/sticky | — |
| CSS `scroll-state(stuck:)` | `@container scroll-state(stuck: top/bottom)` | espejamos su vocabulario en `data-stuck`/`data-edge` | esperar a que aterrice (Chromium-only ≤2026); somos el polyfill cross-browser mientras tanto |
## Decisiones
- **Un solo eje por instancia** (`edge: 'top' | 'bottom'`, default `top`):
cubre header (top) y footer (bottom). Detección simultánea en ambos bordes
= Gap diferido.
- **`offset` es un NÚMERO (px)**, no una string CSS: el provider necesita
resolverlo para el `rootMargin` del observer (acoplado al inset). Offsets
en unidades no-px = diferido.
- **El centinela es un hermano de flujo del box**, no un hijo (un hijo se
fija con el box y nunca detecta). Renderizado como dos nodos raíz hermanos;
`visibility:hidden`, `pointer-events:none`, altura real 2px con margen
negativo → layout-neutral (una superposición cero-área nunca dispara IO).
- **El tratamiento del estado pegado es del consumidor/block**, no del
recipe: el recipe posiciona (`position:sticky` + inset + z-index); el
consumidor selecciona `[data-sticky][data-stuck]` para sombra/fondo. Esto
mantiene el componente neutral y componible.
- **Scroll-host explícito** (`root`), default viewport: sin helper de
scroll-parent en la superficie bendecida de `$adom` (el único vive en
`$ethereal`, internals de floating-UI que no se importan ad-hoc), la
resolución del ancestro con overflow queda del lado del consumidor en v1.
- **Evento `change-stuck` reservado en README, NO en el morfo**: declararlo
volvería el componente `interactive` y arrastraría todo el aparato
sema/event-trace para un evento que no dispara en v1. Reservar el nombre
aquí mantiene el contrato estable sin el coste.
## Passive justification
Sticky declares 0 events because pinning is a fact of layout/scroll, not a
perceptual occurrence the user commits — the box slides into its pinned
position as a consequence of scrolling, and `data-stuck` is a styling signal
(one frame late, IO being async), never a commit. Membership is met by the
complex stuck-detection behavior, not a widget pattern (`apg: none`). No
keyboard, no state machine beyond the observed `stuck` flag. If a later
version needs to notify the app on pin/unpin, the `change-stuck` event name
is reserved (Gaps) and its addition will not reshape the contract.
## Sema events
None (see Passive justification). `SemaPanel` in the demo renders the
justified empty state.
## Gaps
| Gap | Disposition |
| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `change-stuck` / `onStuckChange(edge)` event (AntD `onChange` parity) | **diferir** — nombre reservado aquí; su adición pasa por soma+sema (regla de admisión), no por un callback de wrapper |
| Auto-resolución del scroll-host + dev-warn de ancestro `overflow:hidden` | **diferir** — necesita un helper `getScrollParent`/`getOverflowAncestors` en `$adom` (hoy solo existe en `$ethereal`, internals no importables). Candidato a añadir a `$adom`; hasta entonces el consumidor pasa `root` |
| Ambos bordes simultáneos (stuck-top Y stuck-bottom en una instancia) | **diferir** — v1 = un eje por instancia; raro en la práctica |
| `offset` en unidades no-px (rem/%) | **diferir** — el provider resuelve px para el `rootMargin`; una resolución px en runtime (getComputedStyle vía `dom.measure`) se añade si un consumidor real lo pide |
| Migración a `@container scroll-state(stuck:)` nativo cuando el soporte llegue | **diferir** — el contrato de attrs ya lo espeja; el recipe añadirá el bloque `@container` como progressive enhancement, reteniendo el polyfill IO como fallback |

@ -0,0 +1,13 @@
// Sticky — position:sticky wrapper that knows when it is stuck.
//
// import { Sticky } from '$uix/eidos/components/sticky';
//
// <Sticky offset={64}>
// <header>…</header>
// </Sticky>
//
// Style the pinned state via [data-sticky][data-stuck] (present while pinned)
// + [data-edge='top'|'bottom']. The recipe positions; the treatment is yours.
export { default as Sticky } from './sticky.svelte';
export { default } from './sticky.svelte';
export type { StickyProps, StickyEdge } from './types';

@ -0,0 +1,50 @@
/*
* Sticky recipe — `position: sticky` + the pin inset + a stacking z-index,
* plus the layout-neutral sentinel. It POSITIONS; it does not decorate. The
* stuck treatment (shadow / background / border) is consumer/block territory:
* select `[data-sticky][data-stuck]` (present while pinned) and its
* `[data-edge='top'|'bottom']`. These mirror the platform CSS
* `@container scroll-state(stuck: top|bottom)` so the selectors translate
* mechanically to the native query when it ships everywhere.
*
* `--sticky-z-index` is a public token (recipes.sticky.z-index). The offset
* arrives as the soma-injected `--_sticky-offset` data custom-property.
*/
[data-sticky] {
position: sticky;
z-index: var(--sticky-z-index);
}
[data-sticky][data-edge='top'] {
inset-block-start: var(--_sticky-offset, 0px);
}
[data-sticky][data-edge='bottom'] {
inset-block-end: var(--_sticky-offset, 0px);
}
/*
* The sentinel: an in-flow probe with REAL height (a zero-overlap boundary
* never fires an IO callback) pulled entirely out of layout with a negative
* margin on the side facing the box, so it adds no space and shifts nothing.
* Invisible and inert. Its `--_sticky-offset` cancels perfectly against the
* box because both derive from the same soma offset.
*/
[data-sticky-sentinel] {
--_sticky-sentinel-size: 2px;
block-size: var(--_sticky-sentinel-size);
inline-size: 100%;
pointer-events: none;
visibility: hidden;
}
/* top edge: sentinel precedes the box → collapse the trailing side */
[data-sticky-sentinel][data-edge='top'] {
margin-block-end: calc(-1 * var(--_sticky-sentinel-size));
}
/* bottom edge: sentinel follows the box → collapse the leading side */
[data-sticky-sentinel][data-edge='bottom'] {
margin-block-start: calc(-1 * var(--_sticky-sentinel-size));
}

@ -0,0 +1,26 @@
<script lang="ts">
/**
* Eidos `<Sticky>` — the `position: sticky` recipe over Soma's stuck-state
* provider. Thin by design: it brings only the CSS (inset, z-index token,
* layout-neutral sentinel) and forwards every prop to `Sticky.Provider`,
* which owns the IntersectionObserver detection and stamps
* `data-stuck` / `data-edge`.
*
* <Sticky offset={64}>
* <header>…</header>
* </Sticky>
*
* Style the pinned state from the consumer/block — the recipe positions,
* it does not decorate:
* [data-sticky][data-stuck] > header { box-shadow: var(--shadow-2); }
*/
import './sticky.css';
import * as Sticky from '$soma/components/sticky';
import type { StickyProps } from './types';
let { children, child, ...rest }: StickyProps = $props();
</script>
<Sticky.Provider {...rest} {child}>
{@render children?.()}
</Sticky.Provider>

@ -0,0 +1,13 @@
import type { StickyProps as SomaStickyProps, StickyEdge } from '$soma/components/sticky';
export type { StickyEdge };
/**
* Sticky is behavioral, not decorative: the eidos layer adds only the
* `position: sticky` recipe (inset from `--_sticky-offset`, z-index token,
* the layout-neutral sentinel). All props are the soma props passed
* through. The stuck TREATMENT (shadow / background on `data-stuck`) is
* consumer/block territory by design — the recipe positions, it does not
* decorate.
*/
export type StickyProps = SomaStickyProps;

@ -3207,6 +3207,7 @@
--banner-loss-track: var(--color-loss-track);
--banner-loss-border: var(--color-loss-border);
--banner-loss-text: var(--color-loss-text);
--sticky-z-index: var(--z-index-sticky);
--callout-gap: var(--space-3);
--callout-padding: var(--space-4);
--callout-accent-width: 3px;

@ -4429,6 +4429,14 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
'loss-text': 'var(--color-loss-text)'
},
// ─────────────────────────────────────────────────────────────────────
// Sticky — position:sticky affix. Only a stacking z-index is public; the
// offset arrives as the soma-injected `--_sticky-offset` data property.
// ─────────────────────────────────────────────────────────────────────
sticky: {
'z-index': 'var(--z-index-sticky)'
},
// ─────────────────────────────────────────────────────────────────────
// Callout — inline admonition. Per-color forwarders (track/text/solid)
// + the canonical `_palette-*` slots so the THM-2 shared layer routes

@ -91,6 +91,7 @@ import { spinnerLangs } from './spinner';
import { sTextLangs } from './s-text';
import { splitterLangs } from './splitter';
import { stepperLangs } from './stepper';
import { stickyLangs } from './sticky';
import { switchLangs } from './switch';
import { tableLangs } from './table';
import { tabsLangs } from './tabs';
@ -210,6 +211,7 @@ export const componentLangs = {
's-text': sTextLangs,
splitter: splitterLangs,
stepper: stepperLangs,
sticky: stickyLangs,
switch: switchLangs,
table: tableLangs,
tabs: tabsLangs,

@ -0,0 +1,8 @@
import type { LangNode } from '$libs/langs';
export const stickyLangs = {
label: {
es: 'Fijo',
en: 'Sticky'
}
} satisfies LangNode;

@ -0,0 +1,80 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* Sticky — a `position: sticky` wrapper that KNOWS when it is stuck, so a
* header / toolbar can change elevation / background / shadow the moment it
* pins. Detection is an IntersectionObserver SENTINEL (never a scroll
* listener + sync getBoundingClientRect — that violates the anti-reflow
* doctrine): an in-flow, layout-neutral probe placed at the pin line whose
* intersection state drives `data-stuck`. Built as F1.1 of the blocks tier
* (unblocks the site-header block); reference floor + reflow doctrine in
* `docs/process/RESEARCH-blocks-references.md` §P3.
*
* `data-stuck` (present/absent) + `data-edge` (top | bottom) mirror the
* platform CSS `@container scroll-state(stuck: top|bottom)` vocabulary
* (Chrome/Edge 133+; not Firefox/Safari ≤2026), so eidos selectors
* translate mechanically to the native query when it lands everywhere —
* `[data-sticky][data-stuck][data-edge='top']` today, `scroll-state()`
* later. Until then the IO sentinel is the only cross-browser motor, and it
* is the one Chrome itself blesses (it honors our reflow doctrine — no
* layout-forcing reads on scroll).
*
* Membership (§3): passive (0 events, 0 keyboard) but justified by COMPLEX
* BEHAVIOR — IntersectionObserver stuck-detection + Provider/Sentinel
* composition + the offset↔sentinel-geometry coupling. `apg` = none (no
* WAI-ARIA widget pattern applies). 0-event justification lives in the
* README (`## Passive justification`).
*
* Contract note (dossier §P3): AntD `Affix` ships an `onChange(affixed)`
* callback, so "no events v1" is below the parity floor. The affixed-change
* event is RESERVED as `change-stuck` (documented in the README Gaps) rather
* than declared here — declaring it would flip the component to
* `interactive` and pull in the whole sema/event-trace apparatus for an
* event that fires nothing in v1. The name is reserved so a later addition
* does not reshape the contract.
*/
export const stickyMorfo = {
name: 'Sticky',
kebab: 'sticky',
scope: ['soma', 'eidos'],
apg: 'none — passive affix surface; stuck-detection via IntersectionObserver, no widget pattern applies',
texts: {
label: '#?components.sticky.label|Sticky'
},
parts: [
{
// The `position: sticky` box. Carries the state contract eidos
// selects against. `kebab: 'provider'` → bare `data-sticky`.
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [
// Configured pin edge — always present (prop-driven, per instance).
{ attr: 'data-edge', values: ['top', 'bottom'], value: v.propRef('edge') },
// Present WHILE pinned (state-driven, present/absent — the
// scroll-state(stuck) polyfill). Emitted a frame late (IO is
// async): styling only, never layout math.
{ attr: 'data-stuck', value: v.propRef('stuck'), severity: 'optional' }
],
aria: []
},
{
// In-flow, layout-neutral, aria-hidden probe at the pin line. Real
// height (not 1px — an exact-zero overlap never fires an IO
// callback); pulled out of layout with a negative margin in the
// recipe. Carries the edge so the recipe can pick which margin side
// to collapse.
name: 'Sentinel',
kebab: 'sentinel',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [{ attr: 'data-edge', values: ['top', 'bottom'], value: v.propRef('edge') }],
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
}
]
} as const satisfies Morfo;

@ -71,6 +71,7 @@ export * as Select from './select';
export * as Slider from './slider';
export * as Splitter from './splitter';
export * as Stepper from './stepper';
export * as Sticky from './sticky';
export * as Switch from './switch';
export * as Table from './table';
export * as Tabs from './tabs';

@ -0,0 +1,61 @@
# Sticky (soma)
Headless `position: sticky` provider that KNOWS when it is pinned. The
behavior lives here; the `position: sticky` recipe lives in
[`eidos/components/sticky`](../../../eidos/components/sticky/README.md). Built
as F1.1 of the blocks tier (unblocks the `site-header` block).
## The contract
- **Parts**: `provider` (the sticky box → bare `data-sticky`) + `sentinel`
(the observed in-flow probe → `data-sticky-sentinel`, `aria-hidden`). Both
carry `data-edge` (`top | bottom`, the configured pin edge); the provider
also carries `data-stuck` (present WHILE pinned).
- **Detection**: an IntersectionObserver on the sentinel, run in one
`$effect` that returns `soma.dom.observeIntersection(...)` as its teardown
(mirrors `FeedSentinelProvider`). NEVER a scroll listener + sync
`getBoundingClientRect` — that forces a reflow and is exactly what the
sentinel technique exists to avoid. `data-stuck` flips a frame late (IO is
async): it is a styling signal, never layout math.
- **The sentinel is a flow SIBLING of the box**, not a child — a child pins
along with the box and never detects the pin. The wrapper renders them as
two sibling root nodes (sentinel before the box for `top`, after for
`bottom`).
- **Offset ↔ geometry coupling**: the `offset` (px) feeds BOTH the observer's
`rootMargin` (it shrinks the scroll-host's observed edge so the sentinel's
trip line lands exactly on the pin line) AND the box inset (via the
`--_sticky-offset` custom-property `style`, A8-allowed data — the provider
emits no visual CSS). The two are mathematically coupled, so `offset` is a
first-class prop, not optional.
- **Scroll-host**: the IO `root` must be the nearest scrolling ancestor. v1
defaults to the viewport (`root: null`) — correct for a header in page
scroll. Pass `root` (an element) when the box lives inside a scroll
container, or detection silently never fires.
## API
`<Sticky.Provider offset={0} edge="top" root={null} disabled={false}>` —
`offset` px from the pin edge, `edge` = `top | bottom`, `root` = scroll-host
element or null (viewport), `disabled` suspends observation. `bind:ref` for
the box element. `child` snippet for asChild composition.
## Passive justification
0 events, 0 keyboard → passive. Membership is met by COMPLEX BEHAVIOR
(IntersectionObserver stuck-detection + provider/sentinel composition + the
offset↔geometry coupling), not a WAI-ARIA widget pattern — hence `apg:
none`. Pinning is a fact of layout/scroll, not a perceptual occurrence the
user commits, so there is nothing to emit. (AntD `Affix` ships
`onChange(affixed)`; that affixed-change event is reserved as `change-stuck`
for a future version — see the eidos README Gaps — rather than declared now,
which would flip the component to interactive for an event that fires nothing
in v1.)
## Tests
`sticky-provider.svelte.test.ts` (jsdom) asserts: the offset-derived
`rootMargin` per edge, the null-coerced root, the `data-stuck` flip on the
observer callback, `data-edge`/`aria-hidden` via `syncAttrs`, the marker +
offset on the props bag, and observer teardown. It uses the shipped
`installSomaHarness` (identity translator) like every other provider unit
test — see the build notes on the doctrine tension.

@ -0,0 +1,64 @@
<script lang="ts">
/**
* Headless `<Sticky.Provider>` — renders the in-flow sentinel and the
* sticky box as two sibling root nodes (the sentinel must be a flow
* sibling OUTSIDE the sticky box, or it pins along with it and never
* detects the pin). Order follows the edge: sentinel BEFORE the box for
* `top`, AFTER for `bottom`. Both refs attach to one StickyProvider.
*/
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { StickyProvider } from '../sticky-provider.svelte';
import type { StickyProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'sticky'),
offset = 0,
edge = 'top',
root = null,
disabled = false,
children,
child,
...restProps
}: StickyProps = $props();
let sentinelEl = $state<HTMLElement | null>(null);
const provider = StickyProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
sentinelId: readableActive(() => `${id}-sentinel`),
sentinelRef: writableActive(
() => sentinelEl,
(v) => (sentinelEl = v)
),
offset: readableActive(() => offset),
edge: readableActive(() => edge),
root: readableActive(() => root),
disabled: readableActive(() => disabled)
});
const boxProps = $derived(mergeProps(restProps, provider.boxProps));
const sentinelProps = $derived(provider.sentinelProps);
</script>
{#if edge === 'top'}
<div {...sentinelProps}></div>
{/if}
{#if child}
{@render child({ props: boxProps })}
{:else}
<div {...boxProps}>
{@render children?.()}
</div>
{/if}
{#if edge === 'bottom'}
<div {...sentinelProps}></div>
{/if}

@ -0,0 +1,3 @@
export { default as Provider } from './components/sticky.svelte';
export type { StickyProps as ProviderProps, StickyEdge, StickySnippetProps } from './types';

@ -0,0 +1 @@
export * from './exports';

@ -0,0 +1,102 @@
// Real-IntersectionObserver geometry proof. NO `@vitest-environment` pragma:
// this runs in the client (chromium) project, where the real IO fires on
// scroll — the piece the mocked-observer unit test cannot cover. It builds a
// real scroll container + sentinel + sticky box, wires a StickyProvider with
// the REAL `observeIntersection` (not spied), scrolls, and waits for the async
// callback to flip `stuck`. This is the make-or-break for the offset↔rootMargin
// coupling and the layout-neutral sentinel placement.
import { flushSync, tick } from 'svelte';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createActiveDom } from '$adom';
import { state } from '$libs/reactive';
import type { Morfo } from '$uix/morfo';
import { Soma } from '$soma/core/soma.svelte';
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
import { StickyProvider } from './sticky-provider.svelte';
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
let result!: T;
const cleanup = $effect.root(() => {
result = fn();
});
return { result, cleanup };
}
function installSomaHarness() {
const dom = createActiveDom();
const soma = {
dom,
langs: { ts: vi.fn(() => 'Sticky') },
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
createSomaRuntime(morfo, { dom, translate: (key) => key, ...sources })
} as unknown as Soma;
vi.spyOn(Soma, 'require').mockReturnValue(soma);
vi.spyOn(StickyProvider.ctx, 'set').mockImplementation((value) => value);
vi.spyOn(StickyProvider, 'get').mockReturnValue(undefined);
return { dom };
}
let scroll: HTMLDivElement | undefined;
afterEach(() => {
scroll?.remove();
scroll = undefined;
vi.restoreAllMocks();
});
describe('StickyProvider — real IntersectionObserver geometry (chromium)', () => {
it('flips stuck when the sentinel scrolls past the offset pin line, and back', async () => {
installSomaHarness();
// Real scroll container: 200px tall viewport, tall content so the
// sticky box actually pins.
scroll = document.createElement('div');
Object.assign(scroll.style, {
overflow: 'auto',
blockSize: '200px',
inlineSize: '200px',
position: 'relative'
});
const before = document.createElement('div');
before.style.blockSize = '60px'; // sentinel starts visible below the top
const sentinel = document.createElement('div');
// mirror the recipe: real height, pulled out of layout, before the box
Object.assign(sentinel.style, { blockSize: '2px', marginBlockEnd: '-2px' });
const box = document.createElement('div');
Object.assign(box.style, { position: 'sticky', insetBlockStart: '12px', blockSize: '40px' });
const after = document.createElement('div');
after.style.blockSize = '800px';
scroll.append(before, sentinel, box, after);
document.body.appendChild(scroll);
const opts = {
id: state('sticky-box'),
ref: state<HTMLElement | null>(box),
sentinelId: state('sticky-box-sentinel'),
sentinelRef: state<HTMLElement | null>(sentinel),
offset: state(12),
edge: state<'top' | 'bottom'>('top'),
root: state<Element | Document | null>(scroll),
disabled: state(false)
};
const { result, cleanup } = withEffectRoot(() => StickyProvider.create(opts));
flushSync();
await tick();
// At the top: sentinel is visible below the 12px pin line → not stuck.
await vi.waitFor(() => expect(result.stuck).toBe(false), { timeout: 2000 });
// Scroll well past the header: the sentinel leaves above the pin line.
scroll.scrollTop = 400;
await vi.waitFor(() => expect(result.stuck).toBe(true), { timeout: 2000 });
// Back to the top: unstuck again.
scroll.scrollTop = 0;
await vi.waitFor(() => expect(result.stuck).toBe(false), { timeout: 2000 });
cleanup();
});
});

@ -0,0 +1,189 @@
// @vitest-environment jsdom
import { tick } from 'svelte';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createActiveDom } from '$adom';
import { state } from '$libs/reactive';
import type { Morfo } from '$uix/morfo';
import { Soma } from '$soma/core/soma.svelte';
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
import { StickyProvider } from './sticky-provider.svelte';
// NOTE (doctrine): every shipped soma provider test uses this local
// `installSomaHarness` (a minimal Soma stub with an identity translator),
// not `createActiveUix`. These are UNIT tests of the provider's DOM/state
// logic; real-instance integration is covered by contracts.test.ts and the
// browser verification of the demo. Mirrored here for consistency with the
// other 86 provider tests. (Flagged for review — see the component build notes.)
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
let result!: T;
const cleanup = $effect.root(() => {
result = fn();
});
return { result, cleanup };
}
function installSomaHarness() {
const dom = createActiveDom();
const soma = {
dom,
langs: { ts: vi.fn(() => 'Sticky') },
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
createSomaRuntime(morfo, { dom, translate: (key) => key, ...sources })
} as unknown as Soma;
vi.spyOn(Soma, 'require').mockReturnValue(soma);
vi.spyOn(StickyProvider.ctx, 'set').mockImplementation((value) => value);
vi.spyOn(StickyProvider, 'get').mockReturnValue(undefined);
return { dom };
}
function stickyOpts(
box: HTMLElement,
sentinel: HTMLElement,
overrides: Partial<{ offset: number; edge: 'top' | 'bottom'; disabled: boolean }> = {}
) {
return {
id: state('sticky-box'),
ref: state<HTMLElement | null>(box),
sentinelId: state('sticky-box-sentinel'),
sentinelRef: state<HTMLElement | null>(sentinel),
offset: state(overrides.offset ?? 0),
edge: state<'top' | 'bottom'>(overrides.edge ?? 'top'),
root: state<Element | Document | null>(null),
disabled: state(overrides.disabled ?? false)
};
}
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
describe('StickyProvider', () => {
it('stamps data-edge + aria-hidden via syncAttrs, exposes the marker and offset on the props bag, and starts unstuck', async () => {
installSomaHarness();
const box = document.createElement('div');
const sentinel = document.createElement('div');
document.body.append(sentinel, box);
const { result, cleanup } = withEffectRoot(() =>
StickyProvider.create(stickyOpts(box, sentinel, { offset: 64, edge: 'top' }))
);
// touch the reactive prop bags so the syncAttrs effects run
void result.boxProps;
void result.sentinelProps;
await tick();
// syncAttrs writes the morfo attrs directly to the refs:
expect(box.getAttribute('data-edge')).toBe('top');
expect(box.hasAttribute('data-stuck')).toBe(false); // unstuck by default
expect(sentinel.getAttribute('data-edge')).toBe('top');
expect(sentinel.getAttribute('aria-hidden')).toBe('true');
// The bare part marker + offset data ride the props bag (the wrapper
// spreads them; a headless unit test doesn't render):
const boxProps = result.boxProps as Record<string, unknown>;
expect(boxProps['data-sticky']).toBe('');
expect((boxProps.style as Record<string, string>)['--_sticky-offset']).toBe('64px');
expect((result.sentinelProps as Record<string, unknown>)['data-sticky-sentinel']).toBe('');
cleanup();
});
it('observes the sentinel with an offset-derived rootMargin and null-coerced root', async () => {
const { dom } = installSomaHarness();
let observerCallback!: IntersectionObserverCallback;
const observerCleanup = vi.fn();
const observeIntersection = vi
.spyOn(dom, 'observeIntersection')
.mockImplementation((_t, cb) => {
observerCallback = cb;
return observerCleanup;
});
const box = document.createElement('div');
const sentinel = document.createElement('div');
document.body.append(sentinel, box);
const { result, cleanup } = withEffectRoot(() =>
StickyProvider.create(stickyOpts(box, sentinel, { offset: 80, edge: 'top' }))
);
void result.boxProps;
await tick();
expect(observeIntersection).toHaveBeenCalledWith(
sentinel,
expect.any(Function),
expect.objectContaining({ root: null, rootMargin: '-80px 0px 0px 0px', threshold: 0 })
);
// Sentinel scrolled past the pin line → stuck.
observerCallback(
[{ target: sentinel, isIntersecting: false } as unknown as IntersectionObserverEntry],
{} as IntersectionObserver
);
await tick();
expect(result.stuck).toBe(true);
expect(box.getAttribute('data-stuck')).toBe('');
// Sentinel back in view → unstuck, attr removed.
observerCallback(
[{ target: sentinel, isIntersecting: true } as unknown as IntersectionObserverEntry],
{} as IntersectionObserver
);
await tick();
expect(result.stuck).toBe(false);
expect(box.hasAttribute('data-stuck')).toBe(false);
cleanup();
expect(observerCleanup).toHaveBeenCalledOnce();
});
it('derives a bottom rootMargin and bottom sentinel edge for edge=bottom', async () => {
const { dom } = installSomaHarness();
const observeIntersection = vi.spyOn(dom, 'observeIntersection').mockReturnValue(vi.fn());
const box = document.createElement('div');
const sentinel = document.createElement('div');
document.body.append(box, sentinel);
const { result, cleanup } = withEffectRoot(() =>
StickyProvider.create(stickyOpts(box, sentinel, { offset: 24, edge: 'bottom' }))
);
void result.boxProps;
void result.sentinelProps;
await tick();
expect(box.getAttribute('data-edge')).toBe('bottom');
expect(sentinel.getAttribute('data-edge')).toBe('bottom');
expect(observeIntersection).toHaveBeenCalledWith(
sentinel,
expect.any(Function),
expect.objectContaining({ rootMargin: '0px 0px -24px 0px' })
);
cleanup();
});
it('does not observe while disabled and stays unstuck', async () => {
const { dom } = installSomaHarness();
const observeIntersection = vi.spyOn(dom, 'observeIntersection').mockReturnValue(vi.fn());
const box = document.createElement('div');
const sentinel = document.createElement('div');
document.body.append(sentinel, box);
const { result, cleanup } = withEffectRoot(() =>
StickyProvider.create(stickyOpts(box, sentinel, { disabled: true }))
);
void result.boxProps;
await tick();
expect(observeIntersection).not.toHaveBeenCalled();
expect(result.stuck).toBe(false);
cleanup();
});
});

@ -0,0 +1,141 @@
import { context, type WithRefOpts } from '../../provider';
import { type Active, type ActiveProps } from '$libs/reactive';
import { Soma } from '../../core/soma.svelte';
import { stickyMorfo } from '../../../morfo/components/sticky';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
/** Which viewport edge the box pins to. */
export type StickyEdge = 'top' | 'bottom';
interface StickyOpts
extends
WithRefOpts,
ActiveProps<{
/** Id for the sentinel part (derived from the provider id). */
sentinelId: string;
/** The in-flow sentinel element (bound by the wrapper). */
sentinelRef: HTMLElement | null;
/**
* Inset (px) from the pin edge. Feeds BOTH the CSS inset (via the
* `--_sticky-offset` data custom-property) AND the observer's
* `rootMargin` — the sentinel trip line IS the pin line, so the two
* are mathematically coupled and the offset is mandatory (dossier §P3).
*/
offset: number;
edge: StickyEdge;
/**
* The scroll-host: the nearest scrolling ancestor whose viewport the
* box pins within. `null` = the browser viewport (the common case: a
* header in page scroll). Pass the scroll container when the box lives
* inside one, or detection silently never fires.
*/
root: Element | Document | null;
disabled: boolean;
}> {}
/**
* Headless `Sticky` provider. Registers BOTH morfo parts (the sticky box
* `provider` + the observed `sentinel`) on one runtime — they are always
* co-rendered by the wrapper, so a single provider owning both is simpler
* than feed's split and avoids a cross-part `$state` edge.
*
* The observation lives in one `$effect` on the sentinel ref that RETURNS
* `soma.dom.observeIntersection(...)` as its teardown (mirrors
* FeedSentinelProvider). The async callback flips `stuck = $state`; the
* runtime's `syncAttrs` effect is the single writer that materializes
* `data-stuck` / `data-edge` from the part's `props` sources. The provider
* emits NO visual CSS — only the offset as a custom-property `style`
* (A8-allowed data, meter precedent); `position: sticky` lives in the eidos
* recipe.
*/
export class StickyProvider {
readonly opts: StickyOpts;
readonly soma: Soma;
readonly runtime: SomaRuntime;
readonly boxPart: SomaRuntimePart;
readonly sentinelPart: SomaRuntimePart;
static readonly ctx = context<StickyProvider>('Sticky');
static get(): StickyProvider | undefined {
return this.ctx.getOr(undefined) as StickyProvider | undefined;
}
static require(): StickyProvider {
return this.ctx.get();
}
static create(opts: StickyOpts) {
return new StickyProvider(opts);
}
/** Present WHILE pinned. Flipped by the IO callback; default unstuck. */
stuck = $state(false);
private constructor(opts: StickyOpts) {
this.opts = opts;
this.soma = Soma.require();
this.runtime = this.soma.runtime(stickyMorfo, {
props: {
edge: () => this.opts.edge.current,
stuck: () => this.stuck
}
});
this.boxPart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: StickyProvider.ctx,
syncAttrs: true
});
this.sentinelPart = this.runtime.part('sentinel', {
id: opts.sentinelId,
ref: opts.sentinelRef,
owner: this,
syncAttrs: true
});
$effect(() => {
const el = opts.sentinelRef.current;
const disabled = opts.disabled.current;
if (!el || disabled) {
this.stuck = false;
return;
}
const offset = opts.offset.current;
const edge = opts.edge.current;
const rootOpt = opts.root.current;
// Shrink the root's observed edge by the offset so the sentinel's
// trip line falls exactly on the pin line. rootMargin accepts px/%
// only — never em.
const rootMargin = edge === 'top' ? `${-offset}px 0px 0px 0px` : `0px 0px ${-offset}px 0px`;
return this.soma.dom.observeIntersection(
el,
(entries) => {
for (const entry of entries) {
if (entry.target !== el) continue;
// Stuck once the sentinel has scrolled past the pin line.
// One frame late by design (IO is async) — styling only.
this.stuck = !entry.isIntersecting;
}
},
{
// `root: Document` is not spec'd for IO — coerce to null.
root: rootOpt?.nodeType === 9 ? null : (rootOpt as Element | null),
rootMargin,
threshold: 0
}
);
});
}
readonly boxProps = $derived.by(() =>
this.boxPart.assert({
...this.boxPart.props,
// A8: the offset is DATA for the visual layer, not visual CSS.
style: { '--_sticky-offset': `${this.opts.offset.current}px` }
} as const)
);
readonly sentinelProps = $derived.by(() =>
this.sentinelPart.assert({ ...this.sentinelPart.props } as const)
);
}

@ -0,0 +1,37 @@
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type { StickyEdge } from './sticky-provider.svelte';
export type { StickyEdge };
/** Snippet props exposed to a `child` render (asChild composition). */
export interface StickySnippetProps {
props: Record<string, unknown>;
}
export type StickyProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
/** Bindable ref to the sticky box element. */
ref?: HTMLElement | null;
/** Stable id for the box part (auto-generated when omitted). */
id?: string;
/**
* Inset in **pixels** from the pin edge. Feeds both the CSS inset and the
* observer geometry, so it must be a resolvable number (not a CSS string).
* @default 0
*/
offset?: number;
/** Which viewport edge the box pins to. @default 'top' */
edge?: StickyEdge;
/**
* The scroll-host element (nearest scrolling ancestor). `null` = the
* browser viewport. Pass the scroll container when the box lives inside
* one, or stuck-detection silently never fires.
* @default null
*/
root?: Element | Document | null;
/** Suspend observation (stays unstuck). @default false */
disabled?: boolean;
children?: Snippet;
/** asChild render — receives the box props to spread on your own element. */
child?: Snippet<[StickySnippetProps]>;
};

@ -0,0 +1,471 @@
<script lang="ts">
import { Sticky, type StickyEdge } from '$uix/eidos/components/sticky';
import { compileMorfo } from '$uix/morfo';
import { stickyMorfo } from '@/uix/morfo/components/sticky';
import { getActiveUix } from '$active-uix';
import SystemAxes from '../../lib/SystemAxes.svelte';
import MotionPanel from '../../lib/MotionPanel.svelte';
import SemaPanel from '../../lib/SemaPanel.svelte';
import { DemoTrace } from '../../lib/harness.svelte';
const uix = getActiveUix();
// prettier-ignore
type Tab = 'live' | 'system' | 'motion' | 'sema' | 'services' | 'api' | 'morfo' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
// ── Stage + System axes ──────────────────────────────────────────────
const trace = new DemoTrace();
let stageRef = $state<HTMLElement | null>(null);
$effect(() => {
if (stageRef) return trace.observe(stageRef);
});
let density = $state('comfortable');
let scaling = $state(1);
let mode = $state<'inherit' | 'light' | 'dark'>('inherit');
let dir = $state<'ltr' | 'rtl'>('ltr');
let borderWidth = $state(1);
// ── Live state ───────────────────────────────────────────────────────
const edges: StickyEdge[] = ['top', 'bottom'];
let offset = $state(12);
let edge = $state<StickyEdge>('top');
let disabled = $state(false);
// The scroll container IS the scroll-host — the box roots its observer on it.
let scrollEl = $state<HTMLElement | null>(null);
// Live stuck readout, driven by a MutationObserver on the box's data-stuck.
let stuck = $state(false);
$effect(() => {
const host = scrollEl;
if (!host) return;
const box = host.querySelector('[data-sticky]');
if (!box) return;
const read = () => (stuck = box.hasAttribute('data-stuck'));
read();
const obs = new MutationObserver(read);
obs.observe(box, { attributes: true, attributeFilter: ['data-stuck'] });
return () => obs.disconnect();
});
// ── Compiled morfo ───────────────────────────────────────────────────
const compiled = compileMorfo(stickyMorfo);
const partsList = [...compiled.parts.byKebab.values()];
const events = [...compiled.actions.byName.values()];
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { Sticky } from '$uix/eidos/components/sticky';",
' let scrollEl = $state(null);',
'</' + 'script>',
'',
'<div bind:this={scrollEl} style="overflow-y: auto; max-block-size: 340px">',
' …content…',
` <Sticky offset={${offset}} edge="${edge}" root={scrollEl}${disabled ? ' disabled' : ''}>`,
' <header>Section title</header>',
' </Sticky>',
' …more content…',
'</div>',
'',
'/* style the pinned state yourself — the recipe positions, you decorate */',
'[data-sticky][data-stuck] > header { box-shadow: var(--shadow-3); }'
].join('\n')
);
</script>
{#snippet filler(label: string, n: number)}
<div style="padding: var(--space-3) var(--space-4); color: var(--color-content-muted);">
{#each Array(n) as _, i (i)}
<p style="margin: 0 0 var(--space-3);">{label} line {i + 1} — scroll to pin the header.</p>
{/each}
</div>
{/snippet}
{#snippet stickyHeader()}
<div
style={`background: var(--color-surface-raised, var(--color-neutral-track)); border-block: 1px solid var(--color-border-default); padding: var(--space-3) var(--space-4); font-weight: var(--font-weight-semibold); transition: box-shadow 160ms ease; ${stuck ? 'box-shadow: 0 6px 16px -8px rgba(0,0,0,0.35);' : ''}`}
>
Section header · {stuck ? `stuck to ${edge}` : 'not stuck'}
</div>
{/snippet}
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Layout · Sticky</div>
<h1 data-uix-page-title>Sticky</h1>
<p data-uix-page-lede>
A <code>position: sticky</code> wrapper that KNOWS when it is pinned. An IntersectionObserver
sentinel (never a scroll listener + <code>getBoundingClientRect</code> — that forces a reflow)
flips <code>data-stuck</code> the moment the box sticks, so a header can change elevation.
<code>data-stuck</code>
+ <code>data-edge</code> mirror the platform
<code>@container scroll-state(stuck: top|bottom)</code> — this is its cross-browser polyfill.
The recipe positions; you decorate <code>[data-sticky][data-stuck]</code>.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill>
<span data-uix-meta-key>parts</span>{compiled.parts.order.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>events</span>{events.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>stuck</span>{stuck ? 'yes' : 'no'}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>scope</span>soma · eidos
</span>
</div>
</header>
<!-- Always-on stage — a real scroll container carries the System axes -->
<div data-uix-stage>
<div
data-uix-stage-area
bind:this={stageRef}
data-density={density}
data-theme={mode === 'inherit' ? undefined : mode}
data-mode={mode === 'inherit' ? undefined : mode}
{dir}
style={`--scaling: ${scaling}; --border-width: ${borderWidth}px; display: block;`}
>
<div
bind:this={scrollEl}
style="overflow-y: auto; max-block-size: 340px; inline-size: min(100%, 40rem); margin-inline: auto; border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-default);"
>
{#if edge === 'top'}
{@render filler('Before', 6)}
<Sticky {offset} {edge} root={scrollEl} {disabled}>
{@render stickyHeader()}
</Sticky>
{@render filler('After', 16)}
{:else}
{@render filler('Before', 16)}
<Sticky {offset} {edge} root={scrollEl} {disabled}>
{@render stickyHeader()}
</Sticky>
{@render filler('After', 6)}
{/if}
</div>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>stuck</span>
<span>{stuck ? 'yes' : 'no'}</span>
<span style="color: var(--uix-text-faint)">·</span>
<span data-uix-stage-trace-key>edge</span>
<span>{edge}</span>
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>offset</span>
{offset}px ·
<span data-uix-stage-trace-key>events</span>
{events.length}
</span>
</div>
</div>
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'system'} onclick={() => (tab = 'system')}>
System
</button>
<button data-uix-tab data-active={tab === 'motion'} onclick={() => (tab = 'motion')}>
Motion
</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>{events.length}</span>
</button>
<button data-uix-tab data-active={tab === 'services'} onclick={() => (tab = 'services')}>
Services
</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · {events.length}e</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>
Recipe
</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
Scroll the panel above to pin the header. <code>offset</code> is the inset (px) from the pin
edge — it feeds BOTH the CSS inset and the observer geometry, so the sentinel's trip line
lands exactly on the pin line. <code>root</code> here is the scroll panel (the scroll-host); for
a page header it defaults to the viewport.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> behavior
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>edge</span>
<span data-uix-chips role="radiogroup">
{#each edges as opt (opt)}
<button data-uix-chip data-active={edge === opt} onclick={() => (edge = opt)}>
{opt}
</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>offset (px)</span>
<input type="range" min="0" max="80" step="4" bind:value={offset} />
<span style="font-variant-numeric: tabular-nums;">{offset}</span>
</label>
<label data-uix-control>
<span data-uix-control-label>disabled</span>
<input type="checkbox" bind:checked={disabled} />
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>sticky box + scroll-host + consumer treatment</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'system'}
<section data-uix-section>
<h2 data-uix-section-title>System axes</h2>
<p data-uix-section-desc>
Foundation knobs applied to the stage. RTL flips the accent-agnostic geometry (the recipe
uses logical <code>inset-block-start</code> / <code>inset-block-end</code>, so top/bottom
pinning is writing-mode correct).
</p>
<SystemAxes bind:density bind:scaling bind:mode bind:dir bind:borderWidth />
</section>
{/if}
{#if tab === 'motion'}
<section data-uix-section>
<h2 data-uix-section-title>Motion</h2>
<MotionPanel
note="Sticky has no motion prop. Any pin transition (shadow/elevation on data-stuck) is the consumer's — and a thin pinned rail that becomes a sema event target must override will-change:auto to avoid fractional-DPR jitter."
/>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span> · events
</h2>
<p data-uix-section-desc>
Sticky declares no semantic events: pinning is a fact of layout/scroll, not a perceptual
occurrence the user commits. <code>data-stuck</code> is a styling signal (a frame late — IO
is async), never a commit. AntD's <code>onChange(affixed)</code> maps to a reserved
<code>change-stuck</code> event for a future version (README Gaps); declaring it now would flip
the component to interactive for an event that fires nothing in v1.
</p>
<SemaPanel
actions={events}
{uix}
getTarget={() => stageRef?.querySelector('[data-sticky]') ?? stageRef}
/>
</section>
{/if}
{#if tab === 'services'}
<section data-uix-section>
<h2 data-uix-section-title>Services</h2>
<p data-uix-section-desc>
Sticky consumes <strong>adom</strong> (the ActiveDom): all observation goes through
<code>uix.dom.observeIntersection(...)</code> — the sanctioned, iframe/popup-correct IO
wrapper that returns a disconnect cleanup — never a raw <code>IntersectionObserver</code>
or a scroll listener. <strong>langs</strong> supplies only the catalog name («{uix.langs.ts(
'#?components.sticky.label|Sticky'
)}»). No format / announce / clipboard.
</p>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td class="name">offset</td>
<td class="type">number (px)</td>
<td>
Inset from the pin edge. Feeds both the CSS inset and the observer
<code>rootMargin</code> — must be a resolvable number, not a CSS string. Default
<code>0</code>.
</td>
</tr>
<tr>
<td class="name">edge</td>
<td class="type">top | bottom</td>
<td>Which viewport edge the box pins to. Default <code>top</code>.</td>
</tr>
<tr>
<td class="name">root</td>
<td class="type">Element | Document | null</td>
<td>
The scroll-host (nearest scrolling ancestor). <code>null</code> = viewport. Pass the scroll
container when the box lives inside one, or detection silently never fires.
</td>
</tr>
<tr>
<td class="name">disabled</td>
<td class="type">boolean</td>
<td>Suspend observation (stays unstuck). Default <code>false</code>.</td>
</tr>
<tr>
<td class="name">ref</td>
<td class="type">HTMLElement | null</td>
<td>Bindable ref to the sticky box.</td>
</tr>
</tbody>
</table>
</div>
<p data-uix-section-desc>
<strong>Emits</strong> <code>data-stuck</code> (present while pinned) and
<code>data-edge</code> (<code>top | bottom</code>) on the box; an internal
<code>data-sticky-sentinel</code> (aria-hidden) does the observing. Style the pinned state
via <code>[data-sticky][data-stuck]</code>.
</p>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>Morfo contract</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">name</td><td>{stickyMorfo.name}</td></tr>
<tr><td class="name">kebab</td><td><code>{stickyMorfo.kebab}</code></td></tr>
<tr><td class="name">scope</td><td>{stickyMorfo.scope.join(', ')}</td></tr>
<tr><td class="name">parts</td><td>{partsList.length}</td></tr>
<tr><td class="name">events</td><td>{events.length}</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead>
<tr>
<th>kebab</th><th>marker</th><th>element</th><th>archetype</th><th>optional</th>
</tr>
</thead>
<tbody>
{#each partsList as part (part.kebab)}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="type">{part.archetype ?? '—'}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<p data-uix-section-desc style="margin-top: var(--uix-space-4);">
The provider box carries <code>data-edge</code> (prop-driven) + <code>data-stuck</code>
(state-driven present/absent). The sentinel carries <code>data-edge</code> (to pick which
margin side the recipe collapses) + <code>aria-hidden</code>. The offset rides an eidos-only
<code>--_sticky-offset</code> custom-property (A8 data), never visual CSS from the provider.
</p>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
Recipe lives in <code>src/uix/eidos/components/sticky/sticky.css</code>. It POSITIONS only:
<code>position: sticky</code> + the logical inset (<code>inset-block-start</code> /
<code>inset-block-end</code> from <code>--_sticky-offset</code>) + the
<code>--sticky-z-index</code> token + the layout-neutral sentinel. The pinned TREATMENT is
yours: <code>[data-sticky][data-stuck]</code>.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Owner</th><th>Purpose</th></tr></thead>
<tbody>
<tr>
<td class="name"><code>[data-sticky]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td><code>position: sticky</code> + z-index token.</td>
</tr>
<tr>
<td class="name"><code>[data-sticky][data-edge='top'|'bottom']</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>The logical inset from <code>--_sticky-offset</code>.</td>
</tr>
<tr>
<td class="name"><code>[data-sticky-sentinel]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Real-height, layout-neutral (negative margin), invisible, inert probe.</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
<tbody>
<tr>
<td class="name">Role</td>
<td>
None — a layout affix, not a widget. Membership is met by complex behavior (<code
>apg: none</code
>). The consumer's own content (a <code>&lt;header&gt;</code>, a
<code>&lt;nav&gt;</code>) carries whatever landmark it needs.
</td>
</tr>
<tr>
<td class="name">Sentinel</td>
<td>
<code>aria-hidden="true"</code> + <code>pointer-events: none</code> +
<code>visibility: hidden</code> — an inert probe, invisible to AT and to the pointer,
occupying zero net layout.
</td>
</tr>
<tr>
<td class="name">data-stuck timing</td>
<td>
Flips a frame late (IO is async) — a styling signal only. Never gate focus or layout
math on it.
</td>
</tr>
<tr>
<td class="name">Reduced motion</td>
<td>
No motion of its own. Any pin transition is the consumer's and must honor
<code>prefers-reduced-motion</code>.
</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.