You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/arts/adom
dev 2d60703ab4
feat(adom): canonize dom.raf() frame scheduler + migrate Words overlays
4 months ago
..
test feat(uix): persistence + a11ySemantic + polymorphic core (book §5.3, §6, §9) 5 months ago
README.md feat(adom): canonize dom.raf() frame scheduler + migrate Words overlays 4 months ago
active-dom.svelte.ts feat(adom): canonize dom.raf() frame scheduler + migrate Words overlays 4 months ago
body-scroll-lock.svelte.ts Inject ActiveDom into Soma scroll lock 5 months ago
dom-context.svelte.ts eidos: pilot wrapper pattern + doctrinal API conventions 5 months ago
index.ts feat(adom): canonize dom.raf() frame scheduler + migrate Words overlays 4 months ago
reduced-motion.svelte.ts feat(uix): persistence + a11ySemantic + polymorphic core (book §5.3, §6, §9) 5 months ago
roving-focus-group.svelte.ts eidos: pilot wrapper pattern + doctrinal API conventions 5 months ago
viewport.svelte.ts eidos: pilot wrapper pattern + doctrinal API conventions 5 months ago

README.md

ActiveDom

adom contiene ActiveDom: el servicio DOM reactivo de aplicación.

Handoff 2026-05-14

ActiveDom es la unica superficie permitida para mutar DOM gestionado desde UIX. La decision P1 queda cerrada en ActiveUix: dom:false inyecta un disabledDom no-op compartido por Soma/Sema/Eidos. No puede haber fallback silencioso a escrituras directas dentro de UIX.

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:

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:

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;
	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
  • 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:

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() 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:

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.

Powered by TurnKey Linux.