16 KiB
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/windowpropietario 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:
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:
viewportbreakpointscurrentBreakpointresolve(...)isAtLeast(...)matches(...)apply(...)/remove(...)como superficie unica de mutacion de atributoslisten(...)como superficie unica para listeners globales o transversalesquery(...),elementFromPoint(...),activeElement(...)para consultas contra el documento propietariofocus(...),scrollIntoView(...),scrollTo(...),scrollWindowTo(...),scrollWindowBy(...),requestFrame(...)/raf(...)para acciones imperativas que no deben depender delwindow/documentglobal (rafdevuelve un disposer — ver Backlog)BodyScrollLockcomo helper global de body scroll lock, sin bloquear eventos de punteroDOMContextcomo helper scoped paraDocument/ShadowRootRovingFocusGroupcomo 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:
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:
export interface ActiveDom {
breakpoints: Active<Breakpoints>;
viewport: { readonly width: number };
currentBreakpoint: Active<Breakpoint>;
resolve<T>(value: ResponsiveProp<T> | 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<T extends Element = Element>(
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;
measure(read: () => void, 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:
const dom = createActiveDom({
breakpoints: readableActive(() => ({
lg: 1100
}))
});
const domWithDefaults = createActiveDom();
Responsive:
const columns = dom.resolve({ base: 1, sm: 2, lg: 3 });
const tone = dom.resolve({ base: 'compact', md: 'normal', xl: 'wide' });
Mutacion DOM:
dom.apply({
target: node,
attrs: {
'data-state': 'open',
'aria-busy': true,
'data-hidden': false
}
});
dom.remove(node, ['data-state', 'aria-busy']);
Nodos gestionados:
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 breakpointses opcional; si no se pasa, usaBREAKPOINTS_DEFAULT- no hay herencia de
dompadre ActiveDomes servicio de app, no scope anidadoActiveDomregistra el listener deresizeal construirse (por instancia). El singleton compartido (shareViewport: true) pospone la asignación del listener a la primera lectura del viewport — importar$adomno aloca estado reactivo si nadie consume el viewport.applysolo escribe atributos; no interpreta semantica ni eventos- listeners de
document/window, observers (ResizeObserver,MutationObserver,IntersectionObserver) y acciones imperativas transversales van porActiveDom - lecturas locales de un elemento propio (
getBoundingClientRect,contains,closest,clientWidth,scrollTop) siguen siendo responsabilidad del componente; envolverlas enActiveDomseria ruido writeNode/writeTextson para nodos owned por servicios UIX (live regions, descripciones ocultas, style hosts auxiliares), no para saltarse el render de Svelte en componentes normalesfalse | null | undefinedremueven atributosviewportes per-instancia por defecto: cadaActiveDomposee su propio listener deresize, scoped altargetWindow(defaultwindow). Esto evita filtraciones entre tests, iframes, popups y entornos happy-dom. Llamardispose()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:
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:
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()responsiveapply()/remove()BodyScrollLockDOMContextRovingFocusGroup
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.domApp.domviewportbreakpointscurrentBreakpoint- 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
DocumentoShadowRoot - introspección/diagnóstico de runtime más rica
La regla importante por ahora es simple:
ActiveDomes el servicio reactivo de DOM de la app;$libs/domes 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-29 — dom.measure(read, node?): lectura de layout coalescida post-layout
Qué. Nuevo método en ActiveDom:
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 pendientes.
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).
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).
2026-06-02 — dom.raf(callback, node?): frame de animación con disposer
Qué. Nuevo método en la superficie ActiveDom:
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:
ActiveDomexponíarequestFrame/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/cancelAnimationFrameglobales. Eso apunta alwindowglobal, que es incorrecto en contextos iframe / popup / happy-dom — el mismo bug quegetWindow/getDocumentresuelven 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.