@dimina-kit/view-anchor 0.1.0-dev.20260616102751 → 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/README.md CHANGED
@@ -66,22 +66,26 @@ handle.update(publish) // 换 publish(IPC 通道),并立即把当前尺寸
66
66
  handle.dispose() // 停止观察;此后不再上报
67
67
  ```
68
68
 
69
- 它从 `ResizeObserver` 的 border-box 取主导轴尺寸(不调 `getBoundingClientRect`、不强制 reflow),`Math.round` + 钳零,复用与正向同一套 RAF 合并 + 去重。宿主收到尺寸后调整占位、再由正向把视图贴上去——两个单向原语经占位 div 串成一座双向桥。**注意 footgun**:`target` 必须在主导轴上 shrink-to-fit(其尺寸不能被宿主灌入的视图尺寸反向决定),否则跨进程环不收敛。详见 [`docs/bidirectional-design.md`](./docs/bidirectional-design.md)。
69
+ 它从 `ResizeObserver` 的 border-box 取主导轴尺寸(不调 `getBoundingClientRect`、不强制 reflow),`Math.round` + 钳零,经反向专用的 RAF 合并 + 去重发出(正向同步、反向 RAF,刻意不对称)。宿主收到尺寸后调整占位、再由正向把视图贴上去——两个单向原语经占位 div 串成一座双向桥。**注意 footgun**:`target` 必须在主导轴上 shrink-to-fit(其尺寸不能被宿主灌入的视图尺寸反向决定),否则跨进程环不收敛。详见 [`docs/bidirectional-design.md`](./docs/bidirectional-design.md)。
70
70
 
71
71
  ## 文档
72
72
 
73
- - [`docs/mechanism.mdx`](./docs/mechanism.mdx) —— 正向完整机制:RAF 与陈旧帧安全性、`present` / 零矩形 / 卸载契约、React 18 StrictMode 生命周期。内嵌可交互 3D 演示 [`docs/anchor-3d.html`](./docs/anchor-3d.html)。
74
- - [`docs/bidirectional-design.md`](./docs/bidirectional-design.md) —— 双向化设计:共享 `createMeasureLoop` 核心、单轴所有权与收敛性、信任边界、以及两个原语如何「锚」到一起。
73
+ - [`docs/mechanism.mdx`](./docs/mechanism.mdx) —— 正向完整机制:同步发布与陈旧帧安全性、`present` / 零矩形 / 卸载契约、React 18 StrictMode 生命周期。内嵌可交互 3D 演示 [`docs/anchor-3d.html`](./docs/anchor-3d.html)。
74
+ - [`docs/bidirectional-design.md`](./docs/bidirectional-design.md) —— 双向几何桥:正向同步 / 反向 RAF 的刻意不对称、单轴所有权与收敛性、信任边界、以及两个原语如何「锚」到一起。
75
75
 
76
76
  ## API
77
77
 
78
78
  | 导出 | 类型 | 作用 |
79
79
  |---|---|---|
80
- | `createViewAnchor(target, opts)` | 函数 | 正向命令式核心,返回 `{ update, dispose }`。不依赖 React、不依赖 Electron。 |
81
- | `useViewAnchor(opts)` | Hook | 正向 React 适配层,返回一个挂到占位元素上的 ref 回调。 |
80
+ | `createViewAnchor(target, opts)` | 函数 | 正向命令式核心,返回 `{ update, dispose }`。`present:false` 用零矩形 `{0,0,0,0}` 表示收起。不依赖 React、不依赖 Electron。 |
81
+ | `createPlacementAnchor(target, opts)` | 函数 | 正向核心的显式 `Placement` 变体:可见性是判别式 `{ visible:true, bounds }` / `{ visible:false }`,绝不从零尺寸推断——真正 0×0 但在屏的视图与隐藏视图就此可区分。另支持 opt-in 的 `guardDisplayNone` / `followScroll` / `followGeometry`(按需开窗的 RAF 几何哨兵)+ `pulse()`。 |
82
+ | `measurePlacement(target)` | 函数 | 纯测量:读 `target` 矩形,包成 `{ visible:true, bounds }`。 |
83
+ | `useViewAnchor(opts)` | Hook | 正向 React 适配层(基于 `createViewAnchor`),返回一个挂到占位元素上的 ref 回调。 |
82
84
  | `createSizeAdvertiser(target, opts)` | 函数 | 反向命令式核心,返回 `{ update, dispose }`。下游量内容尺寸回流给宿主。 |
83
85
  | `Bounds` | 类型 | `{ x, y, width, height }`,单位为 CSS 像素。 |
84
- | `ViewAnchorOptions` / `ViewAnchorHandle` | 类型 | 正向核心的选项与句柄形状。 |
86
+ | `Placement` | 类型 | `{ visible:true; bounds:Bounds } \| { visible:false }`,显式可见性判别式。 |
87
+ | `ViewAnchorOptions` / `ViewAnchorHandle` | 类型 | 正向零矩形核心的选项与句柄形状。 |
88
+ | `PlacementAnchorOptions` / `PlacementAnchorHandle` | 类型 | 正向 `Placement` 核心的选项与句柄形状。 |
85
89
  | `UseViewAnchorOptions` / `ViewAnchorRef` | 类型 | 正向适配层的选项与 ref 回调形状。 |
86
90
  | `AdvertisedAxis` / `AdvertisedSize` | 类型 | 反向的轴(`'block'\|'inline'`)与帧载荷 `{ axis, extent }`。 |
87
91
  | `SizeAdvertiserOptions` / `SizeAdvertiserHandle` | 类型 | 反向核心的选项与句柄形状。 |
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
@@ -1,27 +1,21 @@
1
- # view-anchor 双向化重构设计(提案)
1
+ # view-anchor 双向几何桥
2
2
 
3
- > 状态:契约草案,经四轮对抗评审收敛。本页只定契约,不含实现。
3
+ view-anchor 是一座**双向几何桥**,引擎无关、传输注入:
4
4
 
5
- ## 1. 目标
5
+ - **正向** `createViewAnchor(target, { present, publish })` —— 宿主渲染进程量 DOM 占位矩形 → `publish(bounds)` → IPC → 主进程 `WebContentsView.setBounds`。
6
+ - **反向** `createSizeAdvertiser(target, { axis, publish })` —— 下游 WebContentsView 自己的渲染进程量自身内容尺寸 → `publish(size)` → IPC → 宿主,宿主据此调整占位尺寸,再经正向把视图贴上去。
6
7
 
7
- view-anchor 从「单向锚定」重构成「**双向几何桥**」,且保持引擎无关、传输注入:
8
+ 适用场景:toolbar 这类「**交给下游控制**」的 WebContentsView,尺寸的事实来源在下游一侧(典型:宽由宿主主导、高由下游内容主导),占位映射是动态的。
8
9
 
9
- - **正向(已有)** `createViewAnchor(target, { present, publish })` —— 宿主渲染进程量 DOM 占位矩形 → `publish(bounds)` → IPC → 主进程 `WebContentsView.setBounds`。
10
- - **反向(新增)** `createSizeAdvertiser(target, { axis, publish })` —— 下游 WebContentsView 自己的渲染进程量自身内容尺寸 → `publish(size)` → IPC → 宿主,宿主据此调整占位尺寸,再经正向把视图贴上去。
10
+ 范围之外:不是同层渲染(合成器层面、另一套机制);包里不提供 React 反向适配、不提供宿主决策 `decide`(见 §4/§7)。
11
11
 
12
- 动机:workbench toolbar 改成「**交给下游控制**」的 WebContentsView,尺寸的事实来源从宿主翻到了下游一侧(典型:宽宿主主导、高下游内容主导),占位映射变成动态的。
12
+ ## 2. 正反向不共享发射核心——两个方向最优时机不同
13
13
 
14
- 非目标:不是同层渲染(合成器层面、另一套机制);不在包里提供 React 反向适配、不在包里提供宿主决策 `decide`(见 §4/§7)。
14
+ 正反向的最优发射时机本就不同,所以它们**不共享发射核心**,采取**正向同步、反向 RAF** 的刻意不对称。
15
15
 
16
- ## 2. 正反向**不共享发射核心**——两个方向最优时机不同
16
+ **正向 `createViewAnchor` = 同步发布,不走 RAF。** 原生 overlay 的 `setBounds` 是跨进程、本就晚约 1 合成帧;RAF 再叠一帧 → 拖拽可见拖尾。所以正向在每个 `ResizeObserver` / window `resize` 触发里**同步**测量+发布,抗洪由 `lastPublished` 同值去重承担。撤销安全靠「每次发布开头同步读 `disposed`/`present`」——没有排队帧可跑赢状态变化。正向的 sink 是 IPC→主进程 `setBounds`,**不碰本渲染进程 DOM**,所以同步发布不会形成 RO 重入循环。
17
17
 
18
- > 设计初稿曾想抽一个共享 `createMeasureLoop` 给两个方向。落地时与 refactor-simulator
19
- > 的生产正向合并,发现这是**错的**:正反向的最优发射时机本就不同,强行共享会让核心
20
- > 长出 by-direction flag。最终采取**正向同步、反向 RAF** 的刻意不对称。
21
-
22
- **正向 `createViewAnchor` = 同步发布,不走 RAF。** 原生 overlay 的 `setBounds` 是跨进程、本就晚约 1 合成帧;RAF 再叠一帧 → 拖拽可见拖尾(refactor-simulator 生产验证过的修复)。所以正向在每个 `ResizeObserver` / window `resize` 触发里**同步**测量+发布,抗洪由 `lastPublished` 同值去重承担。撤销安全靠「每次发布开头同步读 `disposed`/`present`」——没有排队帧可跑赢状态变化。正向的 sink 是 IPC→主进程 `setBounds`,**不碰本渲染进程 DOM**,所以同步发布不会形成 RO 重入循环。
23
-
24
- **反向 `createSizeAdvertiser` = RAF 合并(内部 `createMeasureLoop`,反向专用)。** 反向是一条**跨进程反馈环**:advertise → 宿主 resize 这块视图 → 下游内容 remeasure → 再 advertise。RAF 的「每帧≤1 次发布」对这条环是合理的阻尼。`createMeasureLoop<T>` 只服务反向,通过注入 `produce`/`same`/`sink` 三元组把方向细节关在外层,核心零 flag:
18
+ **反向 `createSizeAdvertiser` = RAF 合并(内部 `createMeasureLoop`,反向专用)。** 反向是一条**跨进程反馈环**:advertise → 宿主 resize 这块视图 → 下游内容 remeasure → 再 advertise。RAF 的「每帧≤1 次发布」对这条环是合理的阻尼。`createMeasureLoop<T>` 只服务反向(正向不用它),通过注入 `produce`/`same`/`sink` 三元组把方向细节关在外层,核心零 flag:
25
19
 
26
20
  ```ts
