# ActiveDom `adom` contiene `ActiveDom`: el servicio DOM reactivo de aplicación. ## Qué es hoy Ahora mismo `ActiveDom` no es un bus de eventos semánticos ni un reflector de `data-event*`. Su responsabilidad actual es más pequeña y más concreta: - 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 imperativas (`focus`, `scrollTo`, `scrollWindowTo`, `scrollWindowBy`, `requestFrame`) - escribir/remover nodos gestionados y texto accesible (`writeNode`, `writeText`, `removeNode`) En otras palabras: ```text libs/dom -> arts/adom -> App.dom / app.dom puro reactivo consumo de app ``` ## Composicion 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. ## Qué pertenece a cada capa ### `libs/dom` Primitives DOM puras o casi puras: - guards y traversal DOM - focus helpers - tabbable helpers - responsive helpers puros **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. No mantiene estado de aplicación. ### `arts/adom` Runtime reactivo de DOM: - `viewport` - `breakpoints` - `currentBreakpoint` - `resolve(...)` - `isAtLeast(...)` - `matches(...)` - `apply(...)` / `remove(...)` como superficie unica de mutacion de atributos - `listen(...)` como superficie unica para listeners globales o transversales - `query(...)`, `elementFromPoint(...)`, `activeElement(...)` para consultas contra el documento propietario - `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 `ActiveDom` sí mantiene estado reactivo y por eso vive aquí, no en `$libs/dom`. ## Posición en App `ActiveDom` vive a nivel de aplicación: ```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`. ## API actual La API pública real de `ActiveDom` hoy es esta: ```ts export interface ActiveDom { breakpoints: Active; viewport: { readonly width: number }; currentBreakpoint: Active; resolve(value: ResponsiveProp | undefined): T | undefined; isAtLeast(breakpoint: Breakpoint): boolean; matches(breakpoint: Breakpoint): boolean; apply(change: StructuralChange): void; remove(target: HTMLElement, names: readonly string[]): void; writeStyle( id: string, css: string, options?: ActiveDomWriteStyleOptions ): HTMLStyleElement | undefined; removeStyle(id: string, options?: ActiveDomRemoveStyleOptions): void; writeNode(id: string, options?: ActiveDomWriteNodeOptions): HTMLElement | undefined; writeText(target: HTMLElement, text: string): void; removeNode(id: string, options?: ActiveDomRemoveNodeOptions): void; listen( target: EventTarget, event: string | readonly string[], handler: EventListener ): () => void; observeResize( target: Element, callback: ResizeObserverCallback, options?: ResizeObserverOptions ): () => void; observeMutation( target: Node, callback: MutationCallback, options: MutationObserverInit ): () => void; observeIntersection( target: Element, callback: IntersectionObserverCallback, options?: IntersectionObserverInit ): () => void; activeElement(node?: Element | Window | Node | Document | null): Element | null; query( selector: string, root?: ParentNode | Document | null ): T | null; elementFromPoint( x: number, y: number, node?: Element | Window | Node | Document | null ): Element | null; focus(target: HTMLElement | null | undefined, options?: FocusOptions): void; scrollIntoView(target: Element | null | undefined, arg?: boolean | ScrollIntoViewOptions): void; scrollTo(target: Element | null | undefined, arg: ScrollToOptions | number, y?: number): void; requestFrame( callback: FrameRequestCallback, node?: Element | Window | Node | Document | null ): number; cancelFrame(handle: number, node?: Element | Window | Node | Document | null): void; raf(callback: FrameRequestCallback, node?: Element | Window | Node | Document | null): () => void; scrollWindowBy( arg: ScrollToOptions | number, y?: number, node?: Element | Window | Node | Document | null ): void; scrollWindowTo( arg: ScrollToOptions | number, y?: number, node?: Element | Window | Node | Document | null ): void; getDocument(node?: Element | Window | Node | Document | null): Document; getWindow(node?: Node | ShadowRoot | Document | Window | null): Window; dispose(): void; } ``` Creación: ```ts const dom = createActiveDom({ breakpoints: readableActive(() => ({ lg: 1100 })) }); const domWithDefaults = createActiveDom(); ``` Responsive: ```ts const columns = dom.resolve({ base: 1, sm: 2, lg: 3 }); const tone = dom.resolve({ base: 'compact', md: 'normal', xl: 'wide' }); ``` Mutacion DOM: ```ts dom.apply({ target: node, attrs: { 'data-state': 'open', 'aria-busy': true, 'data-hidden': false } }); dom.remove(node, ['data-state', 'aria-busy']); ``` Nodos gestionados: ```ts const region = dom.writeNode('app-live-region', { host: dom.getDocument().body, attrs: { role: 'status', 'aria-live': 'polite' }, text: 'Ready' }); if (region) dom.writeText(region, 'Saved'); dom.removeNode('app-live-region', { host: dom.getDocument().body }); ``` Reglas: - 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` padre - `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 eventos - 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 normales - `false | null | undefined` remueven atributos - `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 instancias 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: ```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 es `breakpoints`, `currentBreakpoint` y los demás reactivos por-instancia. ### Tracking de un window distinto Para iframes, popups o entornos de test: ```ts const iframeDom = createActiveDom({ targetWindow: iframe.contentWindow! }); const popupDom = createActiveDom({ targetWindow: popup }); ``` Ignorado cuando `shareViewport: true` (el singleton siempre rastrea el top-level `window`). ## Qué no es `ActiveDom` no es: - `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 - sistema de modal, backdrop o inert - reemplazo de `$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. 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. ## Pagina De Prueba La pagina manual esta en `/test/adom` y cubre: - viewport y breakpoint actual - `resolve()` responsive - `apply()` / `remove()` - `BodyScrollLock` - `DOMContext` - `RovingFocusGroup` ## Relación con otras piezas ### Semántica La semántica pertenece a `Sema` y a `EngineSemantic`, no a `ActiveDom`. Si mañana `ActiveDom` refleja eventos al DOM, será como consumidor de `EngineSemantic`, no como autoridad semántica. ### 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. ### Air y Terra `air` y `terra` no consumen esta capa nueva. Su código actual sirve como referencia histórica para extraer utilidades hacia `$libs/dom`, pero no forman parte del runtime nuevo. ## Estado del diseño `ActiveDom` está en fase fundacional. Lo que ya está cerrado: - `app.dom` - `App.dom` - `viewport` - `breakpoints` - `currentBreakpoint` - resolución responsive - listeners, observers y acciones imperativas scoped a la ventana propietaria del target Lo que queda para fases posteriores, si de verdad hace falta: - reflexión de eventos semánticos al DOM - APIs por `Document` o `ShadowRoot` - introspección/diagnóstico de runtime más rica La regla importante por ahora es simple: > `ActiveDom` es el servicio reactivo de DOM de la app; `$libs/dom` es su base pura. --- ## Backlog / Decisiones de evolución 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. ### 2026-06-02 — `dom.raf(callback, node?)`: frame de animación con disposer **Qué.** Nuevo método en la superficie `ActiveDom`: ```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` / `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.