diff --git a/src/arts/ethereal/PERF.md b/src/arts/ethereal/PERF.md new file mode 100644 index 000000000..7a9e86702 --- /dev/null +++ b/src/arts/ethereal/PERF.md @@ -0,0 +1,80 @@ +# `$ethereal` vs `@floating-ui` — correctness & performance + +Read the caveats inline. The headline speed numbers are **CPU work**, not perceived +wall-clock latency — that distinction is load-bearing and gets dropped when these +figures are quoted out of context. + +## Correctness — the validation that actually justifies the replacement + +The speed numbers are a one-time datum; **this** suite is what protects every commit. + +- **487 parity cases** assert `own == @floating-ui` output: 350 synthetic (the + middleware math, over fixed rects) + 137 real-DOM (`computePosition` of both + engines on the same live elements). +- **Cross-browser**: the 137 real-DOM cases pass on **Chromium + WebKit + Firefox** + (411 runs). Run with `UIX_CROSS_BROWSER=1 npx vitest run --project client + src/arts/ethereal/engine-dom.svelte.test.ts` (needs `npx playwright install + webkit firefox`). This matters: the `isWebKit`-gated paths (`getViewportRect` + visual-viewport offset, `isContainingBlock` filter/backdrop) only execute on + WebKit — Chromium never exercised them. They agree with floating-ui on WebKit. +- **Not covered (honest gaps):** the CSS-anchor *primary* path (P2, unbuilt — today + only the JS fallback exists); deep cross-iframe (documented single-document + simplification); the visual-viewport pinch-zoom branch (not scriptable headless). + +## Speed — CPU work ONLY (not perceived latency) + +`own runMiddleware` is **synchronous**; `@floating-ui`'s `computePosition` is +**async** (a Promise per platform read AND per middleware). The real-DOM numbers +fire own's `dom.measure` rAF **synchronously** to time the WORK, not the frame +wait — so this is the CPU cost of the two orchestration models, **not** +wall-clock-to-paint. own's design *adds a frame of latency that these numbers +exclude* (see below). + +| Bench | own | fui | ratio | note | +|---|--:|--:|--:|---| +| synthetic full chain (CPU) | 130k ops/s | 73k ops/s | **1.79×** | the async/await tax: one microtask hop per read + per middleware | +| per-middleware (CPU) | — | — | **1.6–2.3×** | hide 2.33×, size/shift 2.09×, offset 1.56× | +| real-DOM work, simple | 161 µs | 236 µs | **1.46×** | ⚠️ reads are 97–98% of this and reads are IDENTICAL → the delta is async-orchestration overhead on the ~2%, NOT a read-phase win | +| real-DOM work, nested-scroll | 397 µs | 760 µs | **1.91×** | heavier clipping-walk → more async hops | +| real-DOM work, scaled | 199 µs | 322 µs | **1.62×** | | + +**Many elements.** JS is single-threaded — both engines serialise; async does **not** +parallelise (floating-ui's `await`s are sync work wrapped in Promises, no overlap). +The real many-element win is reflow, not ops/s: own coalesces all reads into one +`dom.measure` (1 layout flush); floating-ui's realistic compute→apply loop +interleaves reads and writes (read → write transform → next read forces reflow). +A read/write-interleave micro-pattern measured ~**1.8×** the cost of read-all-then- +write-all — the penalty floating-ui pays and own avoids. + +## Bundle + +- `@floating-ui` (core + dom, min+gzip): **~8.2 KB**. +- `$ethereal` (own engine, min+gzip, with `$adom` external — its node helpers are + shared infra already in the bundle): **~6.1 KB**. +- P4 removes 8.2 KB and adds 6.1 KB of unique code → **~2 KB net win**, not neutral. + (An earlier "neutral" claim was an un-measured hand-wave; measured, it's smaller — + own implements only the subset soma needs, no `autoPlacement`/`inline`.) + +## The honest cost — +1 frame latency + +own positions inside a `dom.measure` rAF, **deliberately**, to coalesce reads and +never force a synchronous reflow (the whole point of the `sec-dom` branch). + +- **One-shot `computePosition` (open) + the `animationFrame` autoUpdate mode:** + +1 frame. Masked by open transitions; potentially perceptible for a tooltip that + must be correctly placed on its very first frame. +- **Event-driven autoUpdate (scroll/resize):** the rAF coalescing tends to align + with the paint frame, but whether the first frame after a scroll start / + direction change lags depends on the browser's scroll-event timing — **not + measured.** +- `@floating-ui` resolves in a microtask (same frame), so it has no such frame. + "Regression by design" is demonstrated; "imperceptible" is asserted, not proven. + +## Reproducing + +The speed benches were one-off scripts (timing tests are machine-dependent and +flaky in CI, so they are not committed). The methodology above is enough to +recreate them: a synthetic chain over fixed rects (`runMiddleware` vs +`computePosition` with a stub platform), and a real-DOM loop with own's rAF fired +synchronously. The **correctness** suite (`engine.test.ts` + +`engine-dom.svelte.test.ts`) is the committed, CI-run guard. diff --git a/src/arts/floating/auto-update.ts b/src/arts/ethereal/auto-update.ts similarity index 100% rename from src/arts/floating/auto-update.ts rename to src/arts/ethereal/auto-update.ts diff --git a/src/arts/floating/clipping.ts b/src/arts/ethereal/clipping.ts similarity index 100% rename from src/arts/floating/clipping.ts rename to src/arts/ethereal/clipping.ts diff --git a/src/arts/floating/compute.ts b/src/arts/ethereal/compute.ts similarity index 100% rename from src/arts/floating/compute.ts rename to src/arts/ethereal/compute.ts diff --git a/src/arts/floating/engine-dom.svelte.test.ts b/src/arts/ethereal/engine-dom.svelte.test.ts similarity index 100% rename from src/arts/floating/engine-dom.svelte.test.ts rename to src/arts/ethereal/engine-dom.svelte.test.ts diff --git a/src/arts/floating/engine.test.ts b/src/arts/ethereal/engine.test.ts similarity index 100% rename from src/arts/floating/engine.test.ts rename to src/arts/ethereal/engine.test.ts diff --git a/src/arts/floating/flag.ts b/src/arts/ethereal/flag.ts similarity index 100% rename from src/arts/floating/flag.ts rename to src/arts/ethereal/flag.ts diff --git a/src/arts/floating/geometry.ts b/src/arts/ethereal/geometry.ts similarity index 100% rename from src/arts/floating/geometry.ts rename to src/arts/ethereal/geometry.ts diff --git a/src/arts/floating/index.ts b/src/arts/ethereal/index.ts similarity index 96% rename from src/arts/floating/index.ts rename to src/arts/ethereal/index.ts index a01f41cc3..b3c93176d 100644 --- a/src/arts/floating/index.ts +++ b/src/arts/ethereal/index.ts @@ -1,4 +1,4 @@ -// `$floating` — the in-house positioning engine, an art (parallel to `$motion`). +// `$ethereal` — the in-house positioning engine, an art (parallel to `$motion`). // // Pure collision/positioning geometry over `$adom`'s DOM reads. It has NO // reactive / component dependency — the Svelte wrappers (`use-floating`, diff --git a/src/arts/floating/middleware/arrow.ts b/src/arts/ethereal/middleware/arrow.ts similarity index 100% rename from src/arts/floating/middleware/arrow.ts rename to src/arts/ethereal/middleware/arrow.ts diff --git a/src/arts/floating/middleware/flip.ts b/src/arts/ethereal/middleware/flip.ts similarity index 100% rename from src/arts/floating/middleware/flip.ts rename to src/arts/ethereal/middleware/flip.ts diff --git a/src/arts/floating/middleware/hide.ts b/src/arts/ethereal/middleware/hide.ts similarity index 100% rename from src/arts/floating/middleware/hide.ts rename to src/arts/ethereal/middleware/hide.ts diff --git a/src/arts/floating/middleware/index.ts b/src/arts/ethereal/middleware/index.ts similarity index 100% rename from src/arts/floating/middleware/index.ts rename to src/arts/ethereal/middleware/index.ts diff --git a/src/arts/floating/middleware/limit-shift.ts b/src/arts/ethereal/middleware/limit-shift.ts similarity index 100% rename from src/arts/floating/middleware/limit-shift.ts rename to src/arts/ethereal/middleware/limit-shift.ts diff --git a/src/arts/floating/middleware/offset.ts b/src/arts/ethereal/middleware/offset.ts similarity index 100% rename from src/arts/floating/middleware/offset.ts rename to src/arts/ethereal/middleware/offset.ts diff --git a/src/arts/floating/middleware/shift.ts b/src/arts/ethereal/middleware/shift.ts similarity index 100% rename from src/arts/floating/middleware/shift.ts rename to src/arts/ethereal/middleware/shift.ts diff --git a/src/arts/floating/middleware/size.ts b/src/arts/ethereal/middleware/size.ts similarity index 100% rename from src/arts/floating/middleware/size.ts rename to src/arts/ethereal/middleware/size.ts diff --git a/src/arts/floating/overflow.ts b/src/arts/ethereal/overflow.ts similarity index 100% rename from src/arts/floating/overflow.ts rename to src/arts/ethereal/overflow.ts diff --git a/src/arts/floating/placement.ts b/src/arts/ethereal/placement.ts similarity index 100% rename from src/arts/floating/placement.ts rename to src/arts/ethereal/placement.ts diff --git a/src/arts/floating/rects.ts b/src/arts/ethereal/rects.ts similarity index 100% rename from src/arts/floating/rects.ts rename to src/arts/ethereal/rects.ts diff --git a/src/arts/floating/supports.ts b/src/arts/ethereal/supports.ts similarity index 100% rename from src/arts/floating/supports.ts rename to src/arts/ethereal/supports.ts diff --git a/src/arts/floating/types.ts b/src/arts/ethereal/types.ts similarity index 98% rename from src/arts/floating/types.ts rename to src/arts/ethereal/types.ts index 50d1b9470..1f5fea7e1 100644 --- a/src/arts/floating/types.ts +++ b/src/arts/ethereal/types.ts @@ -1,4 +1,4 @@ -// Positioning types for the `$floating` art — math types the engine threads +// Positioning types for the `$ethereal` art — math types the engine threads // internally, plus the public consumer contract (`Measurable`, `Middleware`, // `MiddlewareData`). Reimplemented to our idioms (floating-ui read as spec). diff --git a/src/uix/soma/layers/floating/CONTINUE.md b/src/uix/soma/layers/floating/CONTINUE.md index 407a5b0b1..1ee2997da 100644 --- a/src/uix/soma/layers/floating/CONTINUE.md +++ b/src/uix/soma/layers/floating/CONTINUE.md @@ -1,14 +1,14 @@ # CONTINUE — Removing `@floating-ui`, building our own positioning layer -> **RELOCATION DONE (2026-07-01): the pure engine is now the `$floating` art** -> (`src/arts/floating/`), parallel to `$motion` — consumed by soma's reactive +> **RELOCATION DONE (2026-07-01): the pure engine is now the `$ethereal` art** +> (`src/arts/ethereal/`), parallel to `$motion` — consumed by soma's reactive > wrappers (still here in `soma/layers/floating/`: `use-floating.svelte.ts`, > `floating.svelte.ts`, `shell.ts`, `safe-polygon.ts`, `utils.ts`, `types.ts`, > `index.ts`). Why: the engine is pure collision geometry over `$adom`; soma (JS > path) AND eidos (CSS-anchor path, future) both build on it. `Measurable` / -> `Middleware` / `MiddlewareData` / placement types now live in `$floating`; soma -> `types.ts`/`index.ts` re-export them. Alias `$floating` added to vite + svelte -> config. The paths below that say `engine/…` now mean `$floating/…`. **`@floating-ui` +> `Middleware` / `MiddlewareData` / placement types now live in `$ethereal`; soma +> `types.ts`/`index.ts` re-export them. Alias `$ethereal` added to vite + svelte +> config. The paths below that say `engine/…` now mean `$ethereal/…`. **`@floating-ui` > is UNTOUCHED — P4 NOT executed** (dep installed, fui imports + flag intact, flag OFF). > > **Status: P1 COMPLETE — flag-gated, math-verified.** The whole engine is built @@ -154,6 +154,15 @@ Two parity suites assert **own engine == `@floating-ui`** API output (no removal migration; flag stays OFF). They found 11 real bugs total (4 critical, in the P1 DOM-read layer); all fixed and re-verified. **487 comparison cases, all green.** +> **Cross-browser (2026-07-01):** the 137 real-DOM cases pass on **Chromium + +> WebKit + Firefox** (411 runs) — `UIX_CROSS_BROWSER=1` (env-gated in `vite.config`, +> default chromium). This exercises the `isWebKit`-gated paths that Chromium never +> ran. **Bundle measured** (not estimated): own engine **6.1 KB** min+gzip vs +> `@floating-ui` core+dom **8.2 KB** → P4 saves ~2 KB. Full perf write-up **with the +> caveats embedded** (the speed numbers are CPU work, NOT perceived wall-clock; the +> +1-frame latency is real): `src/arts/ethereal/PERF.md`. Still NOT validated: the +> CSS-anchor primary path (P2, unbuilt) and perceived frame latency. + - **Math/middleware — `engine/engine.test.ts` (350 cases, green).** `runMiddleware` (the real loop) vs `@floating-ui/core`'s `computePosition`, both fed identical synthetic rects (a synthetic floating-ui `platform` mirrors `snapshotOf`). Covers every diff --git a/src/uix/soma/layers/floating/floating.svelte.ts b/src/uix/soma/layers/floating/floating.svelte.ts index 326cffcec..f75f53e95 100644 --- a/src/uix/soma/layers/floating/floating.svelte.ts +++ b/src/uix/soma/layers/floating/floating.svelte.ts @@ -18,7 +18,7 @@ import { shift as ownShift, size as ownSize, USE_OWN_ENGINE -} from '$floating'; +} from '$ethereal'; import type { Middleware } from './types'; import { attachRef, type RefAttachment } from '$libs/reactive'; import { cssToStyleObj, styleToString } from '../../css'; @@ -33,7 +33,7 @@ import { ElementSize, watch } from 'runed'; import { useFloating } from './use-floating.svelte'; import type { Measurable, UseFloatingReturn } from './types'; -import { OPPOSITE_SIDE, type Align, type Boundary, type Placement, type Side } from '$floating'; +import { OPPOSITE_SIDE, type Align, type Boundary, type Placement, type Side } from '$ethereal'; // A/B middleware factory set — own engine or @floating-ui, selected once via the // flag. Typed loosely so the single `middleware` array expression below serves diff --git a/src/uix/soma/layers/floating/index.ts b/src/uix/soma/layers/floating/index.ts index 9dd07b67b..5831ddcc8 100644 --- a/src/uix/soma/layers/floating/index.ts +++ b/src/uix/soma/layers/floating/index.ts @@ -14,7 +14,7 @@ export { type Side, type Align, type Boundary -} from '$floating'; +} from '$ethereal'; // ── Floating engine ────────────────────────────────────────────────────────── export type { diff --git a/src/uix/soma/layers/floating/safe-polygon.ts b/src/uix/soma/layers/floating/safe-polygon.ts index 6cbd013e5..3133b3992 100644 --- a/src/uix/soma/layers/floating/safe-polygon.ts +++ b/src/uix/soma/layers/floating/safe-polygon.ts @@ -1,5 +1,5 @@ import { watch } from 'runed'; -import type { Side } from '$floating'; +import type { Side } from '$ethereal'; import { isElement, type ActiveDom } from '$adom'; import type { TimerHandle, TimerScheduler } from '$timer'; diff --git a/src/uix/soma/layers/floating/types.ts b/src/uix/soma/layers/floating/types.ts index fc7e03be5..711fc64d3 100644 --- a/src/uix/soma/layers/floating/types.ts +++ b/src/uix/soma/layers/floating/types.ts @@ -5,14 +5,14 @@ import type { } from '$libs/reactive'; import type { Arrayable, Direction, StyleProperties } from '../../types'; import type { Snippet } from 'svelte'; -import type { Align, Boundary, Measurable, Middleware, MiddlewareData, Placement, Side, Strategy } from '$floating'; +import type { Align, Boundary, Measurable, Middleware, MiddlewareData, Placement, Side, Strategy } from '$ethereal'; // ─── Shared ─────────────────────────────────────────────────────────────────── // The positioning contract (Measurable / Middleware / MiddlewareData) now lives -// in the `$floating` art. Re-exported here so `./types` stays soma's public +// in the `$ethereal` art. Re-exported here so `./types` stays soma's public // surface for these. -export type { Measurable, Middleware, MiddlewareData } from '$floating'; +export type { Measurable, Middleware, MiddlewareData } from '$ethereal'; /** The positioned (floating) element — always a real DOM element. */ export type FloatingElement = HTMLElement; diff --git a/src/uix/soma/layers/floating/use-floating.svelte.ts b/src/uix/soma/layers/floating/use-floating.svelte.ts index c177ccaf4..0912a6dcd 100644 --- a/src/uix/soma/layers/floating/use-floating.svelte.ts +++ b/src/uix/soma/layers/floating/use-floating.svelte.ts @@ -11,7 +11,7 @@ import { USE_OWN_ENGINE, type Placement, type Strategy -} from '$floating'; +} from '$ethereal'; export function useFloating(options: UseFloatingOptions): UseFloatingReturn { const whileElementsMountedOption = options.whileElementsMounted; diff --git a/src/uix/soma/types/index.ts b/src/uix/soma/types/index.ts index fd7c2b150..deba15658 100644 --- a/src/uix/soma/types/index.ts +++ b/src/uix/soma/types/index.ts @@ -22,7 +22,7 @@ export type { export { isFunction, isNull, isNotNull, isNumberString } from './guards'; -export type { Side, Align, Boundary } from '$floating'; +export type { Side, Align, Boundary } from '$ethereal'; export type { PrimitiveDivAttributes, diff --git a/svelte.config.js b/svelte.config.js index 3bf3ea1ae..c94ccc788 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -24,7 +24,7 @@ const config = { '$clipboard': resolve(__dirname, 'src/arts/clipboard'), '$color': resolve(__dirname, 'src/arts/color'), '$connection': resolve(__dirname, 'src/arts/connection'), - '$floating': resolve(__dirname, 'src/arts/floating'), + '$ethereal': resolve(__dirname, 'src/arts/ethereal'), '$format': resolve(__dirname, 'src/arts/format'), '$http': resolve(__dirname, 'src/arts/http'), '$langs': resolve(__dirname, 'src/arts/langs'), diff --git a/vite.config.ts b/vite.config.ts index fd8961fe6..d74e30638 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -18,7 +18,7 @@ const aliases = { '$clipboard': resolve(__dirname, 'src/arts/clipboard'), '$color': resolve(__dirname, 'src/arts/color'), '$connection': resolve(__dirname, 'src/arts/connection'), - '$floating': resolve(__dirname, 'src/arts/floating'), + '$ethereal': resolve(__dirname, 'src/arts/ethereal'), '$format': resolve(__dirname, 'src/arts/format'), '$http': resolve(__dirname, 'src/arts/http'), '$langs': resolve(__dirname, 'src/arts/langs'), @@ -69,7 +69,17 @@ export default defineConfig({ browser: { enabled: true, provider: playwright(), - instances: [{ browser: 'chromium', headless: true }] + // Default: chromium (fast dev loop). `UIX_CROSS_BROWSER=1` adds + // WebKit + Firefox — the floating-engine parity suite must agree + // with @floating-ui on all three (the isWebKit-gated paths only + // run on WebKit). Requires `npx playwright install webkit firefox`. + instances: process.env.UIX_CROSS_BROWSER + ? [ + { browser: 'chromium', headless: true }, + { browser: 'webkit', headless: true }, + { browser: 'firefox', headless: true } + ] + : [{ browser: 'chromium', headless: true }] }, include: ['src/**/*.svelte.{test,spec}.{js,ts}'] } diff --git a/web/routes/demos/ethereal/+layout@.svelte b/web/routes/demos/ethereal/+layout@.svelte new file mode 100644 index 000000000..884a5ce4c --- /dev/null +++ b/web/routes/demos/ethereal/+layout@.svelte @@ -0,0 +1,14 @@ + + +{@render children?.()} + + diff --git a/web/routes/demos/ethereal/+page.svelte b/web/routes/demos/ethereal/+page.svelte new file mode 100644 index 000000000..676f514bb --- /dev/null +++ b/web/routes/demos/ethereal/+page.svelte @@ -0,0 +1,731 @@ + + + + ethereal vs @floating-ui — comparativa visual + + +
+
+

