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

21 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 / window for 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:

  • viewport
  • breakpoints
  • currentBreakpoint
  • resolve(...)
  • isAtLeast(...)
  • matches(...)
  • apply(...) / remove(...) as the sole attribute-mutation surface
  • listen(...) as the sole surface for global or cross-cutting listeners
  • query(...), elementFromPoint(...), activeElement(...) for queries against the owning document
  • focus(...), scrollIntoView(...), scrollTo(...), scrollWindowTo(...), scrollWindowBy(...), requestFrame(...) / raf(...) for imperative actions that must not depend on the global window/document (raf returns a disposer — see Backlog)
  • BodyScrollLock as a global body-scroll-lock helper, without blocking pointer events
  • DOMContext as a scoped helper for Document / ShadowRoot
  • RovingFocusGroup as 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 };
	/** Reactive `(prefers-reduced-data: reduce)` OR `navigator.connection.saveData`; `false` in SSR. */
	prefersReducedData: { 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 dom creation
  • breakpoints is optional; if not passed, it uses BREAKPOINTS_DEFAULT
  • there is no parent-dom inheritance
  • ActiveDom is an app service, not a nested scope
  • ActiveDom registers the resize listener on construction (per instance). The shared singleton (shareViewport: true) defers assigning the listener until the first viewport read — importing $adom allocates no reactive state if nobody consumes the viewport.
  • apply only writes attributes; it does not interpret semantics or events
  • document / window listeners, observers (ResizeObserver, MutationObserver, IntersectionObserver) and cross-cutting imperative actions go through ActiveDom
  • local reads of an element's own node (getBoundingClientRect, contains, closest, clientWidth, scrollTop) remain the component's responsibility; wrapping them in ActiveDom would be noise
  • writeNode/writeText are for nodes owned by UIX services (live regions, hidden descriptions, auxiliary style hosts), not for bypassing Svelte's render in normal components
  • false | null | undefined remove attributes
  • viewport is per-instance by default: each ActiveDom owns its own resize listener, scoped to the targetWindow (default window). This prevents leaks across tests, iframes, popups and happy-dom environments. Calling dispose() 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)
ScrollProgress 0→1 progress of a node across its scrollport (the view() range)
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()
  • BodyScrollLock
  • DOMContext
  • RovingFocusGroup

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.dom
  • App.dom
  • viewport
  • breakpoints
  • currentBreakpoint
  • 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-Document or ShadowRoot APIs
  • richer runtime introspection/diagnostics

The important rule for now is simple:

ActiveDom is the app's reactive DOM service; $libs/dom is 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-08-17 — ScrollProgress + dom.prefersReducedData: the two ports a background needs

What. Two additions, both from the Background initiative (docs/process/PLAN-background.md, decision D-BG.7):

  • ScrollProgress — a standalone reactive helper (the IsInViewport family) reporting a node's 0→1 progress across its scrollport, coalescing its reads into one animation frame per scroll burst. Block axis, full cover range.
  • ActiveDom.prefersReducedData — a second preference port beside prefersReducedMotion, reading (prefers-reduced-data: reduce) OR navigator.connection.saveData (Chromium's Data Saver).

Why. Both were living as private code or not at all:

  • The scroll-progress maths existed once, privately, inside eidos/components/scroll-frames (listener + rAF + observeResize). A second consumer arrived — the CSS-scroll-driven parallax needs a JS fallback for engines without animation-timeline (Firefox, still flagged in 2026-08) — and a second private copy is how a repo grows two subtly different answers to one question. The helper is the shared one; ScrollFrames keeps its own until it migrates (a separate, consumer-driven step).
  • There was no port for the data preference at all, so a component that wanted to skip a heavyweight fetch had to reach for matchMedia / navigator.connection itself — exactly the global-window access this artifact exists to own. It is the WEIGHT counterpart of the MOTION preference: honored by not fetching the expensive thing (a background video stays on its poster), never by hiding content.

Discipline. ScrollProgress measures inside requestAnimationFrame resolved from the node's own window (iframe / popup safe), never synchronously after a write. prefersReducedData mirrors the reduced-motion tracker byte for byte: per-instance by default, singleton under shareViewport, false when neither source exists (SSR / Node), disposed with the instance.

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:

  • ActiveDom exposed requestFrame / 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 global window, which is wrong in iframe / popup / happy-dom contexts — the same bug getWindow / getDocument solve 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.

Powered by TurnKey Linux.