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/README.md

416 lines
14 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` / `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<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:
```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.

Powered by TurnKey Linux.