6.7 KiB
ethereal ($ethereal)
The in-house positioning engine — collision-aware placement geometry for
floating overlays (popover, tooltip, menu, select…). Pure, deterministic,
DOM-read-only: computePosition reads the layout once through $adom and
then runs a middleware chain purely over that snapshot. It is an art
(parallel to $motion and $color)
so both UIX layers consume it with no cross-layer dependency:
- soma —
soma/layers/floating(use-floating,FloatingContent) callscomputePosition+autoUpdatefor the JS positioning path. - eidos —
render-cssemits the native CSS Anchor Positioning rules for the overlaysselectPositioningStrategyclears for the native path.
Nothing is vendored: @floating-ui was read as the spec (MIT); these are our
own idioms and types, kept pixel-identical to it by the parity suites.
Status — consumed.
@floating-uiis a devDep-only parity baseline; the runtime is this engine (layers/floating+$ethereal). Correctness & performance record:PERF.md— 487 parity cases (350 synthetic, 137 real-DOM), cross-browser on Chromium + WebKit + Firefox (engine.test.ts+engine-dom.svelte.test.ts). Interactive demo:web/routes/demos/ethereal.
Why an art
The positioning math has two consumers — soma's JS path and eidos's CSS-anchor
generator — exactly like $motion (soma Presence + eidos CSS) and $color
(eidos build + runtime). Living in arts/ (below the UIX layers) lets both
consume it with no soma→eidos coupling. Per the arts convention it imports no
other art, owns no reactive $state, and touches the DOM only through
the injected $adom runtime (iframe / popup safe — every read goes through
dom.getWindow(node) / dom.measure, never a bare window).
The read-phase model
The whole point of the sec-dom design: computePosition positions inside a
single coalesced dom.measure rAF and never forces a synchronous reflow.
dom.measure(floating) ──▶ read EVERYTHING once
reference rect · floating dims · offsetParent · clipping ancestor-walk
· arrow dims · offsetParent→viewport delta · scale · direction
│
▼
runMiddleware(...) ──▶ pure chain over the snapshot (NO DOM access)
offset → shift → flip → arrow → size → hide … (reset restarts, cap 50)
│
▼
{ x, y, placement, strategy, middlewareData }
runMiddleware is synchronous; @floating-ui's computePosition is async
(a Promise per read + per middleware). The honest trade-off is +1 frame of
latency (the deliberate rAF), documented in PERF.md.
Dual engine — JS + native CSS Anchor Positioning
selectPositioningStrategy(features) decides, per overlay, between the JS engine
and the browser's native anchor positioning (anchor-name / position-anchor /
position-area / @position-try). Native wins only when the browser
supports it (supportsCssAnchor) and none of the JS-forcing features are
present — a continuous shift, an arrow, a virtual anchor, or an explicit
boundary. This gate is the behavioural twin of eidos's @supports (anchor-name) and (position-area) block; the two must agree. See
strategy.ts + PERF.md §"Dual engine".
API
| Export | Purpose |
|---|---|
computePosition(reference, floating, config) |
orchestrator → Promise<ComputePositionReturn>; one dom.measure read, then the pure chain |
runMiddleware(placement, strategy, middleware, snap) |
the pure middleware loop over a ReadSnapshot — no DOM; exported so the math is parity-testable |
autoUpdate(reference, floating, update, options) |
re-runs update on ancestor scroll/resize, element resize, and (opt-in) a per-frame loop → cleanup fn |
selectPositioningStrategy(features) |
'native' | 'js' — the native-vs-JS discriminator |
supportsCssAnchor(window) |
runtime capability probe for CSS Anchor Positioning |
Middleware (chain order is caller-controlled): offset, shift, flip,
arrow, size, hide, limitShift (a limiter on shift, the sticky
behaviour). Each is a Middleware — { name, options?, fn(state) } — pure over
the MiddlewareState.
Placement vocabulary (placement.ts): SIDE_OPTIONS
(top/right/bottom/left), ALIGN_OPTIONS (start/center/end),
OPPOSITE_SIDE; Placement = Side | ${Side}-start|end (center carries no
suffix — it is the bare side); Strategy = 'absolute' | 'fixed'.
Types (types.ts): Measurable (a real element or a
virtual anchor exposing only getBoundingClientRect — the context-menu /
customAnchor pattern), Middleware, MiddlewareData, MiddlewareState,
ComputePositionConfig / ComputePositionReturn, Coords / Dimensions /
Rect / SideObject / Padding, ClippingContext, Boundary (Element | null). Geometry helpers (getSide, getAlignment, getOppositePlacement,
getExpandedPlacements, computeCoordsFromPlacement, …) back the middleware
math and are exported for reuse.
config = { placement = 'bottom', strategy = 'absolute', middleware = [], dom }
— dom is the required ActiveDom. autoUpdate options default
ancestorScroll / ancestorResize / elementResize to true and
animationFrame to false; the observeMove IntersectionObserver layout-shift
path is intentionally not implemented (scope: scroll + resize + rAF).
Depends on
$adom— every DOM read/observe (getWindow,measure,listen,observeResize,requestFrame), so the engine is iframe / popup / test safe.- no other art;
@floating-uiis a devDep-only parity baseline, never a runtime import.
Consumers
src/uix/soma/layers/floating/—use-floating.svelte.ts(callscomputePosition),floating.svelte.ts(the middleware wiring +autoUpdate).src/uix/eidos/lib/render-css.ts— the native CSS-anchor block gated on the same capability setselectPositioningStrategyuses.