docs(floating): session hand-off for the @floating-ui removal (P1 in progress)

Self-contained CONTINUE.md to resume the positioning-layer rebuild cold: the
decisions (CSS-anchor primary + full-parity JS engine via $adom, reimplement-not-
vendor, P5 deferred), what's done (P0 + P1 foundation + the DOM-read/clipping core),
the read-phase architecture, the ordered remaining work (overflow + middleware +
compute + auto-update + wiring + dep deletion), the exact @floating-ui removal
surface, and the A/B verification plan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent 044bef5384
commit 7ec04d43b6

@ -0,0 +1,193 @@
# CONTINUE — Removing `@floating-ui`, building our own positioning layer
> **Status: P1 in progress (~half).** The hard part (DOM-read + clipping ancestor-walk)
> is built, committed and compiling. The middleware + orchestration + wiring remain.
> Branch: `alpha-0.1-sec-dom`. Read this cold to resume.
## Why
`@floating-ui/dom@^1.7.1` + `@floating-ui/core@^1.7.1` (package.json:53-54) are the
**last external dependency** — they contradict the framework's `zero dependence, own
implementations; no vendored ports` doctrine. The `alpha-0.1-sec-dom` branch first
added the foundation that makes removal viable with **no perf regression**:
`dom.measure` (coalesced post-layout reads) + `dom.getWindow(node)` (iframe-safe).
Now we replace `@floating-ui` with our own layer.
## Decisions (settled — do NOT re-litigate)
- **CSS Anchor Positioning is the PRIMARY path** (browser computes position natively;
it does clipping/containing-block/flip correctly), generated by **eidos** behind
`@supports`. The **JS engine is the fallback** AND the path for cases CSS can't do
on any browser (`Measurable`/virtual anchors, explicit `collisionBoundary`, `sticky`,
shadow-DOM-crossing).
- **Clipping = full parity (option 2).** The JS engine reimplements the COMPLETE
`getClippingRect` ancestor-walk — NOT a "good-enough" version. Rationale: the JS
path serves modern-browser cases too (virtual anchors etc.), so it must be correct
everywhere, making `engine/` a complete reference-grade positioning engine; CSS is
the optimization on top. **(Done — see `clipping.ts`.)**
- **Reimplement, do NOT vendor.** floating-ui is read as a **spec** (MIT). `engine/`
is our own idioms + types; nothing is copied verbatim (vendoring = the debt the
doctrine forbids).
- **Every layout read goes through `$adom`** — `dom.measure(read, node)` +
`dom.getWindow(node)`. NEVER raw `getBoundingClientRect`/`window` (would regress the
forced-reflow fix this branch shipped).
- **API frozen.** `useFloating` / `FloatingContent` / `FloatingContentProps` /
`Measurable` / `Side`/`Align`/`Boundary` / `getSideFromPlacement` /
`getAlignFromPlacement` / the `--floating-*` CSS vars / `data-side`/`data-align`/
`data-floating-wrapper` attrs stay byte-stable so the ~15 overlay consumers + their
eidos CSS never change.
- **P5 (native `popover`/top-layer via morfo/sema) is DEFERRED** — off the dep-removal
critical path; a separate sprint.
Full design + phasing: `~/.claude/plans/zany-booping-feigenbaum.md` (plan-mode file,
may not persist). Rationale indexed in `docs/decisions.md` (sec-dom entry). Project
memory: `project_floating_ui_removal_2026-06-29`.
## Done (4 commits on `alpha-0.1-sec-dom`, all compile clean, runtime still @floating-ui)
- `e84afb15` **P0** — de-vendor types + dispatcher scaffold:
- `placement.ts` now owns `Placement` (`Side | \`${Side}-${start|end}\``, center = bare
side — matches floating-ui exactly) + `Strategy`.
- `types.ts` owns `FloatingElement`/`ReferenceElement`/`MiddlewareData` (only the
`arrow`/`hide`/`transformOrigin` keys we read). **`Middleware` still imported from
`@floating-ui/dom`** — de-vendor it in P1 (it couples `MiddlewareState`/`Return`,
which the engine defines).
- `safe-polygon.ts` Side import repointed to `./placement`.
- `engine/supports.ts` — `supportsCssAnchor(win)` (behavioural twin of the eidos
`@supports` gate; unused yet).
- `a3dfe203` **P1 foundation** — `engine/types.ts` (internal positioning types +
the read-phase contract) + `engine/geometry.ts` (pure placement/coords math:
`getSide`/`getAlignment`/`getOppositePlacement`/`getOppositeAlignmentPlacement`/
`computeCoordsFromPlacement`/`getPaddingObject`).
- `044bef53` **P1 DOM-read core** — `engine/rects.ts` (offsetParent resolution +
containing-block detection + scale-aware rects + reference-rect-relative-to-offsetParent
+ `convertOffsetParentRelativeRectToViewportRelativeRect`) + `engine/clipping.ts`
(the FULL overflow-ancestor walk `getClippingRect` + `isOverflowElement`).
## Architecture
**Read-phase model (the key idea).** Unlike floating-ui (interleaves reads through the
middleware), our `compute.ts` does **ONE `dom.measure` callback** that reads EVERYTHING
upfront — reference rect, floating dims, offsetParent, the clipping rect (ancestor-walk),
the reference's clipping (only if `hide` is active), arrow element dims, and the
`viewportDelta` (the offset to convert offsetParent-relative → viewport). Then the
middleware chain runs **PURELY** (no DOM reads) on that data. This is cleaner than
floating-ui AND perfectly aligned with `dom.measure` (one rAF, no forced reflow).
**Dispatcher.** `engine/supports.ts:supportsCssAnchor(win)` (two `CSS.supports` probes).
CSS path when true; JS engine when false OR when a case forces JS (Measurable anchor,
explicit `collisionBoundary`, `sticky`, shadow-DOM). It's the behavioural twin of the
eidos `@supports` block — both must agree.
**Module map** (`engine/`):
```
types.ts ✅ internal types (Rect/Coords/ElementRects/ClippingContext/
MiddlewareState/MiddlewareReturn/Middleware/ComputePosition*)
geometry.ts ✅ pure placement/coords math
rects.ts ✅ offsetParent + scale-aware rects + viewport conversion
clipping.ts ✅ getClippingRect ancestor-walk (option 2)
supports.ts ✅ supportsCssAnchor (dispatcher gate)
overflow.ts ⬜ detectOverflow — PURE, over the pre-read clipping + viewportDelta
middleware/ ⬜ offset · shift · flip · arrow · size · hide · limit-shift
compute.ts ⬜ the read phase + the middleware loop (with flip's `reset`)
auto-update.ts⬜ scroll/resize/rAF via $adom
```
## Remaining (ordered — do these next)
1. **Adjust `MiddlewareState`** (`engine/types.ts`): add `viewportDelta: Coords`
(precomputed in the read phase so `detectOverflow` converts purely); the middleware
are pure so they don't need `dom`. The read phase computes `viewportDelta` via
`convertOffsetParentRelativeRectToViewportRelativeRect({x:0,y:0,width:0,height:0},
offsetParent, strategy, win)` → `{x,y}`.
2. **`engine/overflow.ts`** — `detectOverflow(state, { padding?, elementContext? })`:
PURE. `elementRect` (floating at `state.x/y` + `rects.floating` dims, or
`rects.reference`) → convert to viewport via `viewportDelta` → compare to
`state.clipping[elementContext]` (already viewport coords) → `SideObject`. Use
`getPaddingObject` + `rectToClientRect`.
3. **`engine/middleware/*.ts`** — each is a factory returning `Middleware`, matching
floating-ui's call signatures (so `floating.svelte.ts`'s array works with our
factories under the flag):
- `offset({ mainAxis?, alignmentAxis? } | number)` — pure (shift coords along axes).
- `shift({ mainAxis?, crossAxis?, limiter?, boundary?, padding? })` — detectOverflow,
slide within; `limiter` from `limitShift()`.
- `flip({ boundary?, padding?, ... })` — detectOverflow on side; if overflows, return
`{ reset: { placement: getOppositePlacement(...) } }`.
- `arrow({ element, padding? })` — pure; center on reference, clamp by padding, emit
`{ data: { x?, y?, centerOffset } }`. Uses `state.arrowDimensions`.
- `size({ boundary?, padding?, apply })` — detectOverflow → availableWidth/Height →
call `apply({ availableWidth, availableHeight, rects, ... })`.
- `hide({ strategy: 'referenceHidden', boundary?, padding? })` — detectOverflow on the
REFERENCE (`elementContext:'reference'`) → `{ data: { referenceHidden } }`.
- `limitShift(options?)` — the shift limiter for `sticky:'partial'`.
- **`transformOrigin` is ALREADY ours** (floating.svelte.ts:391-429) — keep verbatim.
4. **`engine/compute.ts`** — `computePosition(reference, floating, config): Promise<...>`:
- Extract `boundary`/`padding` from the middleware options (uniform in our usage —
`floating.svelte`'s `this.detectOverflowOptions`; take the first middleware that
declares a `boundary`, default `'clippingAncestors'`). Detect if `hide` is present
(→ also read the reference clipping).
- `await` ONE read phase: `new Promise(res => dom.measure(() => res(readAll()), floating))`
reading `getElementRects` + `getClippingRect(floating, boundary)` +
(hide ? `getClippingRect(reference, boundary)`) + arrow dims + `viewportDelta`.
- Run the middleware loop: base coords from `computeCoordsFromPlacement`, then each
middleware in order; on `reset: { placement }` recompute base coords for the new
placement and restart the loop (cap iterations ~50, like floating-ui). Merge
`data` into `middlewareData[name]`.
- Return `{ x, y, placement, strategy, middlewareData }`.
5. **`engine/auto-update.ts`** — `autoUpdate(reference, floating, update, { animationFrame?, dom })`:
scroll + resize listeners via `dom.listen`; element resize via `$adom` ResizeObserver
(or runed `ElementSize`); `animationFrame` loop via `dom.requestFrame`. Returns cleanup.
**No raw `window`/`getBoundingClientRect`.**
6. **De-vendor `Middleware`** — now that `engine/types.ts` defines `MiddlewareState`/
`Return`/`Middleware`, move `Middleware` out of `@floating-ui` into `../types`
(re-export from `engine/types`). Update the `type Middleware` imports in `types.ts`,
`use-floating.svelte.ts`, `floating.svelte.ts`.
7. **Wire the `USE_OWN_ENGINE` flag** — a module const. When true:
- `use-floating.svelte.ts:59` — call `computePosition` from `./engine/compute` (thread
`dom` from `FloatingProvider.opts.dom`). Keep the `.then(...)` shape (compute is async
via the read-phase rAF — matches today).
- `floating.svelte.ts:143-177` — build the middleware array from `./engine/middleware`;
swap `autoUpdate` (line 294) for `./engine/auto-update`.
Keep `@floating-ui` as the default (flag off) for A/B until verified.
## API-compat checklist (the consumer × feature matrix to preserve)
`side`/`align` → placement · `sideOffset`/`alignOffset` → `offset` · `avoidCollisions`
→ `flip`+`shift` · `arrowPadding` → `arrow` · `collisionBoundary`/`collisionPadding` →
detectOverflow (explicit boundary forces JS) · `sticky` → `limitShift` (forces JS) ·
`hideWhenDetached` → `hide` · `matchAnchorWidth` → `size` (writes `--floating-anchor-*`) ·
`customAnchor` (Measurable forces JS) · `updatePositionStrategy` → `autoUpdate`. The
middleware reads in floating.svelte: `middlewareData.arrow?.{x,y,centerOffset}` (181-186),
`.hide?.referenceHidden` (230), `.transformOrigin?.{x,y}` (225).
## P4 — the exact `@floating-ui` removal surface (after P1-P3 verified)
- `use-floating.svelte.ts:1` `computePosition`, `:2` `type Middleware`.
- `types.ts:1` `type Middleware`.
- `floating.svelte.ts:1-11` `type Middleware, arrow, autoUpdate, flip, hide, limitShift, offset, shift, size`.
- `package.json:53-54` `@floating-ui/core` + `@floating-ui/dom` → `npm install` to drop lockfile.
- Verify: `grep -r "@floating-ui" src/ web/` → ZERO.
## Verification (each phase)
- `npm run check` → 61 baseline (0 new). `npx vitest run src/uix/soma`.
- **Browser, A/B** (flip `USE_OWN_ENGINE`): demos under `web/routes/uix/components/`:
popover, tooltip, dropdown-menu, context-menu, menubar, navigation-menu, select,
combobox, the 5 pickers, dialog, drawer, link-preview. All 12 placements, flip at
viewport edges, shift, arrow centering, matchAnchorWidth, sticky-while-scroll, virtual
anchor (context-menu), safe-polygon (link-preview), **clipping inside a nested scroll
container + a `transform`-ed ancestor** (the option-2 win). Screenshot each (verify-visually).
- Console: **zero `[Violation] Forced reflow`** (all reads via `dom.measure`). Confirm
with `uix.perf` (`createActiveUix({ reflowDetector: true })`) if attribution needed.
## Read first (next session)
- This file + `~/.claude/plans/zany-booping-feigenbaum.md`.
- `floating.svelte.ts` — the middleware array (143-177), the reads (181-186, 225, 230),
`transformOrigin` (391-429), `this.detectOverflowOptions`, the z-index `dom.measure`
read (316-323), `canonicalGapPx` (195-202).
- `use-floating.svelte.ts` — the `computePosition().then(...)` flow (59-92) + the
bad-coord guard (77-84) + `isPositioned`.
- The built engine files (`engine/{types,geometry,rects,clipping,supports}.ts`).
- floating-ui's source as the **spec** for the middleware algorithms (offset/shift/flip/
arrow/size/hide) + `detectOverflow` — read, reimplement, don't copy.
Loading…
Cancel
Save

Powered by TurnKey Linux.