27
21
  function createMeasureLoop<T>(cfg: {
@@ -31,7 +25,7 @@ function createMeasureLoop<T>(cfg: {
31
25
  }): { schedule, emitNow, setActive, cancel, dispose }
32
26
  ```
33
27
 
34
- **为什么不对称是对的**:正向是单向跟随者,少一帧直接消除可见拖尾;反向是反馈环,多一帧阻尼更稳。把两者塞进一个核心要么逼出 `if(direction)` flag,要么把正向重新拖回 RAF(复活拖尾)。所以**正向内联同步、反向用 RAF 引擎**,各取所需。
28
+ **为什么不对称是对的**:正向是单向跟随者,少一帧直接消除可见拖尾;反向是反馈环,多一帧阻尼更稳。把两者塞进一个核心要么逼出 `if(direction)` flag,要么把正向也拖进 RAF(带回拖尾)。所以**正向内联同步、反向用 RAF 引擎**,各取所需。
35
29
 
36
30
  ## 3. 反向原语契约
37
31
 
@@ -50,7 +44,7 @@ export interface SizeAdvertiserOptions {
50
44
  }
51
45
 
52
46
  export interface SizeAdvertiserHandle {
53
- update(opts: SizeAdvertiserOptions): void // 只能换 publish(换 IPC 通道);axis 不可变
47
+ update(publish: (size: AdvertisedSize) => void): void // 只能换 publish(换 IPC 通道);axis 不可变
54
48
  dispose(): void // 停 observe、取消 RAF,此后永不再 publish
55
49
  }
56
50
 
@@ -84,7 +78,7 @@ export function createSizeAdvertiser(target: HTMLElement, opts: SizeAdvertiserOp
84
78
  | 白名单轴 | 宿主 | 用 payload 的 `axis` 常量比对,`axis !== expected` 则丢弃 |
85
79
  | 位置 / 另一轴 / z-order 锁定 | 宿主 | 下游无任何途径改变 → 杜绝全窗覆盖类点击劫持 |
86
80
 
87
- **target 选错(反馈环)的防护——克制为主,不过度防御。** 关键事实:违反单轴所有权时,RAF 合并把「死循环」**封顶为每帧一次**——它退化成可见的抖动 / 每帧一次冗余 IPC,而**不是冻死 UI**。既然不是灾难性故障,就不值得为它在每帧路径上堆检测器。最终只保留:
81
+ **target 选错(反馈环)的防护——克制为主,不过度防御。** 关键事实:违反单轴所有权时,RAF 合并把「死循环」**封顶为每帧一次**——它退化成可见的抖动 / 每帧一次冗余 IPC,而**不是冻死 UI**。既然不是灾难性故障,就不在每帧路径上堆检测器。只保留:
88
82
 
89
83
  - **一条构造期廉价守卫**:`target` 是 `<body>`/`<html>` 时 `console.warn`——这是「stable-but-wrong」(占位永远撑成视图高、不缩到内容)最常见的成因,一次性、零每帧成本。
90
84
  - **其余靠文档**:JSDoc 与本节把「target 必须主导轴 shrink-to-fit、不被宿主灌入的尺寸反向决定」讲清。
@@ -137,15 +131,10 @@ export function createSizeAdvertiser(target: HTMLElement, opts: SizeAdvertiserOp
137
131
 
138
132
  ## 7. 与 Electron preferred-size 的关系(宿主侧可选数据源,非替代)
139
133
 
140
- 宿主若在 Electron 且能接受零下游代码,可用 `enablePreferredSizeMode` + `preferred-size-changed` 作为反向「**源**」喂给同一个 `decide`,省掉下游注入。但它是 Electron 私有、且有两点待 spike 坐实:① 在 `WebContentsView` 上触发且上报值吸收 zoom;② `setBounds.height` 不回灌 layout(否则正反馈震荡)。
141
-
142
- view-anchor 的反向原语是**引擎无关/可移植**那条路(非 Electron / iframe 宿主、需选子 target 的场景)。两者并存:preferred-size 是 Electron 捷径,advertiser 是可移植机制,`decide` + §5 安全红线是二者共用的公共底座。这也是反向逻辑该留在 view-anchor、而非写死成 Electron 事件的理由。spike 结果只决定宿主选哪个**源**,不改变 view-anchor 要不要建反向原语。
143
-
144
- ## 8. 包定位守恒(仍是一个原语,不是框架)
134
+ 宿主若在 Electron 且能接受零下游代码,可用 `enablePreferredSizeMode` + `preferred-size-changed` 作为反向「**源**」喂给同一个 `decide`,省掉下游注入。但它是 Electron 私有,且依赖两个前提:① 在 `WebContentsView` 上触发且上报值吸收 zoom;② `setBounds.height` 不回灌 layout(否则正反馈震荡)。
145
135
 
146
- 双向化后 view-anchor 仍是「DOM 几何 跨进程视图」的**单一职责双向桥**。守住四条即不滑向框架:① 导出面只多 `createSizeAdvertiser` + 其类型;② `createMeasureLoop` 不导出;③ 现在不做 React 反向适配(下游不保证是 React);④ `decide` 留宿主、不进包。
136
+ view-anchor 的反向原语是**引擎无关/可移植**那条路(非 Electron / iframe 宿主、需选子 target 的场景)。两者并存:preferred-size Electron 捷径,advertiser 是可移植机制,`decide` + §5 安全红线是二者共用的公共底座。这也是反向逻辑留在 view-anchor、而非写死成 Electron 事件的理由——宿主选哪个**源**不改变反向原语本身。
147
137
 
148
- ## 9. 已收敛 / 仍待拍板
138
+ ## 8. 包定位守恒(一个原语,不是框架)
149
139
 
150
- 已由对抗评审定:payload 单标量 `{axis, extent}`(关闭「单标量 vs 可空双轴」);命名 `createSizeAdvertiser`/`AdvertisedSize`/`publish`(关闭命名问题);`present` 砍掉。
151
- 仍待定:React 反向适配 `useSizeAdvertiser` 是否要做(默认否,等出现真实 React 下游)。
140
+ view-anchor 是「DOM 几何 ↔ 跨进程视图」的**单一职责双向桥**。守住四条即不滑向框架:① 导出面只含 `createViewAnchor` / `createPlacementAnchor` / `createSizeAdvertiser` + 其类型;② `createMeasureLoop` 不导出;③ 不提供 React 反向适配(下游不保证是 React);④ `decide` 留宿主、不进包。
@@ -61,6 +61,27 @@ const handle = createViewAnchor(target, {
61
61
  - **`{0,0,0,0}`(ZERO)** —— 收起信号。宿主把零面积读作「摘除子视图,但保留其 `WebContents` 存活」,即收起而非销毁,重新挂载瞬时且状态完整。
62
62
  - **dispose 后保持静默** —— 不补发零矩形。元素真正消失时,应由调用方先发 ZERO 再 dispose(适配层已替你处理)。
63
63
 
64
+ ## createPlacementAnchor(target, opts) — 显式可见性变体
65
+
66
+ `createViewAnchor` 用零矩形 `{0,0,0,0}` 兼任「收起」信号,可见性是从几何推断的。`createPlacementAnchor` 把可见性提升为显式判别式,发的是 `Placement` 而非 `Bounds`:
67
+
68
+ ```ts
69
+ const handle = createPlacementAnchor(target, {
70
+ visible: true, // 调用方意图:视图是否该在屏
71
+ publish: (placement) => { ... },
72
+ })
73
+ ```
74
+
75
+ | 状态 / 选项 | 行为 |
76
+ |---|---|
77
+ | `visible: true` | 发 `measurePlacement(target)`(`{ visible:true, bounds }`),并在每次 `ResizeObserver` / `resize` 触发**同步**重发。 |
78
+ | `visible: false` | 发 `{ visible:false }`(**不是**零矩形),停止观察。 |
79
+ | `guardDisplayNone`(默认 false) | 开启后,测出零面积的 target(display:none / 卸载 / 首帧未稳)发 `{ visible:false }` 而非 `{ visible:true, bounds:0×0 }`,并挂 `IntersectionObserver` 捕捉 `ResizeObserver` 不上报的 display:none 切换。 |
80
+ | `followScroll`(默认 false) | 捕获阶段监听 `window` 的 `scroll`,祖先滚动容器滚动 target 时重测重发。 |
81
+ | `followGeometry`(默认 false) | 按需开窗的 RAF 几何哨兵,捕捉无 DOM 事件上报的祖先 transform / reflow 位移;几何静止几帧后自动关窗,空闲时零成本。可由 `pulse(durationMs?)` 显式开窗。 |
82
+
83
+ 去重携带判别式(`samePlacement`),所以可见性翻转绝不会被同值合并掉——即使几何看上去一样。真正 0×0 但在屏的视图(`{ visible:true, bounds:{...,width:0} }`)与隐藏视图(`{ visible:false }`)由此可区分,而零矩形约定下二者会塌缩成同一个值。
84
+
64
85
  ## useViewAnchor(opts)
65
86
 
66
87
  React 适配层,返回一个挂到占位元素上的 ref 回调。
@@ -88,9 +109,11 @@ return <div ref={ref} />
88
109
 
89
110
  | 文件 | 作用 |
90
111
  |---|---|
91
- | `src/view-anchor.ts` | 命令式核心 `createViewAnchor`。无 React、无 Electron。 |
92
- | `src/react.ts` | React 适配层 `useViewAnchor`。 |
93
- | `src/types.ts` | `Bounds`、`ViewAnchorOptions`、`ViewAnchorHandle`。 |
112
+ | `src/view-anchor.ts` | 正向命令式核心 `createViewAnchor` / `createPlacementAnchor` / `measurePlacement`。无 React、无 Electron。 |
113
+ | `src/react.ts` | React 适配层 `useViewAnchor`(基于 `createViewAnchor`)。 |
114
+ | `src/size-advertiser.ts` | 反向命令式核心 `createSizeAdvertiser`。 |
115
+ | `src/measure-loop.ts` | 反向专用的 RAF 合并 / 去重 / dispose 引擎 `createMeasureLoop`(不导出)。 |
116
+ | `src/types.ts` | `Bounds`、`Placement`、各核心的选项与句柄类型、反向的 `AdvertisedAxis` / `AdvertisedSize`。 |
94
117
  | `src/index.ts` | 对外公开面。 |
95
118
 
96
- 正向运行时依赖只有 `react`(仅适配层)和浏览器 API(`ResizeObserver`、`getBoundingClientRect`、`window` 的 `resize` 监听)——**不含 `requestAnimationFrame`**(正向同步发布;RAF 只在反向 `createSizeAdvertiser` 用)。
119
+ 正向运行时依赖只有 `react`(仅适配层)和浏览器 API(`ResizeObserver`、`getBoundingClientRect`、`window` 的 `resize` 监听)。`requestAnimationFrame` 只在反向 `createSizeAdvertiser`(经 `createMeasureLoop`)和 `createPlacementAnchor` 的 opt-in `followGeometry` 哨兵里用;正向的 `createViewAnchor` 同步发布、不走 RAF。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dimina-kit/view-anchor",
3
- "version": "0.1.0-dev.20260616102751",
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