19 KiB
ActiveDom
adom contains ActiveDom: the application's reactive DOM service.
What it is today
Right now ActiveDom is not a semantic event bus nor a data-event* reflector.
Its current responsibility is smaller and more concrete:
- 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/windowfor iframes, popups and tests - run imperative actions (
focus,scrollTo,scrollWindowTo,scrollWindowBy,requestFrame) - write/remove managed nodes and accessible text (
writeNode,writeText,removeNode)
In other words:
libs/dom -> arts/adom -> App.dom / app.dom
pure reactive app consumption
Composition via active-app
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.
What belongs to each layer
libs/dom
Pure or nearly-pure DOM primitives:
- DOM guards and traversal
- focus helpers
- tabbable helpers
- pure responsive helpers
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.
It keeps no application state.
arts/adom
Reactive DOM runtime:
viewportbreakpointscurrentBreakpointresolve(...)isAtLeast(...)matches(...)apply(...)/remove(...)as the sole attribute-mutation surfacelisten(...)as the sole surface for global or cross-cutting listenersquery(...),elementFromPoint(...),activeElement(...)for queries against the owning documentfocus(...),scrollIntoView(...),scrollTo(...),scrollWindowTo(...),scrollWindowBy(...),requestFrame(...)/raf(...)for imperative actions that must not depend on the globalwindow/document(rafreturns a disposer — see Backlog)BodyScrollLockas a global body-scroll-lock helper, without blocking pointer eventsDOMContextas a scoped helper forDocument/ShadowRootRovingFocusGroupas a runtime helper for composite keyboard navigation
ActiveDom does keep reactive state, which is why it lives here, not in
$libs/dom.
Position in App
ActiveDom lives at the application level:
App.dom;
app.dom;
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.
Current API
ActiveDom's real public API today is this:
export interface ActiveDom {
breakpoints: Active<Breakpoints>;
viewport: { readonly width: number };
currentBreakpoint: Active<Breakpoint>;
/** Reactive `matchMedia('(prefers-reduced-motion: reduce)')`; `false` in SSR. */
prefersReducedMotion: { readonly matches: boolean };
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;
/** Write a single style property (e.g. a CSS custom property) on an element —
* the per-element counterpart of `apply` (attrs) and `writeStyle` (global). */
writeProperty(target: HTMLElement, property: string, value: string): void;
/** Remove a style property written by `writeProperty`. */
removeProperty(target: HTMLElement, property: 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` is overloaded so the handler's event is typed per target
// (Window / Document / HTMLElement); the last overload is the generic fallback.
listen<K extends keyof WindowEventMap>(
target: Window,
event: K | readonly K[],
handler: (e: WindowEventMap[K]) => void,
options?: boolean | AddEventListenerOptions
): ActiveDomListenerCleanup;
listen<K extends keyof DocumentEventMap>(
target: Document,
event: K | readonly K[],
handler: (e: DocumentEventMap[K]) => void,
options?: boolean | AddEventListenerOptions
): ActiveDomListenerCleanup;
listen<K extends keyof HTMLElementEventMap>(
target: HTMLElement,
event: K | readonly K[],
handler: (e: HTMLElementEventMap[K]) => void,
options?: boolean | AddEventListenerOptions
): ActiveDomListenerCleanup;
listen(
target: EventTarget,
event: string | readonly string[],
handler: EventListener,
options?: boolean | AddEventListenerOptions
): ActiveDomListenerCleanup;
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;
}
Creation:
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' });
DOM mutation:
dom.apply({
target: node,
attrs: {
'data-state': 'open',
'aria-busy': true,
'data-hidden': false
}
});
dom.remove(node, ['data-state', 'aria-busy']);
Managed nodes:
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 });
Rules:
- breakpoints are defined at
domcreation breakpointsis optional; if not passed, it usesBREAKPOINTS_DEFAULT- there is no parent-
dominheritance ActiveDomis an app service, not a nested scopeActiveDomregisters theresizelistener on construction (per instance). The shared singleton (shareViewport: true) defers assigning the listener until the first viewport read — importing$adomallocates no reactive state if nobody consumes the viewport.applyonly writes attributes; it does not interpret semantics or eventsdocument/windowlisteners, observers (ResizeObserver,MutationObserver,IntersectionObserver) and cross-cutting imperative actions go throughActiveDom- local reads of an element's own node (
getBoundingClientRect,contains,closest,clientWidth,scrollTop) remain the component's responsibility; wrapping them inActiveDomwould be noise writeNode/writeTextare for nodes owned by UIX services (live regions, hidden descriptions, auxiliary style hosts), not for bypassing Svelte's render in normal componentsfalse | null | undefinedremove attributesviewportis per-instance by default: eachActiveDomowns its ownresizelistener, scoped to thetargetWindow(defaultwindow). This prevents leaks across tests, iframes, popups and happy-dom environments. Callingdispose()detaches that listener.
Sharing the viewport across instances
For "single window" apps where the whole composition lives in the same document, opting into the singleton avoids N listeners for the same event:
const dom = createActiveDom({ shareViewport: true });
Under this option, dispose() is a no-op for the viewport (the singleton lives
for the whole process lifetime). What is released is breakpoints,
currentBreakpoint and the other per-instance reactives.
Tracking a different window
For iframes, popups or test environments:
const iframeDom = createActiveDom({ targetWindow: iframe.contentWindow! });
const popupDom = createActiveDom({ targetWindow: popup });
Ignored when shareViewport: true (the singleton always tracks the top-level
window).
Helpers rune standalone
Besides the createActiveDom runtime, $adom exports standalone reactive
primitives (0-dep ports of runed, fixed to respect the targetWindow via
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- 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
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.
Focus runtime helpers with their own state, like RovingFocusGroup, also fit
here: they reuse $libs/dom underneath but are no longer just pure utilities.
Test Page
The manual page is at /active/docs/adom and covers:
- viewport and current breakpoint
- responsive
resolve() apply()/remove()BodyScrollLockDOMContextRovingFocusGroup
Relationship with other pieces
Semantics
Semantics belong to Sema and EngineSemantic, not to ActiveDom.
If ActiveDom ever reflects events to the DOM, it will do so as a consumer of
EngineSemantic, not as a semantic authority.
Theme
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 and Terra
air and terra do not consume this new layer.
Their current code serves as historical reference for extracting utilities into
$libs/dom, but they are not part of the new runtime.
Design status
ActiveDom is in a foundational phase.
What is already closed:
app.domApp.domviewportbreakpointscurrentBreakpoint- responsive resolution
- listeners, observers and imperative actions scoped to the target's owning window
What is left for later phases, if it is really needed:
- reflecting semantic events to the DOM
- per-
DocumentorShadowRootAPIs - richer runtime introspection/diagnostics
The important rule for now is simple:
ActiveDomis the app's reactive DOM service;$libs/domis its pure base.
Backlog / Evolution decisions
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?): coalesced post-layout read
What. New method on ActiveDom:
measure(read: () => void, node?: …): ActiveDomFrameCleanup
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 frames.
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).
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?): animation frame with a disposer
What. New method on the ActiveDom surface:
raf(callback: FrameRequestCallback, node?: …): ActiveDomFrameCleanup
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.
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:
ActiveDomexposedrequestFrame/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 globalwindow, which is wrong in iframe / popup / happy-dom contexts — the same buggetWindow/getDocumentsolve 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 Palabras 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 Palabras 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 Palabras audit diff.