@ -1,66 +1,63 @@
# ActiveDom
`adom` contiene `ActiveDom` : el servicio DOM reactivo de aplicación .
`adom` contains `ActiveDom` : the application's reactive DOM service .
## Qué es ho y
## What it is toda y
Ahora mismo `ActiveDom` no es un bus de eventos semánticos ni un reflector de
`data-event*` .
Right now `ActiveDom` is not a semantic event bus nor a `data-event*` reflector.
Su responsabilidad actual es más pequeña y más concreta :
Its current responsibility is smaller and more concrete :
- exponer el ancho de viewport de forma reactiva
- resolver el breakpoint actual
- mantener la definición de breakpoints de la app
- resolver valores responsive
- ofrecer helpers de consulta (`isAtLeast`, `matches` )
- aplicar/remover atributos DOM de forma controlada (`apply`, `remove` )
- registrar listeners globales o transversales con cleanup (`listen`)
- resolver `document` / `window` propietario para iframes, popups y tests
- ejecutar acciones imperativa s (`focus`, `scrollTo` , `scrollWindowTo` ,
- expose the viewport width reactively
- resolve the current breakpoint
- keep the app's breakpoint definition
- resolve responsive values
- offer query helpers (`isAtLeast`, `matches` )
- apply/remove DOM attributes in a controlled way (`apply`, `remove` )
- register global or cross-cutting listeners with cleanup (`listen`)
- resolve the owning `document` / `window` for iframes, popups and tests
- run imperative action s (`focus`, `scrollTo` , `scrollWindowTo` ,
`scrollWindowBy` , `requestFrame` )
- escribir/remover nodos gestionados y texto accesible (`writeNode` ,
` writeText`, ` removeNode`)
- write/remove managed nodes and accessible text (`writeNode`, `writeText` ,
` removeNode`)
En otras palabra s:
In other word s:
```text
libs/dom -> arts/adom -> App.dom / app.dom
puro reactivo consumo de app
pure reactive app consumption
```
## Composic ion via active-app
## Composit ion via active-app
`createActiveApp(...)` puede construir `App.dom` como servicio cuando la app lo
declara con `defineActiveDom()` . `ActiveUix` lo usa como unico escritor de
attrs cuando sus capas reciben una superficie DOM. Las preferencias
transversales se proyectan mediante `createActivePrefsDomProjection(...)` ; las
visuales mediante `ActiveEidos` . Si no existe `dom` porque el integrador pidio
`dom:false` , UIX usa `disabledDom` en modo standalone. Construir
`createActiveDom()` directamente solo es necesario en tests aislados o en
consumidores fuera de la composicion estandar.
`createActiveApp(...)` can build `App.dom` as a service when the app declares it
with `defineActiveDom()` . `ActiveUix` uses it as the sole attribute writer when
its layers receive a DOM surface. Cross-cutting preferences are projected via
`createActivePrefsDomProjection(...)` ; visual ones via `ActiveEidos` . If `dom`
does not exist because the integrator asked for `dom:false` , UIX uses
`disabledDom` in standalone mode. Building `createActiveDom()` directly is only
needed in isolated tests or in consumers outside the standard composition.
## Qué pertenece a cada capa
## What belongs to each layer
### `libs/dom`
Primitives DOM puras o casi pura s:
Pure or nearly-pure DOM primitive s:
- guards y traversal DOM
- DOM guards and traversal
- focus helpers
- tabbable helpers
- responsive helpers puros
- pure responsive helpers
**Capa de implementación.** Componentes y soma NO importan de aquí
directamente — todo se reexporta a través de `$adom` (ver más abajo).
Sólo `arts/adom/*` y los tests de `libs/dom` pueden importar
`$libs/dom` directo.
**Implementation layer.** Components and soma do NOT import from here directly —
everything is re-exported through `$adom` (see below). Only `arts/adom/*` and
the `libs/dom` tests may import `$libs/dom` directly.
No mantiene estado de aplicación .
It keeps no application state .
### `arts/adom`
Runtime r eactivo d e DOM:
Reactive DOM runtime :
- `viewport`
- `breakpoints`
@ -68,36 +65,38 @@ Runtime reactivo de DOM:
- `resolve(...)`
- `isAtLeast(...)`
- `matches(...)`
- `apply(...)` / `remove(...)` como superficie unica de mutacion de atributos
- `listen(...)` como superficie unica para listeners globales o transversale s
- `query(...)` , `elementFromPoint(...)` , `activeElement(...)` para consultas
contra el documento propietario
- `apply(...)` / `remove(...)` as the sole attribute-mutation surface
- `listen(...)` as the sole surface for global or cross-cutting listener s
- `query(...)` , `elementFromPoint(...)` , `activeElement(...)` for queries against
the owning document
- `focus(...)` , `scrollIntoView(...)` , `scrollTo(...)` , `scrollWindowTo(...)` ,
`scrollWindowBy(...)` , `requestFrame(...)` / `raf(...)` para acciones
imperativas que no deben depender del `window/document` global (`raf`
devuelve un disposer — ver Backlog)
- `BodyScrollLock` como helper global de body scroll lock, sin bloquear eventos de puntero
- `DOMContext` como helper scoped para `Document` / `ShadowRoot`
- `RovingFocusGroup` como helper runtime para navegación compuesta por teclado
`scrollWindowBy(...)` , `requestFrame(...)` / `raf(...)` for imperative actions
that must not depend on the global `window/document` (`raf` returns a disposer
— see Backlog)
- `BodyScrollLock` as a global body-scroll-lock helper, without blocking pointer
events
- `DOMContext` as a scoped helper for `Document` / `ShadowRoot`
- `RovingFocusGroup` as a runtime helper for composite keyboard navigation
`ActiveDom` sí mantiene estado reactivo y por eso vive aquí, no en `$libs/dom` .
`ActiveDom` does keep reactive state, which is why it lives here, not in
`$libs/dom` .
## Posición e n App
## Position i n App
`ActiveDom` vive a nivel de aplicación :
`ActiveDom` lives at the application level :
```ts
App.dom;
app.dom;
```
La implementación se consume desde la capa de aplicación y desde artefactos que
necesitan escribir atributos finales sobre un target DOM, por ejemplo
`ActivePrefsDomProjection` , `SomaRuntime` , `VisualChannel` o `ActiveEidos` .
The implementation is consumed from the application layer and from artifacts that
need to write final attributes onto a DOM target, for example
`ActivePrefsDomProjection` , `SomaRuntime` , `VisualChannel` or `ActiveEidos` .
## API actual
## Current API
La API pública real de `ActiveDom` hoy es esta :
`ActiveDom` 's real public API today is this :
```ts
export interface ActiveDom {
@ -202,7 +201,7 @@ export interface ActiveDom {
}
```
Creació n:
Creatio n:
```ts
const dom = createActiveDom({
@ -221,7 +220,7 @@ const columns = dom.resolve({ base: 1, sm: 2, lg: 3 });
const tone = dom.resolve({ base: 'compact', md: 'normal', xl: 'wide' });
```
Mutacion DOM :
DOM mutation :
```ts
dom.apply({
@ -236,7 +235,7 @@ dom.apply({
dom.remove(node, ['data-state', 'aria-busy']);
```
Nodos gestionado s:
Managed node s:
```ts
const region = dom.writeNode('app-live-region', {
@ -249,247 +248,246 @@ if (region) dom.writeText(region, 'Saved');
dom.removeNode('app-live-region', { host: dom.getDocument().body });
```
Regla s:
- los breakpoints se definen en la creación del `dom`
- `breakpoints` es opcional; si no se pasa, usa `BREAKPOINTS_DEFAULT`
- no hay herencia de `dom` padr e
- `ActiveDom` es servicio de app, no scope anidado
- `ActiveDom` registra el listener de `resize` al construirse (por instancia ).
El singleton compartido (`shareViewport: true`) pospone la asignación del
listener a la primera lectura del viewport — importar `$adom` no aloca
estado reactivo si nadie consume el viewport.
- `apply` solo escribe atributos; no interpreta semantica ni evento s
- listeners de `document` / `window` , observers (`ResizeObserver`,
`MutationObserver` , `IntersectionObserver` ) y acciones imperativas
transversales van por `ActiveDom`
- lecturas locales de un elemento propio (`getBoundingClientRect`, `contains` ,
`closest` , `clientWidth` , `scrollTop` ) siguen siendo responsabilidad del
componente; envolverlas en `ActiveDom` seria ruido
- `writeNode` /`writeText` son para nodos owned por servicios UIX
(live regions, descripciones ocultas, style hosts auxiliares), no para
saltarse el render de Svelte en componentes normale s
- `false | null | undefined` remueven atributo s
- `viewport` es **per-instancia por defecto** : cada `ActiveDom` posee su
propio listener de `resize` , scoped al `targetWindow` (default `window` ).
Esto evita filtraciones entre tests, iframes, popups y entornos happy-dom .
Llamar `dispose()` desadjunta ese listener.
### Compartir el viewport entre instancia s
Para apps "single window" donde toda la composición vive en el mismo
documento, opt-in al singleton evita N listeners para el mismo evento :
Rul es:
- breakpoints are defined at `dom` creation
- `breakpoints` is optional; if not passed, it uses `BREAKPOINTS_DEFAULT`
- there is no parent-`dom` inheritanc e
- `ActiveDom` is an app service, not a nested scope
- `ActiveDom` registers the `resize` listener on construction (per instance ).
The shared singleton (`shareViewport: true`) defers assigning the listener
until the first viewport read — importing `$adom` allocates no reactive state
if nobody consumes the viewport.
- `apply` only writes attributes; it does not interpret semantics or event s
- `document` / `window` listeners , observers (`ResizeObserver`,
`MutationObserver` , `IntersectionObserver` ) and cross-cutting imperative
actions go through `ActiveDom`
- local reads of an element's own node (`getBoundingClientRect`, `contains` ,
`closest` , `clientWidth` , `scrollTop` ) remain the component's responsibility;
wrapping them in `ActiveDom` would be noise
- `writeNode` /`writeText` are for nodes owned by UIX services (live regions,
hidden descriptions, auxiliary style hosts), not for bypassing Svelte's render
in normal component s
- `false | null | undefined` remove attribute s
- `viewport` is **per-instance by default** : each `ActiveDom` owns its own
`resize` listener, scoped to the `targetWindow` (default `window` ). This
prevents leaks across tests, iframes, popups and happy-dom environments .
Calling `dispose()` detaches that listener.
### Sharing the viewport across instance s
For "single window" apps where the whole composition lives in the same document,
opting into the singleton avoids N listeners for the same event :
```ts
const dom = createActiveDom({ shareViewport: true });
```
Bajo esta opción, `dispose()` es no-op para el viewport (el singleton vive
toda la vida del proceso). Lo que sí se libera e s `breakpoints` ,
`currentBreakpoint` y los demás reactivos por-instancia .
Under this option, `dispose()` is a no-op for the viewport (the singleton lives
for the whole process lifetime). What is released i s `breakpoints` ,
`currentBreakpoint` and the other per-instance reactives .
### Tracking de un window distinto
### Tracking a different window
Para iframes, popups o entornos de test :
For iframes, popups or test environments :
```ts
const iframeDom = createActiveDom({ targetWindow: iframe.contentWindow! });
const popupDom = createActiveDom({ targetWindow: popup });
```
Ignorado cuando `shareViewport: true` (el singleton siempre rastrea el
top-level `window` ).
Ignored when `shareViewport: true` (the singleton always tracks the top-lev el
`window` ).
## Helpers rune standalone
Además del runtime `createActiveDom` , `$adom` exporta primitivas reactivas
autónomas (ports 0-dep de `runed` , corregidas para respetar el `targetWindow` ví a
`getWindow(node)` ). Se importan directamente del barrel `$adom` :
| Helper | Qué expone |
| --------------------------------- | ----------------------------------------------------------------- |
| `ActiveElement` / `activeElement` | valor reactivo = `document.activeElement` |
| `IsDocumentVisible` | ¿la pestaña está visible? (`visibilitychange`) |
| `IsFocusWithin` | ¿el foco está dentro de un elemento? |
| `IsInViewport` | ¿un nodo intersecta el viewport? (`IntersectionObserver`) |
| `IsIdle` | inactividad del usuario tras N ms sin interacción |
| `PressedKeys` | conjunto reactivo de teclas pulsadas ahora mismo |
| `ElementRect` | `DOMRect` reactivo de un elemento |
| `ElementSize` | tamaño reactivo de un elemento (`ResizeObserver`) |
| `ScrollState` | posición, dirección y bordes de scroll |
| `TextareaAutosize` | crece un `<textarea>` para ajustarse a su contenido |
| `AnimationFrames` | bucle `requestAnimationFrame` gestionado (fps / delta) |
| `onClickOutside` | callback al hacer click / foco fuera de un elemento |
| `BodyScrollLock` | bloquea el scroll del `body` |
| `DOMContext` | resuelve `window` / `document` del contexto (iframe / popup safe) |
| `RovingFocusGroup` | grupo de foco con roving tabindex |
## Qué no es
`ActiveDom` no es :
Besides the `createActiveDom` runtime, `$adom` exports standalone reactive
primitives (0-dep ports of `runed` , fixed to respect the `targetWindow` vi a
`getWindow(node)` ). They are imported directly from the `$adom` barrel :
| Helper | What it exposes |
| --------------------------------- | ------------------------------------------------------------------ |
| `ActiveElement` / `activeElement` | reactive value = `document.activeElement` |
| `IsDocumentVisible` | is the tab visible? (`visibilitychange`) |
| `IsFocusWithin` | is focus inside an element? |
| `IsInViewport` | does a node intersect the viewport? (`IntersectionObserver`) |
| `IsIdle` | user inactivity after N ms without interaction |
| `PressedKeys` | reactive set of currently-pressed keys |
| `ElementRect` | reactive `DOMRect` of an element |
| `ElementSize` | reactive size of an element (`ResizeObserver`) |
| `ScrollState` | scroll position, direction and edges |
| `TextareaAutosize` | grows a `<textarea>` to fit its content |
| `AnimationFrames` | managed `requestAnimationFrame` loop (fps / delta) |
| `onClickOutside` | callback on click / focus outside an element |
| `BodyScrollLock` | locks `body` scroll |
| `DOMContext` | resolves the context's `window` / `document` (iframe / popup safe) |
| `RovingFocusGroup` | roving-tabindex focus group |
## What it is not
`ActiveDom` is not :
- `EngineSemantic`
- broker de eventos
- reflector de `data-event*`
- scheduler de observers compartidos con cache global; solo expone factories
scoped a la ventana propietaria del target
- sistema de theme
- sistem a de modal, backdrop o inert
- reemplazo de `$libs/dom`
- an event broker
- a `data-event*` reflector
- a scheduler of observers shared with a global cache; it only exposes factories
scoped to the target's owning window
- a theme system
- a modal, backdrop or inert system
- a replacement for `$libs/dom`
Además, `uix/adom` puede alojar helpers DOM con estado global real, como
`BodyScrollLock` , o helpers scoped de runtime como `DOMContext` , cuando ya no
son primitives puras de `$libs/dom` pero tampoco pertenecen a un componente UI concreto.
In addition, `uix/adom` can host DOM helpers with real global state, like
`BodyScrollLock` , or scoped runtime helpers like `DOMContext` , once they are no
longer pure `$libs/dom` primitives but do not belong to a concrete UI component
either.
También caben aquí helpers runtime de foco con estado propio, como
`RovingFocusGroup` , que reutilizan `$libs/dom` por debajo pero ya no son
solo utilidades puras.
Focus runtime helpers with their own state, like `RovingFocusGroup` , also fit
here: they reuse `$libs/dom` underneath but are no longer just pure utilities.
## Pagina D e Prueba
## Test Page
La pagina manual esta en `/active/docs/adom` y cubre :
The manual page is at `/active/docs/adom` and covers :
- viewport y breakpoint actual
- `resolve()` responsive
- viewport and current breakpoint
- responsive `resolve()`
- `apply()` / `remove()`
- `BodyScrollLock`
- `DOMContext`
- `RovingFocusGroup`
## Relación con otras pieza s
## Relationship with other piece s
### Semántica
### Semantics
La semántica pertenece a `Sema` y a `EngineSemantic` , no a `ActiveDom` .
Semantics belong to `Sema` and `EngineSemantic` , not to `ActiveDom` .
Si mañana `ActiveDom` refleja eventos al DOM, será como consumidor de
`EngineSemantic` , no como autoridad semántica .
If `ActiveDom` ever reflects events to the DOM, it will do so as a consumer of
`EngineSemantic` , not as a semantic authority .
### Theme
El theme no pertenece a `dom` . En UIX, `ActiveEidos` posee `data-theme` ,
`data-mode` y `data-density` ; `ActiveDom` solo recibe la mutación ya resuelta.
Theme does not belong to `dom` . In UIX, `ActiveEidos` owns `data-theme` ,
`data-mode` and `data-density` ; `ActiveDom` only receives the already-resolved
mutation.
### Air y Terra
### Air and Terra
`air` y `terra` no consumen esta capa nueva .
`air` and `terra` do not consume this new layer .
Su código actual sirve como referencia histórica para extraer utilidades hacia
`$libs/dom` , pero no forman parte del runtime nuevo .
Their current code serves as historical reference for extracting utilities into
`$libs/dom` , but they are not part of the new runtime .
## Estado del diseño
## Design status
`ActiveDom` está en fase fundacional .
`ActiveDom` is in a foundational phase .
Lo que ya está cerrado :
What is already closed :
- `app.dom`
- `App.dom`
- `viewport`
- `breakpoints`
- `currentBreakpoint`
- resolución responsive
- listeners, observers y acciones imperativas scoped a la ventana propietaria
del target
- responsive resolution
- listeners, observers and imperative actions scoped to the target's owning
window
Lo que queda para fases posteriores, si de verdad hace falta :
What is left for later phases, if it is really needed :
- reflexión de eventos semánticos al DOM
- APIs por `Document` o `ShadowRoot`
- introspección/diagnóstico de runtime más rica
- reflecting semantic events to the DOM
- per-`Document` or `ShadowRoot` APIs
- richer runtime introspection/diagnostics
La regla importante por ahora e s simple:
The important rule for now i s simple:
> `ActiveDom` es el servicio reactivo de DOM de la app; `$libs/dom` es su base pura .
> `ActiveDom` is the app's reactive DOM service; `$libs/dom` is its pure base .
---
## Backlog / Decisiones de evolución
## Backlog / Evolution decisions
Registro de cambios de superficie posteriores a la fase fundacional. Cada
entrada documenta **qué** se añadió y, sobre todo, **por qué** — para que la
decisión no se pierda y futuros consumidores entiendan el patrón canónico .
Record of surface changes after the foundational phase. Each entry documents
**what** was added and, above all, **why** — so the decision is not lost and
future consumers understand the canonical pattern .
### 2026-06-29 — `dom.measure(read, node?)` : lectura de layout coalescida post-layout
### 2026-06-29 — `dom.measure(read, node?)` : coalesced post-layout read
**Qué.** Nuevo método e n `ActiveDom` :
**What.** New method o n `ActiveDom` :
```ts
measure(read: () => void, node?: …): ActiveDomFrameCleanup
```
Agenda una lectura que fuerza layout (`getBoundingClientRect`, `getComputedStyle` ,
`offset*` , `scroll*` ) en un **rAF coalescido por ventana** en vez de síncronamente .
Todas las lecturas encoladas en un mismo turno corren juntas en un único frame, así
una lectura nunca fuerza un reflow EN MEDIO de un turno de escritura — el origen de
`[Violation] Forced reflow while executing JavaScript` . Devuelve el mismo disposer
idempotente que `raf` ; `dispose()` cancela los frames pendient es.
Schedules a layout-forcing read (`getBoundingClientRect`, `getComputedStyle` ,
`offset*` , `scroll*` ) in a **per-window coalesced rAF** instead of synchronously .
Every read queued in one turn runs together in a single frame, so a read never
forces a reflow IN THE MIDDLE of a write turn — the source of
`[Violation] Forced reflow while executing JavaScript` . It returns the same
idempotent disposer as `raf` ; `dispose()` cancels pending fram es.
**Por qué.** El framework gobierna las ESCRITURAS (`apply`) y el TIEMPO
(`uix.timers`) pero no tenía superficie para el _timing_ de las LECTURAS de layout,
que solo son seguras post-layout. `measure` es el hogar sancionado: posee _cuándo_
corre la lectura (post-turno, coalescida), no _qué_ elemento. **Solo lecturas** — las
escrituras ya secuencian por `apply` + el runtime; un segundo eje `mutate` /dos-fases
duplicaría lo que `apply` + Svelte ya hacen (sobre-ingeniería descartada ).
**Why.** The framework governs the WRITES (`apply`) and the TIME (`uix.timers`)
but had no surface for the _timing_ of layout READS, which are only safe
post-layout. `measure` is the sanctioned home: it owns _when_ the read runs
(post-turn, coalesced), not _which_ element. **Reads only** — writes already
sequence through `apply` + the runtime; a second `mutate` /two-phase axis would
duplicate what `apply` + Svelte already do (over-engineering rejected ).
**Disciplina.** Está prohibido leer layout síncronamente justo tras una escritura de
DOM/estilo. Un `raf` que haga la lectura cumple igual; `measure` añade el coalescing
y la intención semántica. Patrón canónico ya seguido por `Tabs.Indicator` (mide vía
rAF diferido + guard de coalescing ).
**Discipline.** Reading layout synchronously right after a DOM/style write is
forbidden. A `raf` that performs the read complies just as well; `measure` adds
the coalescing and the semantic intent. Canonical pattern already followed by
`Tabs.Indicator` (measures via a deferred rAF + coalescing guard ).
### 2026-06-02 — `dom.raf(callback, node?)` : frame de animación con disposer
### 2026-06-02 — `dom.raf(callback, node?)` : animation frame with a disposer
**Qué.** Nuevo método en la superficie `ActiveDom` :
**What.** New method on the `ActiveDom` surface :
```ts
raf(callback: FrameRequestCallback, node?: …): ActiveDomFrameCleanup
```
Agenda un `requestAnimationFrame` de una sola pasada contra la ventana que
posee `node` (o el `targetWindow` de la instancia) y devuelve un **disposer**
`() => void` idempotente que cancela el frame pendiente. Tipo
`ActiveDomFrameCleanup` exportado junto a `ActiveDomListenerCleanup` /
Schedules a single-pass `requestAnimationFrame` against the window that owns
`node` (or the instance's `targetWindow` ) and returns an idempotent
`() => void` **disposer** that cancels the pending frame. The
`ActiveDomFrameCleanup` type is exported alongside `ActiveDomListenerCleanup` /
`ActiveDomObserverCleanup` .
**Por qué.** La doctrina del proyecto es _"toda actividad de DOM pasa por
ActiveDom"_ (sin `window.addEventListener` , sin `new ResizeObserver` , sin
`document.querySelector` crudos en componentes). `listen` y `observe*` ya
cumplían esa regla devolviendo un **disposer** — la forma exacta que un
`$effect` de Svelte puede `return` para que el framework limpie en el
teardown. Pero el frame de animación quedaba fuera:
- `ActiveDom` exponía `requestFrame` / `cancelFrame` (resuelven bien la
ventana iframe/popup), pero devuelven el **handle numérico crudo** . Eso
obliga a cada consumidor a guardar el número, gestionar el guard de
"pendiente" y cancelar a mano en el teardown — bookkeeping repetido y
propenso a fugas.
- Los componentes que necesitaban un frame de _layout_ (medir y reposicionar
un overlay) caían en `requestAnimationFrame` / `cancelAnimationFrame`
**globales** . Eso apunta al `window` global, que es **incorrecto** en
contextos iframe / popup / happy-dom — el mismo bug que `getWindow` /
`getDocument` resuelven para el resto de la API.
`raf` cierra ese hueco: envuelve `requestFrame` /`cancelFrame` (cero lógica
nueva de scheduling) y devuelve el disposer simétrico. Un consumidor escribe
ahora `return dom.raf(reposition, node)` y Svelte cancela el frame solo.
**Detonante.** La auditoría del editor **Words** (2026-06-02) encontró 3
`requestAnimationFrame` crudos —`words-block-gutter`, `words-bubble-menu` ,
`words-slash-menu` — que reposicionaban overlays apuntando al `window` global.
Migrados a `dom.raf(...)` . El precedente de rotura ya existía en otros sitios
del eidos (p. ej. `tabs-indicator` ), así que esto canoniza el patrón, no es
un parche puntual de Words.
**Distinción que se mantiene.** `dom.raf` es para frames de **layout**
(medir/posicionar, ligados al ciclo de pintado). NO sustituye al servicio
`$timer` (`App.timers`), que es para _lifecycle timers con clave_
(debounce / heartbeat / intervalos / one-shots con delay). Son dominios
distintos: el timer no tiene primitiva de frame y `raf` no tiene clave ni
cancelación por scope. El `requestFrame` /`cancelFrame` de bajo nivel se
conserva para los pocos consumidores que ya guardan el handle (drawer,
slider, splitter, floating, focus-scope…) y aún no migran.
**Pendiente (no bloqueante).** Migrar los `requestAnimationFrame` crudos
restantes del eidos (`tabs-indicator`, etc.) a `dom.raf` cuando se toquen
esos componentes; no se hizo en barrido para no ensanchar el diff de la
auditoría de Words.
**Why.** The project doctrine is _"all DOM activity goes through ActiveDom"_ (no
raw `window.addEventListener` , no `new ResizeObserver` , no
`document.querySelector` in components). `listen` and `observe*` already followed
that rule by returning a **disposer** — the exact shape a Svelte `$effect` can
`return` so the framework cleans up on teardown. But the animation frame was left
out:
- `ActiveDom` exposed `requestFrame` / `cancelFrame` (they resolve the
iframe/popup window correctly), but they return the **raw numeric handle** .
That forces every consumer to store the number, manage the "pending" guard and
cancel by hand on teardown — repeated, leak-prone bookkeeping.
- Components that needed a _layout_ frame (measure and reposition an overlay)
fell back to **global** `requestAnimationFrame` / `cancelAnimationFrame` . That
points at the global `window` , which is **wrong** in iframe / popup / happy-dom
contexts — the same bug `getWindow` / `getDocument` solve for the rest of the
API.
`raf` closes that gap: it wraps `requestFrame` /`cancelFrame` (zero new scheduling
logic) and returns the symmetric disposer. A consumer now writes
`return dom.raf(reposition, node)` and Svelte cancels the frame by itself.
**Trigger.** The **Words** editor audit (2026-06-02) found 3 raw
`requestAnimationFrame` calls —`words-block-gutter`, `words-bubble-menu` ,
`words-slash-menu` — that repositioned overlays pointing at the global `window` .
Migrated to `dom.raf(...)` . The breakage precedent already existed elsewhere in
eidos (e.g. `tabs-indicator` ), so this canonizes the pattern, it is not a
one-off Words patch.
**Distinction that stays.** `dom.raf` is for **layout** frames (measure/position,
tied to the paint cycle). It does NOT replace the `$timer` service
(`App.timers`), which is for _keyed lifecycle timers_ (debounce / heartbeat /
intervals / delayed one-shots). They are distinct domains: the timer has no frame
primitive and `raf` has no key or scope-based cancellation. The low-level
`requestFrame` /`cancelFrame` is kept for the few consumers that already store the
handle (drawer, slider, splitter, floating, focus-scope…) and have not migrated
yet.
**Pending (non-blocking).** Migrate the remaining raw `requestAnimationFrame`
calls in eidos (`tabs-indicator`, etc.) to `dom.raf` when those components are
touched; not done in a sweep, to avoid widening the Words audit diff.