refactor(ethereal): rename the $floating positioning engine to $ethereal

The in-house positioning engine (relocated to its own art in 58683b03) is
renamed floating -> ethereal: src/arts/floating -> src/arts/ethereal, alias
$floating -> $ethereal (vite + svelte config), all soma wrappers + the demo
repointed. The reactive wrappers stay in soma/layers/floating (soma's
floating-overlay concept); the *library* is ethereal.

Also lands the cross-browser validation + perf characterization the rename
batch was verified against:

- engine-dom parity suite runs on Chromium + WebKit + Firefox via
  UIX_CROSS_BROWSER=1 (env-gated in vite.config; default chromium). 411 runs
  green — exercises the isWebKit-gated paths Chromium never ran.
- PERF.md: honest write-up. Bundle measured (own 6.1 KB vs @floating-ui
  8.2 KB gzip -> ~2 KB net win). Speed numbers labelled CPU-work, NOT
  perceived wall-clock; the +1-frame latency documented as a real cost.
- web/routes/demos/ethereal: visual A/B demo vs @floating-ui.

@floating-ui untouched — flag still OFF, P4 not executed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent 58683b0306
commit 725b979cb8

@ -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.

@ -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`,

@ -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).

@ -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

@ -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

@ -14,7 +14,7 @@ export {
type Side,
type Align,
type Boundary
} from '$floating';
} from '$ethereal';
// ── Floating engine ──────────────────────────────────────────────────────────
export type {

@ -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';

@ -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;

@ -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;

@ -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,

@ -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'),

@ -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}']
}

@ -0,0 +1,14 @@
<script lang="ts">
// Passthrough shell — `+layout@.svelte` resets layout inheritance so this
// route gets no docs chrome. The floating perf demo is self-contained (raw
// DOM + the two positioning engines), no UIX/eidos boot needed.
let { children } = $props();
</script>
{@render children?.()}
<style>
:global(body) {
margin: 0;
}
</style>

@ -0,0 +1,731 @@
<script lang="ts">
/**
* Visual A/B: the in-house `$ethereal` positioning engine vs `@floating-ui/dom`.
* Three demonstrations on the user's own machine:
* 1. Correctness — both engines position the SAME anchor; the boxes overlap
* and a live Δ readout proves the output is pixel-identical.
* 2. Speed — a live micro-benchmark (sync runMiddleware vs async
* computePosition) reports ops/s + speedup.
* 3. Fluidity — N animated anchors with following boxes + an FPS meter; flip
* the engine at high N to feel the difference (own batches its reads into
* one dom.measure; floating-ui reads synchronously per element).
*
* Raw DOM in the rAF loop (no Svelte reactivity in the hot path) so the FPS
* meter reflects the engines, not the framework.
*/
import { onDestroy, onMount, tick } from 'svelte';
import { createActiveDom } from '$adom';
import {
computePosition as ownCompute,
runMiddleware,
offset as oOffset,
shift as oShift,
flip as oFlip,
type Placement,
type Middleware,
type ReadSnapshot,
type Rect
} from '$ethereal';
import {
computePosition as fuiCompute,
offset as fOffset,
shift as fShift,
flip as fFlip
} from '@floating-ui/dom';
const dom = createActiveDom();
const PLACEMENTS: Placement[] = ['top', 'right', 'bottom', 'left'];
let count = $state(60);
let engine = $state<'own' | 'fui' | 'both'>('both');
let placement = $state<Placement>('bottom');
let animate = $state(true);
let fps = $state(0);
let msFrame = $state(0);
let deltaMax = $state(0);
let benching = $state(false);
let bench = $state<{ ownOps: number; fuiOps: number; speedup: number } | null>(null);
// Fixed chain for both engines (offset + shift + flip — the soma default).
const oc: Middleware[] = [oOffset({ mainAxis: 10 }), oShift({ padding: 8 }), oFlip()];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fc: any[] = [fOffset({ mainAxis: 10 }), fShift({ padding: 8 }), fFlip()];
let area: HTMLDivElement;
type Item = {
anchor: HTMLElement;
ownBox: HTMLElement;
fuiBox: HTMLElement;
ax: number;
ay: number;
phase: number;
r: number;
speed: number;
};
let items: Item[] = [];
function mk(cls: string): HTMLDivElement {
const d = document.createElement('div');
d.className = cls;
area.appendChild(d);
return d;
}
function rebuild() {
if (!area) return;
for (const it of items) {
it.anchor.remove();
it.ownBox.remove();
it.fuiBox.remove();
}
items = [];
const w = area.clientWidth || 900;
const h = area.clientHeight || 460;
for (let i = 0; i < count; i++) {
const anchor = mk('anchor');
const ownBox = mk('box box-own');
const fuiBox = mk('box box-fui');
ownBox.textContent = 'ethereal';
fuiBox.textContent = '@floating-ui';
items.push({
anchor,
ownBox,
fuiBox,
ax: 70 + Math.random() * (w - 140),
ay: 70 + Math.random() * (h - 140),
phase: Math.random() * Math.PI * 2,
r: 24 + Math.random() * 70,
speed: 0.4 + Math.random() * 1.1
});
}
applyVisibility();
}
function applyVisibility() {
for (const it of items) {
it.ownBox.style.display = engine !== 'fui' ? '' : 'none';
it.fuiBox.style.display = engine !== 'own' ? '' : 'none';
}
}
let _maxD = 0;
function reposition() {
const doOwn = engine !== 'fui';
const doFui = engine !== 'own';
const round = (n: number) => Math.round(n);
for (const it of items) {
if (doOwn && doFui) {
Promise.all([
ownCompute(it.anchor, it.ownBox, { placement, middleware: oc, dom }),
fuiCompute(it.anchor, it.fuiBox, { placement, middleware: fc })
]).then(([o, f]) => {
it.ownBox.style.transform = `translate(${round(o.x)}px,${round(o.y)}px)`;
it.fuiBox.style.transform = `translate(${round(f.x)}px,${round(f.y)}px)`;
const d = Math.max(Math.abs(o.x - f.x), Math.abs(o.y - f.y));
if (d > _maxD) _maxD = d;
});
} else if (doOwn) {
ownCompute(it.anchor, it.ownBox, { placement, middleware: oc, dom }).then((o) => {
it.ownBox.style.transform = `translate(${round(o.x)}px,${round(o.y)}px)`;
});
} else {
fuiCompute(it.anchor, it.fuiBox, { middleware: fc, placement }).then((f) => {
it.fuiBox.style.transform = `translate(${round(f.x)}px,${round(f.y)}px)`;
});
}
}
}
let raf = 0;
let lastT = 0;
let frames = 0;
let accum = 0;
function settle() {
// position anchors at their base + reposition boxes once (paused state)
for (const it of items) it.anchor.style.transform = `translate(${it.ax}px,${it.ay}px)`;
reposition();
}
function frame(t: number) {
const dt = lastT ? t - lastT : 16;
lastT = t;
frames++;
accum += dt;
if (accum >= 300) {
fps = Math.round((1000 * frames) / accum);
msFrame = Math.round((accum / frames) * 10) / 10;
deltaMax = Math.round(_maxD * 100) / 100;
_maxD = 0;
frames = 0;
accum = 0;
}
const time = t / 1000;
for (const it of items) {
const x = it.ax + Math.cos(time * it.speed + it.phase) * it.r;
const y = it.ay + Math.sin(time * it.speed + it.phase) * it.r;
it.anchor.style.transform = `translate(${x}px,${y}px)`;
}
reposition();
// Only keep the loop alive while animating → paused = renderer idle.
raf = animate ? requestAnimationFrame(frame) : 0;
}
function kick() {
if (!animate) return;
if (raf) cancelAnimationFrame(raf);
lastT = 0;
raf = requestAnimationFrame(frame);
}
function onVisibility() {
// rAF is paused while the tab is in the background; resume cleanly on return.
if (!document.hidden && animate) kick();
}
onMount(() => {
document.addEventListener('visibilitychange', onVisibility);
if (animate) kick();
else settle();
});
onDestroy(() => {
document.removeEventListener('visibilitychange', onVisibility);
if (raf) cancelAnimationFrame(raf);
dom.dispose();
});
// Rebuild reactively when N changes (runs once after mount too — area is bound).
$effect(() => {
void count;
rebuild();
if (!animate) settle();
});
$effect(() => {
void engine;
applyVisibility();
if (!animate) settle();
});
$effect(() => {
void placement;
if (!animate) settle();
});
$effect(() => {
if (animate) kick();
else {
fps = 0;
settle();
}
});
// ── live micro-benchmark (synthetic, CPU only) ────────────────────────────
async function runBench() {
benching = true;
bench = null;
await tick();
await new Promise((r) => setTimeout(r, 30));
const reference: Rect = { x: 850, y: 700, width: 120, height: 36 };
const floatingDims = { width: 240, height: 160 };
const clip: Rect = { x: 0, y: 0, width: 1000, height: 760 };
const viewportDelta = { x: -40, y: -120 };
const snapshot: ReadSnapshot = {
rects: { reference, floating: { x: 0, y: 0, ...floatingDims } },
elements: { reference: {} as never, floating: {} as never },
clipping: { floating: clip, reference: clip },
arrowDimensions: undefined,
viewportDelta,
offsetScale: { x: 1, y: 1 },
rtl: false
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const platform: any = {
getElementRects: async () => ({ reference, floating: { x: 0, y: 0, ...floatingDims } }),
getClippingRect: async () => ({ ...clip }),
getDimensions: async () => ({ ...floatingDims }),
getOffsetParent: async () => ({}),
getScale: async () => ({ x: 1, y: 1 }),
isElement: async () => true,
convertOffsetParentRelativeRectToViewportRelativeRect: async ({ rect }: { rect: Rect }) => ({
x: rect.x + viewportDelta.x,
y: rect.y + viewportDelta.y,
width: rect.width,
height: rect.height
}),
isRTL: async () => false,
getDocumentElement: async () => ({})
};
const N = 40000;
for (let i = 0; i < 4000; i++) runMiddleware('bottom', 'absolute', oc, snapshot);
for (let i = 0; i < 4000; i++)
await fuiCompute({} as never, {} as never, {
placement: 'bottom',
strategy: 'absolute',
middleware: fc,
platform
});
const t0 = performance.now();
for (let i = 0; i < N; i++) runMiddleware('bottom', 'absolute', oc, snapshot);
const ownMs = performance.now() - t0;
const t1 = performance.now();
for (let i = 0; i < N; i++)
await fuiCompute({} as never, {} as never, {
placement: 'bottom',
strategy: 'absolute',
middleware: fc,
platform
});
const fuiMs = performance.now() - t1;
bench = {
ownOps: Math.round((N / ownMs) * 1000),
fuiOps: Math.round((N / fuiMs) * 1000),
speedup: Math.round((fuiMs / ownMs) * 100) / 100
};
benching = false;
}
const fmt = (n: number) => n.toLocaleString('es-ES');
</script>
<svelte:head>
<title>ethereal vs @floating-ui — comparativa visual</title>
</svelte:head>
<main>
<header>
<h1><span class="brand">ethereal</span> <span class="vs">vs</span> @floating-ui</h1>
<p>
Comparativa en vivo sobre <strong>tu máquina</strong>. Verde = <code>ethereal</code> (motor
propio, síncrono). Magenta = <code>@floating-ui</code> (async). En modo
<em>Ambos</em> las cajas se superponen: si el output es idéntico, no se separan.
</p>
</header>
<section class="controls">
<div class="ctl">
<span class="lbl">Motor</span>
<div class="seg">
<button class:on={engine === 'own'} onclick={() => (engine = 'own')}>ethereal</button>
<button class:on={engine === 'fui'} onclick={() => (engine = 'fui')}>@floating-ui</button>
<button class:on={engine === 'both'} onclick={() => (engine = 'both')}>Ambos</button>
</div>
</div>
<div class="ctl">
<span class="lbl">Elementos · {count}</span>
<input type="range" min="5" max="300" step="5" bind:value={count} />
</div>
<div class="ctl">
<span class="lbl">Lado</span>
<div class="seg">
{#each PLACEMENTS as p (p)}
<button class:on={placement === p} onclick={() => (placement = p)}>{p}</button>
{/each}
</div>
</div>
<div class="ctl">
<span class="lbl">Animar</span>
<button class="toggle" class:on={animate} onclick={() => (animate = !animate)}
>{animate ? 'on' : 'off'}</button
>
</div>
</section>
<section class="metrics">
<div class="metric">
<div class="mv" class:warn={fps < 50} class:bad={fps < 30}>{fps}</div>
<div class="ml">FPS</div>
</div>
<div class="metric">
<div class="mv">{msFrame}<small>ms</small></div>
<div class="ml">por frame</div>
</div>
<div class="metric">
<div class="mv">{fmt(fps * count * (engine === 'both' ? 2 : 1))}</div>
<div class="ml">posiciones/s</div>
</div>
<div class="metric">
<div class="mv" class:good={deltaMax < 0.6} class:bad={deltaMax >= 1}>
{engine === 'both' ? deltaMax.toFixed(2) : '—'}<small>px</small>
</div>
<div class="ml">Δ máx (corrección)</div>
</div>
</section>
<div class="area" bind:this={area}></div>
<section class="bench">
<div class="bench-head">
<div>
<h2>Benchmark de cómputo puro</h2>
<p>40 000 iteraciones de la cadena, math sola (CPU). Sin DOM. En tu CPU.</p>
</div>
<button class="run" onclick={runBench} disabled={benching}
>{benching ? 'Midiendo…' : 'Ejecutar benchmark'}</button
>
</div>
{#if bench}
<div class="bars">
<div class="bar-row">
<span class="bn own">ethereal</span>
<div class="bar"><div class="fill own" style:width="100%"></div></div>
<span class="bops">{fmt(bench.ownOps)} ops/s</span>
</div>
<div class="bar-row">
<span class="bn fui">@floating-ui</span>
<div class="bar">
<div class="fill fui" style:width="{(bench.fuiOps / bench.ownOps) * 100}%"></div>
</div>
<span class="bops">{fmt(bench.fuiOps)} ops/s</span>
</div>
</div>
<p class="verdict">
<strong>{bench.speedup}×</strong> más rápido el motor propio (síncrono vs el async de
floating-ui).
</p>
{/if}
</section>
<section class="notes">
<h2>Cómo leerlo</h2>
<ul>
<li>
<strong>Corrección</strong> — en <em>Ambos</em>, Δ máx ≈ <code>0.00px</code>: el output es
idéntico bit a bit (verificado además por 487 tests de paridad).
</li>
<li>
<strong>Velocidad</strong> — el motor propio es síncrono; floating-ui envuelve cada lectura
y middleware en un <code>await</code>. Esa maquinaria async cuesta: ~1.5–2.3× en CPU.
</li>
<li>
<strong>Fluidez</strong> — sube los elementos y alterna el motor con <em>Animar</em> on. JS
es mono-hilo: ambos serializan (el async <em>no</em> paraleliza). Pero el motor propio
coalesce sus lecturas en un <code>dom.measure</code> → 1 reflow; floating-ui lee síncrono por
elemento → puede provocar <em>thrashing</em> de layout con muchos overlays.
</li>
<li>
<strong>El precio honesto</strong> — el motor propio posiciona en el siguiente
<code>rAF</code> (+1 frame de latencia), deliberado para no forzar reflow. Imperceptible al
abrir; alineado al frame durante scroll.
</li>
</ul>
</section>
</main>
<style>
:global(html) {
background: #0b0e14;
}
main {
max-width: 1100px;
margin: 0 auto;
padding: 32px 24px 80px;
color: #d6dae3;
font:
14px/1.55 'Inter',
system-ui,
sans-serif;
}
header h1 {
font-size: 26px;
font-weight: 650;
margin: 0 0 8px;
color: #f2f4f8;
letter-spacing: -0.01em;
}
.brand {
background: linear-gradient(90deg, #22c55e, #60a5fa);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.vs {
color: #6b7280;
font-weight: 400;
}
header p {
margin: 0 0 22px;
max-width: 760px;
color: #9aa3b2;
}
code {
background: #1a1f2b;
padding: 1px 6px;
border-radius: 5px;
font-size: 0.92em;
color: #c7d0de;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 22px;
align-items: flex-end;
padding: 16px 18px;
background: #11151e;
border: 1px solid #1e2430;
border-radius: 12px;
margin-bottom: 14px;
}
.ctl {
display: flex;
flex-direction: column;
gap: 7px;
}
.lbl {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #6b7484;
}
.seg {
display: inline-flex;
background: #0c0f16;
border: 1px solid #232a38;
border-radius: 8px;
overflow: hidden;
}
.seg button {
background: none;
border: none;
color: #9aa3b2;
padding: 7px 13px;
font-size: 13px;
cursor: pointer;
border-right: 1px solid #1c2230;
}
.seg button:last-child {
border-right: none;
}
.seg button.on {
background: #2563eb;
color: #fff;
}
.toggle {
background: #0c0f16;
border: 1px solid #232a38;
color: #9aa3b2;
border-radius: 8px;
padding: 7px 16px;
cursor: pointer;
font-size: 13px;
}
.toggle.on {
background: #16a34a;
color: #fff;
border-color: #16a34a;
}
input[type='range'] {
width: 200px;
accent-color: #2563eb;
}
.metrics {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 14px;
}
.metric {
background: #11151e;
border: 1px solid #1e2430;
border-radius: 12px;
padding: 16px 18px;
text-align: center;
}
.mv {
font-size: 30px;
font-weight: 680;
color: #f2f4f8;
font-variant-numeric: tabular-nums;
}
.mv small {
font-size: 13px;
color: #6b7484;
font-weight: 400;
margin-left: 1px;
}
.mv.warn {
color: #f59e0b;
}
.mv.bad {
color: #ef4444;
}
.mv.good {
color: #22c55e;
}
.ml {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #6b7484;
margin-top: 4px;
}
.area {
position: relative;
height: 460px;
background:
radial-gradient(circle at 20% 20%, #141926, #0b0e14 60%);
border: 1px solid #1e2430;
border-radius: 14px;
overflow: hidden;
margin-bottom: 24px;
}
.area :global(.anchor) {
position: absolute;
top: 0;
left: 0;
width: 7px;
height: 7px;
margin: -3.5px;
border-radius: 50%;
background: #f8fafc;
box-shadow: 0 0 8px rgba(255, 255, 255, 0.5);
will-change: transform;
}
.area :global(.box) {
position: absolute;
top: 0;
left: 0;
width: 88px;
height: 30px;
border-radius: 6px;
font:
9px/30px 'Inter',
sans-serif;
text-align: center;
color: rgba(255, 255, 255, 0.85);
pointer-events: none;
will-change: transform;
white-space: nowrap;
overflow: hidden;
}
.area :global(.box-own) {
background: rgba(22, 163, 74, 0.16);
border: 1.5px solid #22c55e;
}
.area :global(.box-fui) {
background: rgba(217, 70, 239, 0.12);
border: 1.5px dashed #d946ef;
}
.bench {
background: #11151e;
border: 1px solid #1e2430;
border-radius: 12px;
padding: 20px 22px;
margin-bottom: 22px;
}
.bench-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
}
.bench h2 {
font-size: 16px;
margin: 0 0 4px;
color: #f2f4f8;
}
.bench p {
margin: 0;
color: #8b93a3;
font-size: 13px;
}
.run {
background: #2563eb;
color: #fff;
border: none;
border-radius: 9px;
padding: 11px 20px;
font-size: 14px;
font-weight: 560;
cursor: pointer;
white-space: nowrap;
}
.run:disabled {
opacity: 0.6;
cursor: default;
}
.bars {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.bar-row {
display: grid;
grid-template-columns: 110px 1fr 130px;
align-items: center;
gap: 14px;
}
.bn {
font-size: 13px;
font-weight: 560;
}
.bn.own {
color: #22c55e;
}
.bn.fui {
color: #d946ef;
}
.bar {
height: 22px;
background: #0c0f16;
border-radius: 6px;
overflow: hidden;
}
.fill {
height: 100%;
border-radius: 6px;
transition: width 0.5s ease;
}
.fill.own {
background: linear-gradient(90deg, #16a34a, #22c55e);
}
.fill.fui {
background: linear-gradient(90deg, #a21caf, #d946ef);
}
.bops {
font-size: 13px;
color: #c7d0de;
text-align: right;
font-variant-numeric: tabular-nums;
}
.verdict {
margin: 16px 0 0;
font-size: 15px;
color: #d6dae3;
}
.verdict strong {
color: #22c55e;
font-size: 18px;
}
.notes {
background: #0e1219;
border: 1px solid #1a202c;
border-radius: 12px;
padding: 20px 24px;
}
.notes h2 {
font-size: 15px;
margin: 0 0 12px;
color: #cbd2de;
}
.notes ul {
margin: 0;
padding-left: 18px;
display: flex;
flex-direction: column;
gap: 9px;
}
.notes li {
color: #9aa3b2;
font-size: 13.5px;
}
.notes strong {
color: #d6dae3;
}
</style>

@ -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;
Loading…
Cancel
Save

Powered by TurnKey Linux.