From 998b11d9bcef970586c8823393e10878941cd6d6 Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 21 Jul 2026 22:24:59 +0200 Subject: [PATCH] =?UTF-8?q?uix(sticky):=20F1.1=20=C2=B7=20position:sticky?= =?UTF-8?q?=20con=20detecci=C3=B3n=20de=20estado=20(soma+eidos)=20?= =?UTF-8?q?=E2=80=94=20el=20primer=20behavioral?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/uix/eidos/components/sticky/README.md | 92 ++++ src/uix/eidos/components/sticky/index.ts | 13 + src/uix/eidos/components/sticky/sticky.css | 50 ++ src/uix/eidos/components/sticky/sticky.svelte | 26 + src/uix/eidos/components/sticky/types.ts | 13 + src/uix/eidos/generated/base.css | 1 + src/uix/eidos/lib/recipes/base.ts | 8 + src/uix/langs/components/index.ts | 2 + src/uix/langs/components/sticky.ts | 8 + src/uix/morfo/components/sticky.ts | 80 +++ src/uix/soma/components/index.ts | 1 + src/uix/soma/components/sticky/README.md | 61 +++ .../sticky/components/sticky.svelte | 64 +++ src/uix/soma/components/sticky/exports.ts | 3 + src/uix/soma/components/sticky/index.ts | 1 + .../sticky/sticky-io.svelte.test.ts | 102 ++++ .../sticky/sticky-provider.svelte.test.ts | 189 +++++++ .../sticky/sticky-provider.svelte.ts | 141 ++++++ src/uix/soma/components/sticky/types.ts | 37 ++ web/routes/uix/components/sticky/+page.svelte | 471 ++++++++++++++++++ 20 files changed, 1363 insertions(+) create mode 100644 src/uix/eidos/components/sticky/README.md create mode 100644 src/uix/eidos/components/sticky/index.ts create mode 100644 src/uix/eidos/components/sticky/sticky.css create mode 100644 src/uix/eidos/components/sticky/sticky.svelte create mode 100644 src/uix/eidos/components/sticky/types.ts create mode 100644 src/uix/langs/components/sticky.ts create mode 100644 src/uix/morfo/components/sticky.ts create mode 100644 src/uix/soma/components/sticky/README.md create mode 100644 src/uix/soma/components/sticky/components/sticky.svelte create mode 100644 src/uix/soma/components/sticky/exports.ts create mode 100644 src/uix/soma/components/sticky/index.ts create mode 100644 src/uix/soma/components/sticky/sticky-io.svelte.test.ts create mode 100644 src/uix/soma/components/sticky/sticky-provider.svelte.test.ts create mode 100644 src/uix/soma/components/sticky/sticky-provider.svelte.ts create mode 100644 src/uix/soma/components/sticky/types.ts create mode 100644 web/routes/uix/components/sticky/+page.svelte diff --git a/src/uix/eidos/components/sticky/README.md b/src/uix/eidos/components/sticky/README.md new file mode 100644 index 000000000..015999177 --- /dev/null +++ b/src/uix/eidos/components/sticky/README.md @@ -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 | diff --git a/src/uix/eidos/components/sticky/index.ts b/src/uix/eidos/components/sticky/index.ts new file mode 100644 index 000000000..a47191ab2 --- /dev/null +++ b/src/uix/eidos/components/sticky/index.ts @@ -0,0 +1,13 @@ +// Sticky — position:sticky wrapper that knows when it is stuck. +// +// import { Sticky } from '$uix/eidos/components/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'; diff --git a/src/uix/eidos/components/sticky/sticky.css b/src/uix/eidos/components/sticky/sticky.css new file mode 100644 index 000000000..5af774952 --- /dev/null +++ b/src/uix/eidos/components/sticky/sticky.css @@ -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)); +} diff --git a/src/uix/eidos/components/sticky/sticky.svelte b/src/uix/eidos/components/sticky/sticky.svelte new file mode 100644 index 000000000..9236d1816 --- /dev/null +++ b/src/uix/eidos/components/sticky/sticky.svelte @@ -0,0 +1,26 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/sticky/types.ts b/src/uix/eidos/components/sticky/types.ts new file mode 100644 index 000000000..48ebb86f1 --- /dev/null +++ b/src/uix/eidos/components/sticky/types.ts @@ -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; diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index d6c541074..964fddfd3 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -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; diff --git a/src/uix/eidos/lib/recipes/base.ts b/src/uix/eidos/lib/recipes/base.ts index d90ec0744..8ab23e543 100644 --- a/src/uix/eidos/lib/recipes/base.ts +++ b/src/uix/eidos/lib/recipes/base.ts @@ -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 diff --git a/src/uix/langs/components/index.ts b/src/uix/langs/components/index.ts index 7ab9af277..701b2a82c 100644 --- a/src/uix/langs/components/index.ts +++ b/src/uix/langs/components/index.ts @@ -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, diff --git a/src/uix/langs/components/sticky.ts b/src/uix/langs/components/sticky.ts new file mode 100644 index 000000000..6cb1d9144 --- /dev/null +++ b/src/uix/langs/components/sticky.ts @@ -0,0 +1,8 @@ +import type { LangNode } from '$libs/langs'; + +export const stickyLangs = { + label: { + es: 'Fijo', + en: 'Sticky' + } +} satisfies LangNode; diff --git a/src/uix/morfo/components/sticky.ts b/src/uix/morfo/components/sticky.ts new file mode 100644 index 000000000..1c8579d82 --- /dev/null +++ b/src/uix/morfo/components/sticky.ts @@ -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; diff --git a/src/uix/soma/components/index.ts b/src/uix/soma/components/index.ts index 6b39f40fc..fdb6d22fa 100644 --- a/src/uix/soma/components/index.ts +++ b/src/uix/soma/components/index.ts @@ -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'; diff --git a/src/uix/soma/components/sticky/README.md b/src/uix/soma/components/sticky/README.md new file mode 100644 index 000000000..2cfce2aa4 --- /dev/null +++ b/src/uix/soma/components/sticky/README.md @@ -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 + +`` — +`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. diff --git a/src/uix/soma/components/sticky/components/sticky.svelte b/src/uix/soma/components/sticky/components/sticky.svelte new file mode 100644 index 000000000..2dcdbe380 --- /dev/null +++ b/src/uix/soma/components/sticky/components/sticky.svelte @@ -0,0 +1,64 @@ + + +{#if edge === 'top'} +
+{/if} +{#if child} + {@render child({ props: boxProps })} +{:else} +
+ {@render children?.()} +
+{/if} +{#if edge === 'bottom'} +
+{/if} diff --git a/src/uix/soma/components/sticky/exports.ts b/src/uix/soma/components/sticky/exports.ts new file mode 100644 index 000000000..725ca935e --- /dev/null +++ b/src/uix/soma/components/sticky/exports.ts @@ -0,0 +1,3 @@ +export { default as Provider } from './components/sticky.svelte'; + +export type { StickyProps as ProviderProps, StickyEdge, StickySnippetProps } from './types'; diff --git a/src/uix/soma/components/sticky/index.ts b/src/uix/soma/components/sticky/index.ts new file mode 100644 index 000000000..8570a426a --- /dev/null +++ b/src/uix/soma/components/sticky/index.ts @@ -0,0 +1 @@ +export * from './exports'; diff --git a/src/uix/soma/components/sticky/sticky-io.svelte.test.ts b/src/uix/soma/components/sticky/sticky-io.svelte.test.ts new file mode 100644 index 000000000..a545e3675 --- /dev/null +++ b/src/uix/soma/components/sticky/sticky-io.svelte.test.ts @@ -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(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) => + 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(box), + sentinelId: state('sticky-box-sentinel'), + sentinelRef: state(sentinel), + offset: state(12), + edge: state<'top' | 'bottom'>('top'), + root: state(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(); + }); +}); diff --git a/src/uix/soma/components/sticky/sticky-provider.svelte.test.ts b/src/uix/soma/components/sticky/sticky-provider.svelte.test.ts new file mode 100644 index 000000000..9384aa9f1 --- /dev/null +++ b/src/uix/soma/components/sticky/sticky-provider.svelte.test.ts @@ -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(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) => + 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(box), + sentinelId: state('sticky-box-sentinel'), + sentinelRef: state(sentinel), + offset: state(overrides.offset ?? 0), + edge: state<'top' | 'bottom'>(overrides.edge ?? 'top'), + root: state(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; + expect(boxProps['data-sticky']).toBe(''); + expect((boxProps.style as Record)['--_sticky-offset']).toBe('64px'); + expect((result.sentinelProps as Record)['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(); + }); +}); diff --git a/src/uix/soma/components/sticky/sticky-provider.svelte.ts b/src/uix/soma/components/sticky/sticky-provider.svelte.ts new file mode 100644 index 000000000..42460c439 --- /dev/null +++ b/src/uix/soma/components/sticky/sticky-provider.svelte.ts @@ -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('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) + ); +} diff --git a/src/uix/soma/components/sticky/types.ts b/src/uix/soma/components/sticky/types.ts new file mode 100644 index 000000000..3f5b3d261 --- /dev/null +++ b/src/uix/soma/components/sticky/types.ts @@ -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; +} + +export type StickyProps = Omit, '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]>; +}; diff --git a/web/routes/uix/components/sticky/+page.svelte b/web/routes/uix/components/sticky/+page.svelte new file mode 100644 index 000000000..565899e47 --- /dev/null +++ b/web/routes/uix/components/sticky/+page.svelte @@ -0,0 +1,471 @@ + + +{#snippet filler(label: string, n: number)} +
+ {#each Array(n) as _, i (i)} +

{label} line {i + 1} — scroll to pin the header.

+ {/each} +
+{/snippet} + +{#snippet stickyHeader()} +
+ Section header · {stuck ? `stuck to ${edge}` : 'not stuck'} +
+{/snippet} + +
+
+
Layout · Sticky
+

Sticky

+

+ A position: sticky wrapper that KNOWS when it is pinned. An IntersectionObserver + sentinel (never a scroll listener + getBoundingClientRect — that forces a reflow) + flips data-stuck the moment the box sticks, so a header can change elevation. + data-stuck + + data-edge mirror the platform + @container scroll-state(stuck: top|bottom) — this is its cross-browser polyfill. + The recipe positions; you decorate [data-sticky][data-stuck]. +

+
+ + parts{compiled.parts.order.length} + + + events{events.length} + + + stuck{stuck ? 'yes' : 'no'} + + + scopesoma · eidos + +
+
+ + +
+
+
+ {#if edge === 'top'} + {@render filler('Before', 6)} + + {@render stickyHeader()} + + {@render filler('After', 16)} + {:else} + {@render filler('Before', 16)} + + {@render stickyHeader()} + + {@render filler('After', 6)} + {/if} +
+
+
+ stuck + {stuck ? 'yes' : 'no'} + · + edge + {edge} + + offset + {offset}px · + events + {events.length} + +
+
+ +
+ + + + + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

+ Scroll the panel above to pin the header. offset 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. root here is the scroll panel (the scroll-host); for + a page header it defaults to the viewport. +

+ +
+ soma behavior +
+
+ + + +
+ +
+
+ eidos + sticky box + scroll-host + consumer treatment + svelte +
+
{eidosSnippet}
+
+
+ {/if} + + {#if tab === 'system'} +
+

System axes

+

+ Foundation knobs applied to the stage. RTL flips the accent-agnostic geometry (the recipe + uses logical inset-block-start / inset-block-end, so top/bottom + pinning is writing-mode correct). +

+ +
+ {/if} + + {#if tab === 'motion'} +
+

Motion

+ +
+ {/if} + + {#if tab === 'sema'} +
+

+ sema · events +

+

+ Sticky declares no semantic events: pinning is a fact of layout/scroll, not a perceptual + occurrence the user commits. data-stuck is a styling signal (a frame late — IO + is async), never a commit. AntD's onChange(affixed) maps to a reserved + change-stuck event for a future version (README Gaps); declaring it now would flip + the component to interactive for an event that fires nothing in v1. +

+ stageRef?.querySelector('[data-sticky]') ?? stageRef} + /> +
+ {/if} + + {#if tab === 'services'} +
+

Services

+

+ Sticky consumes adom (the ActiveDom): all observation goes through + uix.dom.observeIntersection(...) — the sanctioned, iframe/popup-correct IO + wrapper that returns a disconnect cleanup — never a raw IntersectionObserver + or a scroll listener. langs supplies only the catalog name («{uix.langs.ts( + '#?components.sticky.label|Sticky' + )}»). No format / announce / clipboard. +

+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PropTypeNotes
offsetnumber (px) + Inset from the pin edge. Feeds both the CSS inset and the observer + rootMargin — must be a resolvable number, not a CSS string. Default + 0. +
edgetop | bottomWhich viewport edge the box pins to. Default top.
rootElement | Document | null + The scroll-host (nearest scrolling ancestor). null = viewport. Pass the scroll + container when the box lives inside one, or detection silently never fires. +
disabledbooleanSuspend observation (stays unstuck). Default false.
refHTMLElement | nullBindable ref to the sticky box.
+
+

+ Emits data-stuck (present while pinned) and + data-edge (top | bottom) on the box; an internal + data-sticky-sentinel (aria-hidden) does the observing. Style the pinned state + via [data-sticky][data-stuck]. +

+
+ {/if} + + {#if tab === 'morfo'} +
+

Morfo contract

+
+ + + + + + + + + +
FieldValue
name{stickyMorfo.name}
kebab{stickyMorfo.kebab}
scope{stickyMorfo.scope.join(', ')}
parts{partsList.length}
events{events.length}
+
+ +
Parts
+
+ + + + + + + + {#each partsList as part (part.kebab)} + + + + + + + + {/each} + +
kebabmarkerelementarchetypeoptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.archetype ?? '—'}{part.optional ? 'yes' : 'no'}
+
+ +

+ The provider box carries data-edge (prop-driven) + data-stuck + (state-driven present/absent). The sentinel carries data-edge (to pick which + margin side the recipe collapses) + aria-hidden. The offset rides an eidos-only + --_sticky-offset custom-property (A8 data), never visual CSS from the provider. +

+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

+ Recipe lives in src/uix/eidos/components/sticky/sticky.css. It POSITIONS only: + position: sticky + the logical inset (inset-block-start / + inset-block-end from --_sticky-offset) + the + --sticky-z-index token + the layout-neutral sentinel. The pinned TREATMENT is + yours: [data-sticky][data-stuck]. +

+
+ + + + + + + + + + + + + + + + + + + +
SelectorOwnerPurpose
[data-sticky]morfoposition: sticky + z-index token.
[data-sticky][data-edge='top'|'bottom']morfoThe logical inset from --_sticky-offset.
[data-sticky-sentinel]morfoReal-height, layout-neutral (negative margin), invisible, inert probe.
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+
+ + + + + + + + + + + + + + + + + + + + +
ConcernContract
Role + None — a layout affix, not a widget. Membership is met by complex behavior (apg: none). The consumer's own content (a <header>, a + <nav>) carries whatever landmark it needs. +
Sentinel + aria-hidden="true" + pointer-events: none + + visibility: hidden — an inert probe, invisible to AT and to the pointer, + occupying zero net layout. +
data-stuck timing + Flips a frame late (IO is async) — a styling signal only. Never gate focus or layout + math on it. +
Reduced motion + No motion of its own. Any pin transition is the consumer's and must honor + prefers-reduced-motion. +
+
+
+ {/if} +