@dimina-kit/view-anchor 0.1.0-dev.20260618090552 → 0.1.0-dev.20260624040247

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/react.d.ts CHANGED
@@ -23,7 +23,7 @@ export type ViewAnchorRef = (el: HTMLElement | null) => void;
23
23
  * detach (`null`) → publish ZERO then `dispose()`; on `opts`/`deps` change →
24
24
  * `update`; on unmount → publish ZERO then `dispose`.
25
25
  *
26
- * Why ZERO on disappearance (P1 fix): the anchor's follower is a *main-process*
26
+ * Why ZERO on disappearance: the anchor's follower is a *main-process*
27
27
  * `WebContentsView`, not a DOM node. When the anchored element vanishes, core
28
28
  * `dispose()` only stops observing — it deliberately never publishes again
29
29
  * (its Contract 6/7). But the host only collapses the native view when it
package/dist/react.js CHANGED
@@ -6,7 +6,7 @@ import { createViewAnchor } from './view-anchor.js';
6
6
  * detach (`null`) → publish ZERO then `dispose()`; on `opts`/`deps` change →
7
7
  * `update`; on unmount → publish ZERO then `dispose`.
8
8
  *
9
- * Why ZERO on disappearance (P1 fix): the anchor's follower is a *main-process*
9
+ * Why ZERO on disappearance: the anchor's follower is a *main-process*
10
10
  * `WebContentsView`, not a DOM node. When the anchored element vanishes, core
11
11
  * `dispose()` only stops observing — it deliberately never publishes again
12
12
  * (its Contract 6/7). But the host only collapses the native view when it