ethereal vs @floating-ui

+

+ Comparativa en vivo sobre tu máquina. Verde = ethereal (motor + propio, síncrono). Magenta = @floating-ui (async). En modo + Ambos las cajas se superponen: si el output es idéntico, no se separan. +

+
+ +
+
+ Motor +
+ + + +
+
+
+ Elementos · {count} + +
+
+ Lado +
+ {#each PLACEMENTS as p (p)} + + {/each} +
+
+
+ Animar + +
+
+ +
+
+
{fps}
+
FPS
+
+
+
{msFrame}ms
+
por frame
+
+
+
{fmt(fps * count * (engine === 'both' ? 2 : 1))}
+
posiciones/s
+
+
+
= 1}> + {engine === 'both' ? deltaMax.toFixed(2) : '—'}px +
+
Δ máx (corrección)
+
+
+ +
+ +
+
+
+

Benchmark de cómputo puro

+

40 000 iteraciones de la cadena, math sola (CPU). Sin DOM. En tu CPU.

+
+ +
+ {#if bench} +
+
+ ethereal +
+ {fmt(bench.ownOps)} ops/s +
+
+ @floating-ui +
+
+
+ {fmt(bench.fuiOps)} ops/s +
+
+

+ {bench.speedup}× más rápido el motor propio (síncrono vs el async de + floating-ui). +

+ {/if} +
+ +
+

Cómo leerlo

+ +
+
+ + diff --git a/web/routes/demos/ethereal/+page.ts b/web/routes/demos/ethereal/+page.ts new file mode 100644 index 000000000..7b049042d --- /dev/null +++ b/web/routes/demos/ethereal/+page.ts @@ -0,0 +1,4 @@ +// Browser-only demo: it drives raw DOM + requestAnimationFrame + the positioning +// engines (which read live layout). Nothing to server-render. +export const ssr = false; +export const prerender = false;