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