@@ -50,7 +50,7 @@ export interface PlacementAnchorOptions {
50
50
  */
51
51
  guardDisplayNone?: boolean;
52
52
  /**
53
- * Opt-in capture-phase ancestor-scroll follow (§2.C). When true, the anchor
53
+ * Opt-in capture-phase ancestor-scroll follow. When true, the anchor
54
54
  * listens for `scroll` on `window` in the CAPTURE phase (scroll events don't
55
55
  * bubble, but reach `window` while capturing), so an ancestor scroll
56
56
  * container scrolling the target re-measures and re-publishes. With
@@ -60,7 +60,7 @@ export interface PlacementAnchorOptions {
60
60
  */
61
61
  followScroll?: boolean;
62
62
  /**
63
- * Opt-in windowed RAF geometry sentinel (§2.D/§6). Catches ancestor
63
+ * Opt-in windowed RAF geometry sentinel. Catches ancestor
64
64
  * transform / reflow moves that no DOM event reports. The sentinel is
65
65
  * NON-resident: it is OPENED on demand (a scroll burst, a `[role="separator"]`
66
66
  * splitter pointerdown, or an explicit `pulse()`), polls geometry once per
@@ -173,7 +173,7 @@ export function createPlacementAnchor(target, opts) {
173
173
  let io = null;
174
174
  let lastPublished = null;
175
175
  let disposed = false;
176
- // ── Windowed RAF geometry sentinel state (§2.D/§6) ──────────────────
176
+ // ── Windowed RAF geometry sentinel state ──────────────────
177
177
  // The sentinel is a windowed poll, opened on demand and auto-closing once
178
178
  // the geometry goes steady. It publishes IN-FRAME (no nested defer): each
179
179
  // frame measures, and either publishes a changed rect synchronously or
@@ -183,12 +183,12 @@ export function createPlacementAnchor(target, opts) {
183
183
  let steadyFrames = 0;
184
184
  const STEADY_CLOSE_FRAMES = 2;
185
185
  // True while a [role="separator"] splitter drag is in progress: set on the
186
- // capture-phase pointerdown that opened the window, cleared on pointerup
187
- // (§2.D). A held pointer means the drag may still resume after a static
188
- // pause, so a steady run while held must NOT close the sentinel — it only
189
- // closes once the pointer is released (松手后 2~3 帧内判静止→关窗). Without
190
- // this gate a press that pauses a couple of frames before the drag actually
191
- // moves would close mid-press and drop the entire subsequent drag.
186
+ // capture-phase pointerdown that opened the window, cleared on pointerup.
187
+ // A held pointer means the drag may still resume after a static pause, so a
188
+ // steady run while held must NOT close the sentinel — it only closes once the
189
+ // pointer is released. Without this gate a press that pauses a couple of
190
+ // frames before the drag actually moves would close mid-press and drop the
191
+ // entire subsequent drag.
192
192
  let pointerHeld = false;
193
193
  // Absolute time (performance.now()) past which a `pulse(durationMs)` window
194
194
  // force-closes even if the geometry is still changing — the upper bound that
@@ -227,7 +227,7 @@ export function createPlacementAnchor(target, opts) {
227
227
  sentinelDeadline = null;
228
228
  return;
229
229
  }
230
- // §2.F upper bound: a pulse window past its deadline closes regardless of
230
+ // Upper bound: a pulse window past its deadline closes regardless of
231
231
  // motion, so a target that changes every frame can't keep the sentinel alive.
232
232
  if (sentinelDeadline !== null && performance.now() >= sentinelDeadline) {
233
233
  sentinelDeadline = null;
@@ -236,7 +236,7 @@ export function createPlacementAnchor(target, opts) {
236
236
  const p = computePlacement();
237
237
  if (lastPublished && samePlacement(lastPublished, p)) {
238
238
  steadyFrames++;
239
- // Steady-close only fires once the pointer is RELEASED (§2.D): while a
239
+ // Steady-close only fires once the pointer is RELEASED: while a
240
240
  // splitter drag is held, a static pause is a hesitation, not the end of
241
241
  // the drag, so we keep polling (re-arm below) and let `steadyFrames`
242
242
  // accrue — it converges to a close within N frames after pointerup.
@@ -251,7 +251,7 @@ export function createPlacementAnchor(target, opts) {
251
251
  steadyFrames = 0;
252
252
  }
253
253
  // `publish` may have synchronously disposed (or hidden) the anchor; re-read
254
- // live state so a re-entrant teardown leaves ZERO scheduled frames (B2).
254
+ // live state so a re-entrant teardown leaves ZERO scheduled frames.
255
255
  if (!disposed && visible) {
256
256
  rafId = requestAnimationFrame(sentinelFrame); // keep polling
257
257
  }
@@ -275,18 +275,18 @@ export function createPlacementAnchor(target, opts) {
275
275
  sentinelDeadline = null;
276
276
  pointerHeld = false;
277
277
  };
278
- // §2.C — an ancestor scroll moved the target's screen rect. With the sentinel
279
- // on, open the window so the whole scroll burst is followed frame-by-frame;
280
- // without it, a single synchronous emit() follows the new rect (A1).
278
+ // An ancestor scroll moved the target's screen rect. With the sentinel on,
279
+ // open the window so the whole scroll burst is followed frame-by-frame;
280
+ // without it, a single synchronous emit() follows the new rect.
281
281
  const onScroll = () => {
282
282
  if (followGeometry)
283
283
  openSentinel();
284
284
  else
285
285
  emit();
286
286
  };
287
- // §2.D — a capture-phase pointerdown on a [role="separator"] splitter handle
288
- // marks the start of a drag that moves the target via ancestor reflow (no RO
289
- // tick) → open the sentinel.
287
+ // A capture-phase pointerdown on a [role="separator"] splitter handle marks
288
+ // the start of a drag that moves the target via ancestor reflow (no RO tick)
289
+ // → open the sentinel.
290
290
  const onPointerDown = (e) => {
291
291
  const t = e.target;
292
292
  if (t && t.closest && t.closest('[role="separator"]')) {
@@ -294,7 +294,7 @@ export function createPlacementAnchor(target, opts) {
294
294
  openSentinel();
295
295
  }
296
296
  };
297
- // §2.D — pointer released: the drag is over, so a steady run may now close the
297
+ // Pointer released: the drag is over, so a steady run may now close the
298
298
  // sentinel. Re-open it (a no-op if already polling) so the steady-close
299
299
  // threshold is reached even if the geometry was already static at release.
300
300
  const onPointerUp = () => {
@@ -378,7 +378,7 @@ export function createPlacementAnchor(target, opts) {
378
378
  stopObserving();
379
379
  },
380
380
  pulse(durationMs) {
381
- // Imperative window open (§2.F): start the animation-follow window. It
381
+ // Imperative window open: start the animation-follow window. It
382
382
  // closes on steady (N=2 unchanged frames) OR, when `durationMs` is given,
383
383
  // at that deadline — whichever comes first. The deadline is the upper bound
384
384
  // that guarantees a still-animating target cannot keep the sentinel
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dimina-kit/view-anchor",
3
- "version": "0.1.0-dev.20260618090552",
3
+ "version": "0.1.0-dev.20260624040247",
4
4
  "description": "Engine-agnostic primitive that keeps a main-process native view (Electron WebContentsView) aligned to a DOM element's geometry.",
5
5
  "keywords": [
6
6
  "dimina",
@@ -52,6 +52,7 @@
52
52
  "@testing-library/react": "^16.3.2",
53
53
  "@types/react": "^18.3.12",
54
54
  "@vitejs/plugin-react": "^6.0.1",
55
+ "@vitest/coverage-v8": "^4.1.4",
55
56
  "esbuild": "^0.28.0",
56
57
  "eslint": "^10.2.1",
57
58
  "jsdom": "^29.0.2",
@@ -71,6 +72,7 @@
71
72
  "check-types": "tsc --noEmit",
72
73
  "lint": "eslint . --max-warnings 0",
73
74
  "test": "vitest run",
74
- "test:dev": "vitest"
75
+ "test:dev": "vitest",
76
+ "test:coverage": "vitest run --coverage"
75
77
  }
76
78
  }
@@ -5,23 +5,20 @@ import type { Placement } from './types.js'
5
5
  // ── press-pause-drag: the windowed RAF geometry sentinel must NOT close
6
6
  // while the pointer is still held down ───────────────────────────────
7
7
  //
8
- // TDD — FAILING-FIRST. Locks the press-pause-drag behaviour of the
9
- // `followGeometry` sentinel (view-anchor-following.md §2.D / §6).
8
+ // Locks the press-pause-drag behaviour of the `followGeometry` sentinel.
10
9
  //
11
- // The contract (§2.D, line "一次 splitter 拖动 = 开窗→拖动中每帧跟随→
12
- // 松手后 2~3 帧内判静止→关窗") is explicit that the windowed sentinel only
13
- // closes on *steady* frames AFTER release (松手 = pointerup). The current
14
- // implementation instead closes after N consecutive identical frames
15
- // UNCONDITIONALLY so a press that pauses before the drag actually starts
16
- // (very common: user clicks the splitter, hesitates a frame or two, THEN
17
- // drags) closes the window mid-press, and the entire subsequent drag is
18
- // dropped (the native view freezes at its pre-drag position).
10
+ // The windowed sentinel only closes on *steady* frames AFTER release
11
+ // (pointerup), NOT after N consecutive identical frames unconditionally a
12
+ // press that pauses before the drag actually starts (very common: user clicks
13
+ // the splitter, hesitates a frame or two, THEN drags) must not close the
14
+ // window mid-press and drop the entire subsequent drag (freezing the native
15
+ // view at its pre-drag position).
19
16
  //
20
17
  // This file pins:
21
18
  // 1. press-pause-drag: pointerdown → ≥2 static frames (current close
22
19
  // threshold) → rect starts moving → the movement is STILL followed.
23
- // RED today because the sentinel closes after the static pause and the
24
- // drag frames find no scheduled rAF.
20
+ // The pointerHeld gate keeps the sentinel open through the static pause
21
+ // so the later drag frames still find a scheduled rAF.
25
22
  // 2. no-regression: pointerup → static frames → the sentinel still
26
23
  // eventually closes (we must not "fix" #1 by keeping the window open
27
24
  // forever / spinning a rAF while idle).
@@ -124,7 +121,7 @@ function buildElement(rect: { x: number; y: number; w: number; h: number }): {
124
121
  }
125
122
 
126
123
  /** A `[role="separator"]` splitter (the drag handle): a capture-phase
127
- * pointerdown matching it opens the sentinel window (§2.D). */
124
+ * pointerdown matching it opens the sentinel window. */
128
125
  function buildSplitter(): HTMLElement {
129
126
  const sep = document.createElement('div')
130
127
  sep.setAttribute('role', 'separator')
@@ -1,37 +1,35 @@
1
1
  import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
2
2
 
3
3
  // ─────────────────────────────────────────────────────────────────────
4
- // TDD — FAILING tests for the *not-yet-implemented* explicit `Placement`
5
- // API. The current package encodes "hidden" as the magic geometric value
6
- // `{x:0,y:0,width:0,height:0}` (the `present:false → ZERO bounds`
7
- // convention in types.ts/view-anchor.ts). That leaks a compositor/host
8
- // concept ("detach the view") into the geometry layer, and is ambiguous:
9
- // a view that is genuinely 0-wide is indistinguishable from a hidden one.
4
+ // Tests for the explicit `Placement` API, the alternative to encoding
5
+ // "hidden" as the magic geometric value `{x:0,y:0,width:0,height:0}` (the
6
+ // `present:false → ZERO bounds` convention in types.ts/view-anchor.ts). That
7
+ // ZERO convention leaks a compositor/host concept ("detach the view") into the
8
+ // geometry layer, and is ambiguous: a view that is genuinely 0-wide is
9
+ // indistinguishable from a hidden one.
10
10
  //
11
- // This file pins the REPLACEMENT contract:
11
+ // The replacement contract:
12
12
  //
13
13
  // type Placement =
14
14
  // | { visible: true; bounds: Bounds }
15
15
  // | { visible: false }
16
16
  //
17
17
  // measure produces a `Placement`; the sink consumes a `Placement`.
18
- // Visibility is EXPLICIT (a discriminant), never inferred from a 0.
18
+ // Visibility is EXPLICIT (a discriminant), never inferred from a 0. The
19
+ // explicit API NEVER reintroduces a ZERO-bounds path for "hidden".
19
20
  //
20
- // These tests must be RED today: the symbols they import/exercise
21
- // (`measurePlacement`, `createPlacementAnchor`, the `Placement` type) do
22
- // not exist yet. We import them dynamically so the failure surfaces as a
23
- // runtime/assertion failure (a missing export is `undefined`, and calling
24
- // it throws) rather than a whole-file compile error that vitest would skip.
25
- // The implementer makes them GREEN by adding the explicit API — WITHOUT
26
- // reintroducing any ZERO-bounds path for "hidden".
21
+ // The symbols (`measurePlacement`, `createPlacementAnchor`, `Placement`) are
22
+ // imported dynamically so a missing export surfaces as a runtime/assertion
23
+ // failure (an `undefined` that throws when called) rather than a whole-file
24
+ // compile error that vitest would skip.
27
25
  // ─────────────────────────────────────────────────────────────────────
28
26
 
29
27
  import type { Bounds } from './types.js'
30
28
 
31
- // The explicit API is imported through this indirection so that a not-yet-
32
- // existing export is observable as `undefined` at runtime (→ a failing
33
- // assertion / TypeError), not a static "module has no exported member"
34
- // compile error that would prevent the whole suite from running.
29
+ // The explicit API is imported through this indirection so that a missing
30
+ // export is observable as `undefined` at runtime (→ a failing assertion /
31
+ // TypeError), not a static "module has no exported member" compile error that
32
+ // would prevent the whole suite from running.
35
33
  import * as viewAnchorModule from './view-anchor.js'
36
34
 
37
35
  // The shape the explicit API must produce/consume. Mirrored locally (not
@@ -128,11 +126,10 @@ function buildElement(rect: {
128
126
  }
129
127
  }
130
128
 
131
- // ── Resolve the (not-yet-existing) explicit API at call time ─────────
129
+ // ── Resolve the explicit API at call time ─────────
132
130
  //
133
- // Each returns `undefined` until the implementer adds the export. The
134
- // tests below call them, so an absent export throws ("not a function") —
135
- // a RED behavioural failure, which is what TDD wants here.
131
+ // Each helper asserts the export is a function before calling it, so an absent
132
+ // export surfaces as a clear assertion failure rather than a TypeError.
136
133
 
137
134
  interface MaybeModule {
138
135
  // measurePlacement(target): Placement — pure measure, no IPC.
@@ -154,7 +151,7 @@ function measurePlacement(target: HTMLElement): ExpectedPlacement {
154
151
  const fn = mod.measurePlacement
155
152
  expect(
156
153
  typeof fn,
157
- 'explicit API `measurePlacement` is not implemented yet (expected a function export from view-anchor)',
154
+ 'explicit API `measurePlacement` must be a function export from view-anchor',
158
155
  ).toBe('function')
159
156
  return fn!(target)
160
157
  }
@@ -169,7 +166,7 @@ function createPlacementAnchor(
169
166
  const fn = mod.createPlacementAnchor
170
167
  expect(
171
168
  typeof fn,
172
- 'explicit API `createPlacementAnchor` is not implemented yet (expected a function export from view-anchor)',
169
+ 'explicit API `createPlacementAnchor` must be a function export from view-anchor',
173
170
  ).toBe('function')
174
171
  return fn!(target, opts)
175
172
  }
@@ -160,10 +160,10 @@ describe('useViewAnchor — ref attach', () => {
160
160
  // the leaf div conditionally rendered), failing to dispose leaks the RO
161
161
  // and keeps publishing against a detached element.
162
162
  //
163
- // P1 fix: this contract ALSO requires emitting one ZERO on detach. The
164
- // anchor's follower is a main-process WebContentsView; the host only
165
- // collapses it on `{0,0,0,0}`. Without a ZERO the native view stays frozen
166
- // at its last bounds and occludes content. See `react.ts`.
163
+ // This contract ALSO requires emitting one ZERO on detach. The anchor's
164
+ // follower is a main-process WebContentsView; the host only collapses it on
165
+ // `{0,0,0,0}`. Without a ZERO the native view stays frozen at its last bounds
166
+ // and occludes content. See `react.ts`.
167
167
 
168
168
  describe('useViewAnchor — ref null disposes', () => {
169
169
  it('detaching the DOM node publishes ZERO once, disconnects the observer, and stops publishing', () => {
@@ -195,11 +195,9 @@ describe('useViewAnchor — ref null disposes', () => {
195
195
  })
196
196
  expect(ro.disconnected).toBe(true)
197
197
 
198
- // NOTE: this assertion previously encoded the P1 bug it asserted
199
- // `expect(publish).not.toHaveBeenCalled()`, i.e. detach published nothing,
200
- // which left the native WebContentsView stranded at its old bounds. The
201
- // correct behaviour is: a vanished anchor MUST publish exactly one ZERO so
202
- // the host collapses the native view.
198
+ // A vanished anchor MUST publish exactly one ZERO so the host collapses the
199
+ // native view detach publishing nothing would strand the native
200
+ // WebContentsView at its old bounds.
203
201
  expect(publish).toHaveBeenCalledTimes(1)
204
202
  expect(publish).toHaveBeenCalledWith({ x: 0, y: 0, width: 0, height: 0 })
205
203
  publish.mockClear()
@@ -296,9 +294,9 @@ describe('useViewAnchor — opts/deps change re-publishes', () => {
296
294
  // Bug it catches: a hook that does not dispose on unmount leaks the RO and
297
295
  // can throw when a queued RAF fires against a torn-down IPC channel.
298
296
  //
299
- // P1 fix: unmount must ALSO collapse the native view with one ZERO (same
300
- // reasoning as Contract 9 — the follower is a main-process WebContentsView
301
- // the host only collapses on `{0,0,0,0}`).
297
+ // Unmount must ALSO collapse the native view with one ZERO (same reasoning as
298
+ // Contract 9 — the follower is a main-process WebContentsView the host only
299
+ // collapses on `{0,0,0,0}`).
302
300
 
303
301
  describe('useViewAnchor — unmount disposes', () => {
304
302
  it('unmounting the component publishes ZERO once, disconnects the observer, and never publishes after', () => {
@@ -318,10 +316,8 @@ describe('useViewAnchor — unmount disposes', () => {
318
316
 
319
317
  expect(ro.disconnected).toBe(true)
320
318
 
321
- // NOTE: this assertion previously encoded the P1 bug it asserted
322
- // `expect(publish).not.toHaveBeenCalled()` after unmount, leaving the
323
- // native view stranded. Correct behaviour: unmount publishes exactly one
324
- // ZERO to collapse the native view.
319
+ // Unmount publishes exactly one ZERO to collapse the native view
320
+ // publishing nothing would leave the native view stranded.
325
321
  expect(publish).toHaveBeenCalledTimes(1)
326
322
  expect(publish).toHaveBeenCalledWith({ x: 0, y: 0, width: 0, height: 0 })
327
323
  publish.mockClear()
@@ -380,7 +376,7 @@ describe('useViewAnchor — independent instances', () => {
380
376
  })
381
377
  })
382
378
 
383
- // ── Remount with present transition (codex regression) ──────────────
379
+ // ── Remount with present transition ──────────────
384
380
  // Production coupling: the debug cell is *unmounted* when hidden and *remounted*
385
381
  // when shown, so the element's mount/unmount and `options.present` flip together
386
382
  // (present=false ⟺ unmounted, present=true ⟺ mounted). On "show", React commits
package/src/react.ts CHANGED
@@ -28,7 +28,7 @@ export type ViewAnchorRef = (el: HTMLElement | null) => void
28
28
  * detach (`null`) → publish ZERO then `dispose()`; on `opts`/`deps` change →
29
29
  * `update`; on unmount → publish ZERO then `dispose`.
30
30
  *
31
- * Why ZERO on disappearance (P1 fix): the anchor's follower is a *main-process*
31
+ * Why ZERO on disappearance: the anchor's follower is a *main-process*
32
32
  * `WebContentsView`, not a DOM node. When the anchored element vanishes, core
33
33
  * `dispose()` only stops observing — it deliberately never publishes again
34
34
  * (its Contract 6/7). But the host only collapses the native view when it
@@ -499,12 +499,12 @@ void _boundsShape
499
499
 
500
500
  // ── display:none / first-frame guard (opt-in) ───────────────────────
501
501
  //
502
- // TDD — FAILING-FIRST tests for an ADDITIVE, OPT-IN hardening of
503
- // `createPlacementAnchor`, gated behind a new `guardDisplayNone?: boolean`
504
- // option (default false = today's behaviour, unchanged).
502
+ // An ADDITIVE, OPT-IN hardening of `createPlacementAnchor`, gated behind the
503
+ // `guardDisplayNone?: boolean` option (default false = the unguarded
504
+ // behaviour).
505
505
  //
506
- // Two new behaviours, ONLY when `guardDisplayNone: true`:
507
- // §4 First-frame guard — `visible:true` but the measured rect has
506
+ // Two behaviours, ONLY when `guardDisplayNone: true`:
507
+ // First-frame guard — `visible:true` but the measured rect has
508
508
  // `width === 0 || height === 0` (no geometry box: unmounted /
509
509
  // display:none / unstable first layout) → publish `{ visible:false }`
510
510
  // (a detach, NO bounds) instead of `{ visible:true, bounds:{...,0,0} }`.
@@ -512,27 +512,22 @@ void _boundsShape
512
512
  // the native view flashing at (0,0). NOTE: only ZERO WIDTH OR HEIGHT
513
513
  // triggers this — a NON-zero box at position (0,0) is still a normal
514
514
  // `{ visible:true }`.
515
- // §3/§2.E display:none guard — the anchor attaches an
516
- // `IntersectionObserver` on the target. When the target goes
517
- // display:none (IO reports `isIntersecting:false` with a zero-area
518
- // `boundingClientRect`) → publish `{ visible:false }`. When it comes
519
- // back with a non-zero box (IO intersecting again / a subsequent
520
- // measure yields non-zero width&height) → re-measure → publish
521
- // `{ visible:true, bounds }`.
515
+ // display:none guard — the anchor attaches an `IntersectionObserver` on the
516
+ // target. When the target goes display:none (IO reports
517
+ // `isIntersecting:false` with a zero-area `boundingClientRect`) → publish
518
+ // `{ visible:false }`. When it comes back with a non-zero box (IO
519
+ // intersecting again / a subsequent measure yields non-zero
520
+ // width&height) → re-measure → publish `{ visible:true, bounds }`.
522
521
  //
523
- // Caller intent still wins (§3.1): a caller-`visible:false` anchor is
524
- // detached regardless — the guard NEVER flips a caller's `visible:false`
525
- // to true, and NEVER detaches purely because a NON-zero box scrolled
526
- // off-screen (a non-zero box at a negative/off-screen origin stays
527
- // `visible:true` with its real, possibly negative-origin, bounds).
522
+ // Caller intent still wins: a caller-`visible:false` anchor is detached
523
+ // regardless — the guard NEVER flips a caller's `visible:false` to true, and
524
+ // NEVER detaches purely because a NON-zero box scrolled off-screen (a non-zero
525
+ // box at a negative/off-screen origin stays `visible:true` with its real,
526
+ // possibly negative-origin, bounds).
528
527
  //
529
- // This increment adds an IntersectionObserver, NOT a RAF — so the existing
530
- // "never schedules a requestAnimationFrame" guard must stay valid. We assert
531
- // it here too (`rafSpy`/`cancelSpy` from the shared harness).
532
- //
533
- // These tests must be RED today: `guardDisplayNone` is not implemented, so
534
- // no IntersectionObserver is wired and a 0×0 visible measure still publishes
535
- // `{ visible:true, bounds:0×0 }`.
528
+ // This guard adds an IntersectionObserver, NOT a RAF — so the existing
529
+ // "never schedules a requestAnimationFrame" guard stays valid. We assert it
530
+ // here too (`rafSpy`/`cancelSpy` from the shared harness).
536
531
 
537
532
  // A controllable fake IntersectionObserver (jsdom has none). Mirrors the
538
533
  // FakeResizeObserver style: records observed elements + disconnect, captures
@@ -762,33 +757,26 @@ describe('createPlacementAnchor — display:none / first-frame guard (opt-in)',
762
757
  })
763
758
  })
764
759
 
765
- // ── scroll + windowed RAF geometry sentinel (opt-in, increment 2) ────
760
+ // ── scroll + windowed RAF geometry sentinel (opt-in) ────
766
761
  //
767
- // TDD — FAILING-FIRST tests for ADDITIVE, OPT-IN follow options on
768
- // `createPlacementAnchor`, derived from view-anchor-following.md
769
- // §2.C / §2.D / §2.F / §6 / §7. Both default OFF (= today's behaviour).
762
+ // ADDITIVE, OPT-IN follow options on `createPlacementAnchor`. Both default
763
+ // OFF (= the unfollowed behaviour).
770
764
  //
771
- // followScroll?: boolean — §2.C ancestor-scroll capture listener.
772
- // followGeometry?: boolean — §2.D windowed RAF geometry sentinel.
773
- // pulse(durationMs?) — §2.F imperative "open the sentinel window".
765
+ // followScroll?: boolean — ancestor-scroll capture listener.
766
+ // followGeometry?: boolean — windowed RAF geometry sentinel.
767
+ // pulse(durationMs?) — imperative "open the sentinel window".
774
768
  //
775
769
  // ────────────────────────────────────────────────────────────────────
776
- // GOALPOST NOTE (§7): the package-wide "never RAF" invariant is
777
- // INTENTIONALLY NARROWED by `followGeometry` (opt-in) to "EVENT-driven
778
- // publishes (ResizeObserver / window resize / scroll) are never
779
- // RAF-deferred". The sentinel rAF is a POLLING mechanism that publishes
780
- // SYNCHRONOUSLY within its own frame it is NOT a deferral of an
781
- // event-driven publish (see view-anchor-following.md §7). The existing
782
- // `createViewAnchor` "never schedules a requestAnimationFrame" test
783
- // (above) REMAINS VALID and is left UNCHANGED, because `followGeometry`
784
- // defaults off and the forward `createViewAnchor` core never opts in. The
785
- // tests below re-pin that the DEFAULT / event-driven paths still schedule
786
- // NO rAF, so the original guarantee holds wherever the sentinel is off.
770
+ // The package-wide "never RAF" invariant is INTENTIONALLY NARROWED by
771
+ // `followGeometry` (opt-in) to "EVENT-driven publishes (ResizeObserver /
772
+ // window resize / scroll) are never RAF-deferred". The sentinel rAF is a
773
+ // POLLING mechanism that publishes SYNCHRONOUSLY within its own frame — it is
774
+ // NOT a deferral of an event-driven publish. The `createViewAnchor` "never
775
+ // schedules a requestAnimationFrame" test (above) stays valid, because
776
+ // `followGeometry` defaults off and the forward `createViewAnchor` core never
777
+ // opts in. The tests below re-pin that the DEFAULT / event-driven paths still
778
+ // schedule NO rAF, so the guarantee holds wherever the sentinel is off.
787
779
  // ────────────────────────────────────────────────────────────────────
788
- //
789
- // These tests must be RED today: `followScroll` / `followGeometry` /
790
- // `pulse` are unimplemented, so no capture scroll listener / pointerdown
791
- // listener / RAF sentinel exists.
792
780
 
793
781
  // A CONTROLLABLE fake requestAnimationFrame. The shared `rafSpy` only
794
782
  // returns 0 and stores nothing (it's a regression guard, not a driver), so
@@ -842,7 +830,7 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
842
830
 
843
831
  /** Build a `[role="separator"]` splitter element (the drag handle). A
844
832
  * capture-phase pointerdown whose target matches `[role="separator"]`
845
- * opens the sentinel window (§2.D). */
833
+ * opens the sentinel window. */
846
834
  function buildSplitter(): HTMLElement {
847
835
  const sep = document.createElement('div')
848
836
  sep.setAttribute('role', 'separator')
@@ -851,7 +839,7 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
851
839
  }
852
840
 
853
841
  /** Dispatch a capture-phase scroll on window (an ancestor scroll container
854
- * scrolling — §2.C: scroll doesn't bubble, but reaches window in capture). */
842
+ * scrolling — scroll doesn't bubble, but reaches window in capture). */
855
843
  function dispatchCaptureScroll(): void {
856
844
  window.dispatchEvent(new Event('scroll'))
857
845
  }
@@ -864,8 +852,8 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
864
852
  }
865
853
 
866
854
  /** Pointer release — ends a splitter drag so the sentinel may steady-close
867
- * (§2.D: 关窗只发生在松手之后). Dispatched bubbling from the target and on
868
- * window directly so a capture/bubble window listener sees it. */
855
+ * (close only happens after release). Dispatched bubbling from the target
856
+ * and on window directly so a capture/bubble window listener sees it. */
869
857
  function dispatchPointerup(target: HTMLElement): void {
870
858
  target.dispatchEvent(new Event('pointerup', { bubbles: true }))
871
859
  window.dispatchEvent(new Event('pointerup'))
@@ -888,12 +876,11 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
888
876
  ): PulseHandle =>
889
877
  createPlacementAnchor(el, o as FollowOpts) as PulseHandle
890
878
 
891
- // ── A. followScroll — capture-phase ancestor scroll (§2.C) ──────────
879
+ // ── A. followScroll — capture-phase ancestor scroll ──────────
892
880
 
893
881
  // A1: capture scroll re-publishes the freshly-measured rect (basic scroll
894
- // follow works WITHOUT the geometry sentinel — §2.C: "if followGeometry
895
- // is false, the scroll callback should still at least do a synchronous
896
- // emit()").
882
+ // follow works WITHOUT the geometry sentinel — with followGeometry off,
883
+ // the scroll callback still does a single synchronous emit()).
897
884
  it('A1) followScroll (followGeometry off): a capture-phase scroll re-publishes the new measured rect synchronously', () => {
898
885
  const publish = vi.fn<(p: Placement) => void>()
899
886
  const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
@@ -960,8 +947,8 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
960
947
  removeSpy.mockRestore()
961
948
  })
962
949
 
963
- // A3: §2.C — a followScroll capture-scroll OPENS the RAF sentinel window
964
- // when followGeometry is ALSO on (so it follows every frame of a scroll
950
+ // A3: a followScroll capture-scroll OPENS the RAF sentinel window when
951
+ // followGeometry is ALSO on (so it follows every frame of a scroll
965
952
  // burst, not just the one synchronous emit).
966
953
  it('A3) followScroll + followGeometry: a capture scroll opens the RAF sentinel (a frame becomes scheduled)', () => {
967
954
  const publish = vi.fn<(p: Placement) => void>()
@@ -982,9 +969,9 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
982
969
  expect(raf.pending).toBeGreaterThanOrEqual(1)
983
970
  })
984
971
 
985
- // ── B. followGeometry — windowed RAF geometry sentinel (§2.D/§6) ─────
972
+ // ── B. followGeometry — windowed RAF geometry sentinel ─────
986
973
 
987
- // B-idle: §6 — IDLE (no scroll / pointerdown / pulse) schedules NO rAF.
974
+ // B-idle: IDLE (no scroll / pointerdown / pulse) schedules NO rAF.
988
975
  // The sentinel is windowed: static cost is exactly zero.
989
976
  it('B-idle) followGeometry on but idle: NO rAF is ever scheduled (windowed = zero static cost)', () => {
990
977
  const publish = vi.fn<(p: Placement) => void>()
@@ -1064,10 +1051,10 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
1064
1051
  })
1065
1052
 
1066
1053
  // B-close: after the pointer is RELEASED, N=2 consecutive UNCHANGED frames
1067
- // cancel the rAF (steady = stop) — no further frame scheduled. (§2.D: a
1068
- // steady run while the pointer is still HELD is a mid-drag pause and must
1069
- // NOT close — see follow-geometry-press-drag.fix.test.ts; close is gated on
1070
- // pointerup, so this test now releases before going steady.)
1054
+ // cancel the rAF (steady = stop) — no further frame scheduled. A steady run
1055
+ // while the pointer is still HELD is a mid-drag pause and must NOT close
1056
+ // (see follow-geometry-press-drag.fix.test.ts); close is gated on pointerup,
1057
+ // so this test releases before going steady.
1071
1058
  it('B-close) pointerup then N=2 consecutive unchanged frames → sentinel stops (cancelAnimationFrame / no further frame scheduled)', () => {
1072
1059
  const publish = vi.fn<(p: Placement) => void>()
1073
1060
  const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
@@ -1095,7 +1082,7 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
1095
1082
  expect(raf.request.mock.calls.length).toBe(requestsBefore)
1096
1083
  })
1097
1084
 
1098
- // ── F. pulse() — explicit window open (§2.F) ────────────────────────
1085
+ // ── F. pulse() — explicit window open ────────────────────────
1099
1086
 
1100
1087
  // F-open: pulse() opens the sentinel and it follows subsequent rect changes.
1101
1088
  it('F-open) pulse() opens the sentinel; it follows rect changes on subsequent frames', () => {
@@ -1137,13 +1124,13 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
1137
1124
  expect(raf.request.mock.calls.length).toBe(requestsBefore)
1138
1125
  })
1139
1126
 
1140
- // ── §7 goalpost: event-driven publishes stay SYNCHRONOUS even with the
1141
- // sentinel enabled; the sentinel rAF publishes in-frame, not deferred. ──
1127
+ // ── Event-driven publishes stay SYNCHRONOUS even with the sentinel enabled;
1128
+ // the sentinel rAF publishes in-frame, not deferred. ──
1142
1129
 
1143
1130
  // B-sync: a ResizeObserver tick still publishes SYNCHRONOUSLY (in the event
1144
1131
  // stack) WITHOUT scheduling a rAF, even with followGeometry enabled. This
1145
- // is the §7 narrowing made concrete: event-driven ≠ RAF-deferred.
1146
- it('§7) with followGeometry ENABLED, a ResizeObserver tick publishes synchronously and schedules NO rAF', () => {
1132
+ // is the narrowing made concrete: event-driven ≠ RAF-deferred.
1133
+ it('with followGeometry ENABLED, a ResizeObserver tick publishes synchronously and schedules NO rAF', () => {
1147
1134
  const publish = vi.fn<(p: Placement) => void>()
1148
1135
  const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
1149
1136
  mk(el, { visible: true, followGeometry: true, publish })
@@ -1158,7 +1145,7 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
1158
1145
  visible: true,
1159
1146
  bounds: { x: 3, y: 4, width: 100, height: 100 },
1160
1147
  })
1161
- // …and the event-driven path did NOT route through a rAF (the §7 invariant
1148
+ // …and the event-driven path did NOT route through a rAF (the invariant
1162
1149
  // survives, narrowed: event-driven publishes are never RAF-deferred).
1163
1150
  expect(raf.request).not.toHaveBeenCalled()
1164
1151
  })
@@ -1166,7 +1153,7 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
1166
1153
  // Default-path "still no rAF": with followGeometry OFF / unset, NOTHING in
1167
1154
  // the increment-2 surface schedules a rAF — the original package-wide
1168
1155
  // "never RAF" guarantee holds for the default + event-driven paths.
1169
- it('§7-default) followGeometry OFF (default): RO + resize + scroll-less lifecycle schedules NO rAF', () => {
1156
+ it('followGeometry OFF (default): RO + resize + scroll-less lifecycle schedules NO rAF', () => {
1170
1157
  const publish = vi.fn<(p: Placement) => void>()
1171
1158
  const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
1172
1159
  const handle = mk(el, { visible: true, publish }) // followGeometry unset
@@ -166,7 +166,7 @@ export interface PlacementAnchorOptions {
166
166
  */
167
167
  guardDisplayNone?: boolean
168
168
  /**
169
- * Opt-in capture-phase ancestor-scroll follow (§2.C). When true, the anchor
169
+ * Opt-in capture-phase ancestor-scroll follow. When true, the anchor
170
170
  * listens for `scroll` on `window` in the CAPTURE phase (scroll events don't
171
171
  * bubble, but reach `window` while capturing), so an ancestor scroll
172
172
  * container scrolling the target re-measures and re-publishes. With
@@ -176,7 +176,7 @@ export interface PlacementAnchorOptions {
176
176
  */
177
177
  followScroll?: boolean
178
178
  /**
179
- * Opt-in windowed RAF geometry sentinel (§2.D/§6). Catches ancestor
179
+ * Opt-in windowed RAF geometry sentinel. Catches ancestor
180
180
  * transform / reflow moves that no DOM event reports. The sentinel is
181
181
  * NON-resident: it is OPENED on demand (a scroll burst, a `[role="separator"]`
182
182
  * splitter pointerdown, or an explicit `pulse()`), polls geometry once per
@@ -262,7 +262,7 @@ export function createPlacementAnchor(
262
262
  let lastPublished: Placement | null = null
263
263
  let disposed = false
264
264
 
265
- // ── Windowed RAF geometry sentinel state (§2.D/§6) ──────────────────
265
+ // ── Windowed RAF geometry sentinel state ──────────────────
266
266
  // The sentinel is a windowed poll, opened on demand and auto-closing once
267
267
  // the geometry goes steady. It publishes IN-FRAME (no nested defer): each
268
268
  // frame measures, and either publishes a changed rect synchronously or
@@ -272,12 +272,12 @@ export function createPlacementAnchor(
272
272
  let steadyFrames = 0
273
273
  const STEADY_CLOSE_FRAMES = 2
274
274
  // True while a [role="separator"] splitter drag is in progress: set on the
275
- // capture-phase pointerdown that opened the window, cleared on pointerup
276
- // (§2.D). A held pointer means the drag may still resume after a static
277
- // pause, so a steady run while held must NOT close the sentinel — it only
278
- // closes once the pointer is released (松手后 2~3 帧内判静止→关窗). Without
279
- // this gate a press that pauses a couple of frames before the drag actually
280
- // moves would close mid-press and drop the entire subsequent drag.
275
+ // capture-phase pointerdown that opened the window, cleared on pointerup.
276
+ // A held pointer means the drag may still resume after a static pause, so a
277
+ // steady run while held must NOT close the sentinel — it only closes once the
278
+ // pointer is released. Without this gate a press that pauses a couple of
279
+ // frames before the drag actually moves would close mid-press and drop the
280
+ // entire subsequent drag.
281
281
  let pointerHeld = false
282
282
  // Absolute time (performance.now()) past which a `pulse(durationMs)` window
283
283
  // force-closes even if the geometry is still changing — the upper bound that
@@ -319,7 +319,7 @@ export function createPlacementAnchor(
319
319
  sentinelDeadline = null
320
320
  return
321
321
  }
322
- // §2.F upper bound: a pulse window past its deadline closes regardless of
322
+ // Upper bound: a pulse window past its deadline closes regardless of
323
323
  // motion, so a target that changes every frame can't keep the sentinel alive.
324
324
  if (sentinelDeadline !== null && performance.now() >= sentinelDeadline) {
325
325
  sentinelDeadline = null
@@ -328,7 +328,7 @@ export function createPlacementAnchor(
328
328
  const p = computePlacement()
329
329
  if (lastPublished && samePlacement(lastPublished, p)) {
330
330
  steadyFrames++
331
- // Steady-close only fires once the pointer is RELEASED (§2.D): while a
331
+ // Steady-close only fires once the pointer is RELEASED: while a
332
332
  // splitter drag is held, a static pause is a hesitation, not the end of
333
333
  // the drag, so we keep polling (re-arm below) and let `steadyFrames`
334
334
  // accrue — it converges to a close within N frames after pointerup.
@@ -342,7 +342,7 @@ export function createPlacementAnchor(
342
342
  steadyFrames = 0
343
343
  }
344
344
  // `publish` may have synchronously disposed (or hidden) the anchor; re-read
345
- // live state so a re-entrant teardown leaves ZERO scheduled frames (B2).
345
+ // live state so a re-entrant teardown leaves ZERO scheduled frames.
346
346
  if (!disposed && visible) {
347
347
  rafId = requestAnimationFrame(sentinelFrame) // keep polling
348
348
  } else {
@@ -366,17 +366,17 @@ export function createPlacementAnchor(
366
366
  pointerHeld = false
367
367
  }
368
368
 
369
- // §2.C — an ancestor scroll moved the target's screen rect. With the sentinel
370
- // on, open the window so the whole scroll burst is followed frame-by-frame;
371
- // without it, a single synchronous emit() follows the new rect (A1).
369
+ // An ancestor scroll moved the target's screen rect. With the sentinel on,
370
+ // open the window so the whole scroll burst is followed frame-by-frame;
371
+ // without it, a single synchronous emit() follows the new rect.
372
372
  const onScroll = (): void => {
373
373
  if (followGeometry) openSentinel()
374
374
  else emit()
375
375
  }
376
376
 
377
- // §2.D — a capture-phase pointerdown on a [role="separator"] splitter handle
378
- // marks the start of a drag that moves the target via ancestor reflow (no RO
379
- // tick) → open the sentinel.
377
+ // A capture-phase pointerdown on a [role="separator"] splitter handle marks
378
+ // the start of a drag that moves the target via ancestor reflow (no RO tick)
379
+ // → open the sentinel.
380
380
  const onPointerDown = (e: Event): void => {
381
381
  const t = e.target as Element | null
382
382
  if (t && t.closest && t.closest('[role="separator"]')) {
@@ -385,7 +385,7 @@ export function createPlacementAnchor(
385
385
  }
386
386
  }
387
387
 
388
- // §2.D — pointer released: the drag is over, so a steady run may now close the
388
+ // Pointer released: the drag is over, so a steady run may now close the
389
389
  // sentinel. Re-open it (a no-op if already polling) so the steady-close
390
390
  // threshold is reached even if the geometry was already static at release.
391
391
  const onPointerUp = (): void => {
@@ -469,7 +469,7 @@ export function createPlacementAnchor(
469
469
  stopObserving()
470
470
  },
471
471
  pulse(durationMs?: number): void {
472
- // Imperative window open (§2.F): start the animation-follow window. It
472
+ // Imperative window open: start the animation-follow window. It
473
473
  // closes on steady (N=2 unchanged frames) OR, when `durationMs` is given,
474
474
  // at that deadline — whichever comes first. The deadline is the upper bound
475
475
  // that guarantees a still-animating target cannot keep the sentinel