@beyondwork/docx-react-component 1.0.106 → 1.0.109

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.
Files changed (190) hide show
  1. package/package.json +19 -5
  2. package/src/api/geometry-overlay-rects.ts +5 -0
  3. package/src/api/package-version.ts +1 -1
  4. package/src/api/page-anchor-id.ts +5 -0
  5. package/src/api/public-types.ts +16 -9
  6. package/src/api/table-node-specs.ts +6 -0
  7. package/src/api/v3/_create.ts +2 -1
  8. package/src/api/v3/_page-anchor-id.ts +52 -0
  9. package/src/api/v3/_runtime-handle.ts +92 -1
  10. package/src/api/v3/ai/_audit-time.ts +5 -0
  11. package/src/api/v3/ai/_pe2-evidence.ts +38 -0
  12. package/src/api/v3/ai/attach.ts +7 -2
  13. package/src/api/v3/ai/replacement.ts +101 -18
  14. package/src/api/v3/ai/resolve.ts +2 -2
  15. package/src/api/v3/ai/review.ts +177 -3
  16. package/src/api/v3/index.ts +1 -0
  17. package/src/api/v3/runtime/collab.ts +462 -0
  18. package/src/api/v3/runtime/document.ts +503 -20
  19. package/src/api/v3/runtime/geometry.ts +97 -0
  20. package/src/api/v3/runtime/layout.ts +744 -0
  21. package/src/api/v3/runtime/perf-probe.ts +14 -0
  22. package/src/api/v3/runtime/viewport.ts +9 -8
  23. package/src/api/v3/ui/_types.ts +149 -55
  24. package/src/api/v3/ui/chrome-preset-model.ts +5 -5
  25. package/src/api/v3/ui/debug.ts +115 -2
  26. package/src/api/v3/ui/index.ts +13 -0
  27. package/src/api/v3/ui/overlays.ts +0 -8
  28. package/src/api/v3/ui/surface.ts +56 -0
  29. package/src/api/v3/ui/viewport.ts +22 -9
  30. package/src/core/commands/image-commands.ts +1 -0
  31. package/src/core/commands/index.ts +6 -0
  32. package/src/core/schema/text-schema.ts +43 -5
  33. package/src/core/selection/mapping.ts +8 -1
  34. package/src/core/selection/review-anchors.ts +5 -1
  35. package/src/core/state/text-transaction.ts +8 -2
  36. package/src/io/export/serialize-revisions.ts +149 -1
  37. package/src/io/normalize/normalize-text.ts +6 -0
  38. package/src/io/ooxml/parse-bookmark-references.ts +55 -0
  39. package/src/io/ooxml/parse-fields.ts +24 -2
  40. package/src/io/ooxml/parse-headers-footers.ts +38 -5
  41. package/src/io/ooxml/parse-main-document.ts +153 -9
  42. package/src/io/ooxml/parse-numbering.ts +20 -0
  43. package/src/io/ooxml/parse-revisions.ts +19 -8
  44. package/src/io/opc/package-reader.ts +98 -8
  45. package/src/model/anchor.ts +4 -3
  46. package/src/model/canonical-document.ts +220 -2
  47. package/src/model/canonical-hash.ts +221 -0
  48. package/src/model/canonical-layout-inputs.ts +245 -6
  49. package/src/model/layout/index.ts +1 -0
  50. package/src/model/layout/page-graph-types.ts +118 -1
  51. package/src/model/review/revision-types.ts +14 -3
  52. package/src/preservation/store.ts +20 -4
  53. package/src/review/README.md +1 -1
  54. package/src/review/store/revision-actions.ts +14 -2
  55. package/src/runtime/collab/event-types.ts +67 -1
  56. package/src/runtime/collab/runtime-collab-sync.ts +177 -5
  57. package/src/runtime/diagnostics/layout-guard-warning.ts +18 -0
  58. package/src/runtime/document-heading-outline.ts +147 -0
  59. package/src/runtime/document-navigation.ts +8 -243
  60. package/src/runtime/document-runtime.ts +240 -97
  61. package/src/runtime/edit-dispatch/dispatch-text-command.ts +11 -0
  62. package/src/runtime/formatting/layout-inputs.ts +38 -5
  63. package/src/runtime/formatting/numbering/geometry.ts +28 -2
  64. package/src/runtime/geometry/adjacent-geometry-intake.ts +835 -0
  65. package/src/runtime/geometry/caret-geometry.ts +5 -6
  66. package/src/runtime/geometry/geometry-facet.ts +60 -10
  67. package/src/runtime/geometry/geometry-index.ts +591 -20
  68. package/src/runtime/geometry/geometry-types.ts +59 -0
  69. package/src/runtime/geometry/hit-test.ts +11 -1
  70. package/src/runtime/geometry/overlay-rects.ts +5 -3
  71. package/src/runtime/geometry/project-anchors.ts +1 -1
  72. package/src/runtime/geometry/word-layout-v2-line-intake.ts +323 -0
  73. package/src/runtime/layout/index.ts +6 -0
  74. package/src/runtime/layout/layout-engine-instance.ts +6 -1
  75. package/src/runtime/layout/layout-engine-version.ts +181 -16
  76. package/src/runtime/layout/layout-facet-types.ts +6 -0
  77. package/src/runtime/layout/page-graph.ts +21 -4
  78. package/src/runtime/layout/paginated-layout-engine.ts +139 -15
  79. package/src/runtime/layout/project-block-fragments.ts +265 -7
  80. package/src/runtime/layout/public-facet.ts +78 -24
  81. package/src/runtime/layout/table-row-continuation-contract.ts +107 -0
  82. package/src/runtime/layout/table-row-split.ts +92 -35
  83. package/src/runtime/prerender/cache-envelope.ts +2 -2
  84. package/src/runtime/prerender/cache-key.ts +5 -4
  85. package/src/runtime/prerender/customxml-cache.ts +0 -1
  86. package/src/runtime/render/render-kernel.ts +1 -1
  87. package/src/runtime/revision-runtime.ts +112 -10
  88. package/src/runtime/scopes/_scope-dependencies.ts +1 -0
  89. package/src/runtime/scopes/action-validation.ts +22 -2
  90. package/src/runtime/scopes/capabilities.ts +316 -0
  91. package/src/runtime/scopes/compile-scope-bundle.ts +14 -0
  92. package/src/runtime/scopes/compiler-service.ts +108 -4
  93. package/src/runtime/scopes/content-control-evidence.ts +79 -0
  94. package/src/runtime/scopes/create-issue.ts +5 -5
  95. package/src/runtime/scopes/evidence.ts +91 -0
  96. package/src/runtime/scopes/formatting/apply.ts +2 -0
  97. package/src/runtime/scopes/geometry-evidence.ts +130 -0
  98. package/src/runtime/scopes/index.ts +54 -0
  99. package/src/runtime/scopes/issue-lifecycle.ts +224 -0
  100. package/src/runtime/scopes/layout-evidence.ts +374 -0
  101. package/src/runtime/scopes/multi-paragraph-refusal.ts +37 -0
  102. package/src/runtime/scopes/preservation-boundary.ts +7 -1
  103. package/src/runtime/scopes/replacement/apply.ts +97 -34
  104. package/src/runtime/scopes/scope-kinds/paragraph.ts +108 -12
  105. package/src/runtime/scopes/semantic-scope-types.ts +242 -3
  106. package/src/runtime/scopes/visualization.ts +28 -0
  107. package/src/runtime/surface-projection.ts +44 -5
  108. package/src/runtime/telemetry/perf-probe.ts +216 -0
  109. package/src/runtime/virtualized-rendering.ts +36 -1
  110. package/src/runtime/workflow/ai-issue-lifecycle.ts +253 -0
  111. package/src/runtime/workflow/coordinator.ts +39 -11
  112. package/src/runtime/workflow/derived-scope-resolver.ts +63 -9
  113. package/src/runtime/workflow/index.ts +3 -0
  114. package/src/runtime/workflow/overlay-lane-types.ts +58 -0
  115. package/src/runtime/workflow/overlay-lanes.ts +168 -10
  116. package/src/runtime/workflow/overlay-store.ts +2 -2
  117. package/src/runtime/workflow/redline-posture-calibration.ts +257 -0
  118. package/src/runtime/workflow/word-field-matrix-calibration.ts +231 -0
  119. package/src/session/_sync-legacy.ts +17 -27
  120. package/src/session/import/loader.ts +6 -4
  121. package/src/session/import/source-package-evidence.ts +186 -2
  122. package/src/session/index.ts +5 -6
  123. package/src/session/session.ts +30 -56
  124. package/src/session/types.ts +8 -13
  125. package/src/shell/session-bootstrap.ts +155 -81
  126. package/src/ui/WordReviewEditor.tsx +520 -12
  127. package/src/ui/editor-shell-view.tsx +14 -4
  128. package/src/ui/editor-surface-controller.tsx +5 -3
  129. package/src/ui/headless/selection-tool-resolver.ts +1 -2
  130. package/src/ui/presence-overlay-lane.ts +0 -1
  131. package/src/ui/ui-controller-factory.ts +7 -0
  132. package/src/ui-tailwind/chrome/build-context-menu-entries.ts +5 -1
  133. package/src/ui-tailwind/chrome/editor-action-registry.ts +105 -5
  134. package/src/ui-tailwind/chrome/editor-actions-to-palette.ts +7 -0
  135. package/src/ui-tailwind/chrome/layer-debug-contracts.ts +208 -0
  136. package/src/ui-tailwind/chrome/resolve-target-kind.ts +13 -0
  137. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +11 -3
  138. package/src/ui-tailwind/chrome/tw-command-palette.tsx +36 -6
  139. package/src/ui-tailwind/chrome/tw-context-menu.tsx +6 -1
  140. package/src/ui-tailwind/chrome/tw-display-mode-selector.tsx +42 -109
  141. package/src/ui-tailwind/chrome/tw-inline-find-bar.tsx +26 -6
  142. package/src/ui-tailwind/chrome/tw-navigation-command-bar.tsx +328 -0
  143. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +8 -4
  144. package/src/ui-tailwind/chrome/tw-runtime-repl-dialog.tsx +129 -1
  145. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +19 -5
  146. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +5 -1
  147. package/src/ui-tailwind/chrome/tw-workspace-chrome-host.tsx +28 -12
  148. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +30 -3
  149. package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +116 -10
  150. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +223 -94
  151. package/src/ui-tailwind/chrome-overlay/tw-presence-overlay-lane.tsx +157 -0
  152. package/src/ui-tailwind/chrome-overlay/tw-review-overlay-lane-markers.tsx +259 -0
  153. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +5 -2
  154. package/src/ui-tailwind/chrome-overlay/tw-substrate-overlay-lanes.tsx +314 -0
  155. package/src/ui-tailwind/debug/README.md +4 -1
  156. package/src/ui-tailwind/debug/layer11-consumer-readiness.ts +272 -0
  157. package/src/ui-tailwind/debug/layer11-word-field-matrix-evidence.ts +160 -0
  158. package/src/ui-tailwind/editor-surface/perf-probe.ts +14 -215
  159. package/src/ui-tailwind/editor-surface/pm-decorations.ts +42 -0
  160. package/src/ui-tailwind/editor-surface/pm-position-map.ts +38 -2
  161. package/src/ui-tailwind/editor-surface/pm-schema.ts +14 -4
  162. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +34 -5
  163. package/src/ui-tailwind/editor-surface/runtime-decoration-plugin.ts +9 -19
  164. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -2
  165. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +145 -0
  166. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +16 -11
  167. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +8 -10
  168. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +3 -0
  169. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +4 -2
  170. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +60 -20
  171. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +16 -11
  172. package/src/ui-tailwind/review/tw-health-panel.tsx +36 -17
  173. package/src/ui-tailwind/review/tw-review-rail.tsx +7 -4
  174. package/src/ui-tailwind/review-workspace/diagnostics-visibility.ts +44 -0
  175. package/src/ui-tailwind/review-workspace/page-shell-metrics.ts +11 -0
  176. package/src/ui-tailwind/review-workspace/tw-review-workspace-rail.tsx +16 -1
  177. package/src/ui-tailwind/review-workspace/types.ts +26 -12
  178. package/src/ui-tailwind/review-workspace/use-diagnostics-signal.ts +40 -11
  179. package/src/ui-tailwind/review-workspace/use-layout-facet-render-signal.ts +2 -1
  180. package/src/ui-tailwind/review-workspace/use-page-markers.ts +15 -26
  181. package/src/ui-tailwind/review-workspace/use-scope-card-state.ts +35 -18
  182. package/src/ui-tailwind/review-workspace/use-selection-toolbar-placement.ts +41 -32
  183. package/src/ui-tailwind/review-workspace/use-status-bar-page-facts.ts +2 -1
  184. package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +2 -1
  185. package/src/ui-tailwind/status/tw-status-bar.tsx +6 -5
  186. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +52 -80
  187. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +12 -48
  188. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +9 -4
  189. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +328 -361
  190. package/src/ui-tailwind/tw-review-workspace.tsx +152 -286
@@ -47,13 +47,19 @@
47
47
  */
48
48
 
49
49
  import * as React from "react";
50
- import type { WordReviewEditorLayoutFacet } from "../../api/public-types";
51
- import { incrementInvalidationCounter } from "../editor-surface/perf-probe";
50
+ import type {
51
+ GeometryFacet,
52
+ PageLayoutSnapshot,
53
+ WordReviewEditorLayoutFacet,
54
+ } from "../../api/public-types";
55
+ import { DEFAULT_PX_PER_TWIP } from "../../api/public-types";
56
+ import { buildPageAnchorAttributes } from "../../api/v3/_page-anchor-id.ts";
52
57
  import {
53
58
  resolvePageOverlayRectsFromGeometry as resolvePageOverlayRectsFromGeometryImpl,
54
- type GeometryFacet,
55
59
  type OverlayVisiblePageIndexRange,
56
- } from "../../runtime/geometry/index.ts";
60
+ } from "../../api/geometry-overlay-rects.ts";
61
+ import { PAGE_CHROME_DEFAULTS } from "../editor-surface/pm-page-break-decorations.ts";
62
+ import { incrementInvalidationCounter } from "../editor-surface/perf-probe";
57
63
  import type { ApiV3Ui } from "../../api/v3/ui";
58
64
  import { useUiApi } from "../ui-api-context";
59
65
 
@@ -72,6 +78,8 @@ export interface PageOverlayRect {
72
78
  bottomPx: number;
73
79
  /** Rendered height = bottomPx - topPx. */
74
80
  heightPx: number;
81
+ /** Cold fallback marker; omitted for UI/geometry/DOM-derived rects. */
82
+ source?: "skeletal";
75
83
  }
76
84
 
77
85
  /**
@@ -108,7 +116,8 @@ function pageOverlayRectsEqual(
108
116
  left.pageIndex !== right.pageIndex ||
109
117
  left.topPx !== right.topPx ||
110
118
  left.bottomPx !== right.bottomPx ||
111
- left.heightPx !== right.heightPx
119
+ left.heightPx !== right.heightPx ||
120
+ left.source !== right.source
112
121
  ) {
113
122
  return false;
114
123
  }
@@ -116,6 +125,81 @@ function pageOverlayRectsEqual(
116
125
  return true;
117
126
  }
118
127
 
128
+ function resolvePageFrameHeightPx(layout: PageLayoutSnapshot): number | null {
129
+ if (!Number.isFinite(layout.pageHeight) || layout.pageHeight <= 0) {
130
+ return null;
131
+ }
132
+ return Math.max(1, Math.round(layout.pageHeight * DEFAULT_PX_PER_TWIP));
133
+ }
134
+
135
+ function resolvePageFrameHeightPxFromLayoutPage(
136
+ page: ReturnType<WordReviewEditorLayoutFacet["getPage"]>,
137
+ ): number | null {
138
+ if (!page) return null;
139
+ const frameHeightTwips = page.frame?.physicalBoundsTwips.heightTwips;
140
+ if (
141
+ typeof frameHeightTwips === "number" &&
142
+ Number.isFinite(frameHeightTwips) &&
143
+ frameHeightTwips > 0
144
+ ) {
145
+ return Math.max(1, Math.round(frameHeightTwips * DEFAULT_PX_PER_TWIP));
146
+ }
147
+ return resolvePageFrameHeightPx(page.layout);
148
+ }
149
+
150
+ /**
151
+ * L11 page-stack consumers receive the mounted L04 page-frame substrate through
152
+ * the public layout facet. This mirrors the records exposed by
153
+ * `runtime.layout.getPageFrames()` without making the React painter depend on a
154
+ * runtime handle; DOM page-frame markers stay a degraded cold-open fallback.
155
+ */
156
+ export function resolvePageOverlayPageIdsFromLayout(
157
+ facet: Pick<WordReviewEditorLayoutFacet, "getPageCount" | "getPage">,
158
+ ): readonly string[] | null {
159
+ const pageCount = facet.getPageCount();
160
+ if (pageCount <= 0) return [];
161
+ const pageIds: string[] = [];
162
+ for (let pageIndex = 0; pageIndex < pageCount; pageIndex += 1) {
163
+ const page = facet.getPage(pageIndex);
164
+ if (!page) return null;
165
+ pageIds.push(page.frame?.pageId ?? page.pageId);
166
+ }
167
+ return pageIds;
168
+ }
169
+
170
+ /**
171
+ * Cold geometry fallback: before the render kernel or UI anchor provider binds,
172
+ * the public layout facet can still tell us how many pages exist and which
173
+ * L04 page frame / canonical section layout owns each page. Use only public
174
+ * frame or section sizing to draw paper cards, then upgrade in place when
175
+ * UI/geometry/DOM rects arrive.
176
+ */
177
+ export function resolveSkeletalPageOverlayRectsFromLayout(
178
+ facet: Pick<WordReviewEditorLayoutFacet, "getPageCount" | "getPage">,
179
+ ): readonly PageOverlayRect[] {
180
+ const pageCount = facet.getPageCount();
181
+ if (pageCount <= 0) return [];
182
+
183
+ const rects: PageOverlayRect[] = [];
184
+ let topPx = 0;
185
+ for (let pageIndex = 0; pageIndex < pageCount; pageIndex += 1) {
186
+ const page = facet.getPage(pageIndex);
187
+ if (!page) return [];
188
+ const heightPx = resolvePageFrameHeightPxFromLayoutPage(page);
189
+ if (heightPx === null) return [];
190
+ rects.push({
191
+ pageId: page.frame?.pageId ?? page.pageId,
192
+ pageIndex,
193
+ topPx,
194
+ bottomPx: topPx + heightPx,
195
+ heightPx,
196
+ source: "skeletal",
197
+ });
198
+ topPx += heightPx + PAGE_CHROME_DEFAULTS.interGapPx;
199
+ }
200
+ return rects;
201
+ }
202
+
119
203
  function pageOverlayLastBottom(rects: readonly PageOverlayRect[]): number {
120
204
  let bottom = 0;
121
205
  for (const rect of rects) {
@@ -239,6 +323,19 @@ function collectTableEmbeddedBoundaryIndices(
239
323
  return indices;
240
324
  }
241
325
 
326
+ function containsTableBoundaryRisk(queryRoot: HTMLElement | null): boolean {
327
+ if (!queryRoot) return false;
328
+ if (queryRoot.getElementsByTagName("table").length > 0) return true;
329
+ const descendants = queryRoot.getElementsByTagName("*");
330
+ for (let i = 0; i < descendants.length; i += 1) {
331
+ const element = descendants[i] as HTMLElement;
332
+ if (element.getAttribute("data-pm-table-root") === "true") {
333
+ return true;
334
+ }
335
+ }
336
+ return false;
337
+ }
338
+
242
339
  /**
243
340
  * Pure helper: turn pre-measured page-boundary widget positions into
244
341
  * one `PageOverlayRect` per page. No DOM access — the caller supplies
@@ -553,6 +650,58 @@ function readOverlayFlowHeight(origin: HTMLElement | null): number {
553
650
  return readElementFlowHeight(origin, { includeScrollHeight: false });
554
651
  }
555
652
 
653
+ export function reconcilePageStackRectsWithFlow(input: {
654
+ baseRects: readonly PageOverlayRect[];
655
+ pageCount: number;
656
+ scrollRoot: HTMLElement | null;
657
+ originElement: HTMLElement | null;
658
+ }): readonly PageOverlayRect[] {
659
+ const { baseRects, pageCount, scrollRoot, originElement } = input;
660
+ if (baseRects.length === 0 || pageCount <= 0) return baseRects;
661
+ const flowHeight = readOverlayFlowHeight(originElement);
662
+ if (flowHeight <= 0) return baseRects;
663
+
664
+ const geometryBottom = pageOverlayLastBottom(baseRects);
665
+ const tableBoundaryRisk = containsTableBoundaryRisk(scrollRoot);
666
+ if (!tableBoundaryRisk && flowHeight <= geometryBottom + 1) {
667
+ return extendFinalPageOverlayRectToFlowHeight(baseRects, flowHeight);
668
+ }
669
+
670
+ const bridgedBase = extendPageOverlayRectsAcrossTableBoundaryGaps(
671
+ baseRects,
672
+ tableBoundaryRisk ? collectTableEmbeddedBoundaryIndices(scrollRoot) : [],
673
+ );
674
+
675
+ const extendedBase = extendFinalPageOverlayRectToFlowHeight(
676
+ bridgedBase,
677
+ flowHeight,
678
+ );
679
+ if (!originElement || !scrollRoot) return extendedBase;
680
+
681
+ // The common warm path has geometry and DOM flow in agreement. Avoid the
682
+ // full boundary-widget scan unless the PM flow is visibly taller than the
683
+ // geometry stack. When it is taller, page-stack consumers must follow the
684
+ // in-flow boundaries so content remains on page chrome instead of canvas.
685
+ if (flowHeight <= pageOverlayLastBottom(bridgedBase) + 1) {
686
+ return extendedBase;
687
+ }
688
+
689
+ const widgets = measureWidgetsViaBoundingRect(scrollRoot, originElement, {
690
+ pageCount,
691
+ visiblePageIndexRange: null,
692
+ });
693
+ if (widgets.length === 0) return extendedBase;
694
+
695
+ const flowRects = resolvePageOverlayRects({
696
+ widgets,
697
+ pageCount,
698
+ scrollHeight: flowHeight,
699
+ visiblePageIndexRange: null,
700
+ });
701
+ const merged = mergePageOverlayRectsByPageIndex(extendedBase, flowRects);
702
+ return extendFinalPageOverlayRectToFlowHeight(merged, flowHeight);
703
+ }
704
+
556
705
  // ---------------------------------------------------------------------------
557
706
  // Component
558
707
  // ---------------------------------------------------------------------------
@@ -681,31 +830,34 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
681
830
  // null here when headless / off-provider; the overlay then falls
682
831
  // through to the existing geometry-facet / DOM paths.
683
832
  const ui = useUiApi();
684
- // Flicker-remediation 2026-04-22: compute initial rects synchronously
685
- // from the geometry facet when the kernel is already warm, so the
686
- // first paint has correct paper cards instead of an empty overlay
687
- // that only populates after `useEffect` ticks. Cold-open paths where
688
- // `geometryFacet` isn't passed / returns null still fall back to
689
- // the empty initial state + the fallback paper rendered below.
833
+ // Flicker-remediation 2026-04-22 + Pass-7 hardening 2026-04-27:
834
+ // compute initial rects synchronously from the geometry facet when
835
+ // the kernel is already warm. If geometry is cold or absent, draw
836
+ // skeletal page cards from canonical section sizing so first paint
837
+ // still shows the document outline.
690
838
  const [rects, setRects] = React.useState<readonly PageOverlayRect[]>(() => {
691
- if (!geometryFacet) return [];
692
839
  const pageCount = facet.getPageCount();
693
840
  if (pageCount <= 0) return [];
694
841
  // Paper-card backgrounds are cheap and must stay ahead of scroll. Heavy
695
842
  // header/footer/footnote chrome still consumes `visiblePageIndexRange`;
696
843
  // this decorative white-paper layer renders every card so fast scrolls
697
844
  // never expose the gray canvas while the page window catches up.
698
- const warm = resolvePageOverlayRectsFromGeometry(
699
- geometryFacet,
700
- pageCount,
701
- null,
702
- );
703
- return warm
704
- ? extendPageOverlayRectsAcrossTableBoundaryGaps(
845
+ if (geometryFacet) {
846
+ const warm = resolvePageOverlayRectsFromGeometry(
847
+ geometryFacet,
848
+ pageCount,
849
+ null,
850
+ );
851
+ if (warm) {
852
+ return extendPageOverlayRectsAcrossTableBoundaryGaps(
705
853
  warm,
706
- collectTableEmbeddedBoundaryIndices(scrollRoot),
707
- )
708
- : [];
854
+ containsTableBoundaryRisk(scrollRoot)
855
+ ? collectTableEmbeddedBoundaryIndices(scrollRoot)
856
+ : [],
857
+ );
858
+ }
859
+ }
860
+ return resolveSkeletalPageOverlayRectsFromLayout(facet);
709
861
  });
710
862
  // P3.d fix: the overlay root acts as the **measurement origin** so
711
863
  // widget `topPx` / `bottomPx` are expressed in the exact coordinate
@@ -746,49 +898,19 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
746
898
  baseRects: readonly PageOverlayRect[],
747
899
  pageCount: number,
748
900
  ): readonly PageOverlayRect[] => {
749
- if (baseRects.length === 0 || pageCount <= 0) return baseRects;
750
- const origin = overlayRootRef.current;
751
- const flowHeight = readOverlayFlowHeight(origin);
752
- const bridgedBase = extendPageOverlayRectsAcrossTableBoundaryGaps(
901
+ return reconcilePageStackRectsWithFlow({
753
902
  baseRects,
754
- collectTableEmbeddedBoundaryIndices(scrollRoot),
755
- );
756
- if (flowHeight <= 0) return bridgedBase;
757
-
758
- const extendedBase = extendFinalPageOverlayRectToFlowHeight(
759
- bridgedBase,
760
- flowHeight,
761
- );
762
- if (!origin || !scrollRoot) return extendedBase;
763
-
764
- // The common warm path has geometry and DOM flow in agreement. Avoid the
765
- // full boundary-widget scan unless the PM flow is visibly taller than the
766
- // geometry stack. When it is taller, the paper-card layer must follow the
767
- // in-flow boundaries so content remains on paper instead of canvas.
768
- if (flowHeight <= pageOverlayLastBottom(bridgedBase) + 1) {
769
- return extendedBase;
770
- }
771
-
772
- const widgets = measureWidgetsViaBoundingRect(scrollRoot, origin, {
773
- pageCount,
774
- visiblePageIndexRange: null,
775
- });
776
- if (widgets.length === 0) return extendedBase;
777
-
778
- const flowRects = resolvePageOverlayRects({
779
- widgets,
780
903
  pageCount,
781
- scrollHeight: flowHeight,
782
- visiblePageIndexRange: null,
904
+ scrollRoot,
905
+ originElement: overlayRootRef.current,
783
906
  });
784
- const merged = mergePageOverlayRectsByPageIndex(extendedBase, flowRects);
785
- return extendFinalPageOverlayRectToFlowHeight(merged, flowHeight);
786
907
  },
787
908
  [scrollRoot],
788
909
  );
789
910
 
790
911
  const refreshRectsNow = React.useCallback(() => {
791
912
  const pageCount = facet.getPageCount();
913
+ const skeletalRects = resolveSkeletalPageOverlayRectsFromLayout(facet);
792
914
 
793
915
  // DS-C2 — first try the UI API seam so presentation code does not
794
916
  // reach into the geometry facet directly. Page-ids come from the
@@ -805,7 +927,8 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
805
927
  // threshold during steady-state typing (follow-up work — bench
806
928
  // setup for F01/F08 typing scenarios is a separate task).
807
929
  if (ui) {
808
- const pageIds: string[] | null = geometryFacet
930
+ const layoutPageIds = resolvePageOverlayPageIdsFromLayout(facet);
931
+ const geometryPageIds: string[] | null = geometryFacet
809
932
  ? Array.from({ length: pageCount }, (_, i) => {
810
933
  const page = geometryFacet.getPage(i);
811
934
  return page?.pageId ?? `page-${i}`;
@@ -815,7 +938,7 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
815
938
  ui,
816
939
  pageCount,
817
940
  null,
818
- pageIds,
941
+ layoutPageIds ?? geometryPageIds,
819
942
  );
820
943
  if (uiRects !== null) {
821
944
  incrementInvalidationCounter("overlay.page.ui_api.hit");
@@ -843,10 +966,11 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
843
966
  setRectsIfChanged(reconcilePaperRectsWithFlow(geometryRects, pageCount));
844
967
  return;
845
968
  }
969
+ incrementInvalidationCounter("overlay.page.geometry.fallthrough");
846
970
  }
847
971
 
848
972
  if (!scrollRoot) {
849
- setRectsIfChanged([]);
973
+ setRectsIfChanged(skeletalRects);
850
974
  return;
851
975
  }
852
976
  const origin = overlayRootRef.current;
@@ -854,6 +978,7 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
854
978
  // Cold-open / pre-paint DOM fallback — warm path early-returned
855
979
  // above via `geometryFacet` or the UI-API resolver. Lines below
856
980
  // fire only before the first render frame.
981
+ incrementInvalidationCounter("overlay.page.dom_fallback");
857
982
  if (origin) {
858
983
  incrementInvalidationCounter("overlay.page.dom.degraded");
859
984
  const widgets = measureWidgetsViaBoundingRect(scrollRoot, origin, {
@@ -870,7 +995,8 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
870
995
  origin.clientHeight > 0 ? origin.clientHeight : originRect.height,
871
996
  visiblePageIndexRange: null,
872
997
  });
873
- setRectsIfChanged(reconcilePaperRectsWithFlow(domRects, pageCount));
998
+ const reconciled = reconcilePaperRectsWithFlow(domRects, pageCount);
999
+ setRectsIfChanged(reconciled.length > 0 ? reconciled : skeletalRects);
874
1000
  } else {
875
1001
  incrementInvalidationCounter("overlay.page.dom.degraded");
876
1002
  const widgets = measureWidgetsViaOffsetChain(scrollRoot, {
@@ -884,7 +1010,8 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
884
1010
  scrollHeight: scrollRoot.clientHeight,
885
1011
  visiblePageIndexRange: null,
886
1012
  });
887
- setRectsIfChanged(reconcilePaperRectsWithFlow(domRects, pageCount));
1013
+ const reconciled = reconcilePaperRectsWithFlow(domRects, pageCount);
1014
+ setRectsIfChanged(reconciled.length > 0 ? reconciled : skeletalRects);
888
1015
  }
889
1016
  }, [
890
1017
  facet,
@@ -1007,15 +1134,9 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
1007
1134
  // root element present + empty is cheap (one `<div>`) and makes the
1008
1135
  // ref resolve during the first layout-effect pass.
1009
1136
  //
1010
- // Flicker-remediation 2026-04-22: when rects are not yet available
1011
- // (cold-open; geometry facet has no kernel yet; DOM not measured),
1012
- // paint a full-area paper-color fallback card so the user sees a
1013
- // white page rather than the gray workspace canvas flashing through.
1014
- // The fallback uses the same paper chrome (bg / border / shadow /
1015
- // radius) as individual page cards, rendered as `inset-0` so it
1016
- // spans whatever region the scroll root currently occupies. When
1017
- // `rects` populates on the next refresh pass the fallback is
1018
- // replaced by N discrete cards.
1137
+ // Flicker-remediation 2026-04-22: if even canonical section sizing is not
1138
+ // available yet, paint a full-area paper-color fallback card so the user
1139
+ // sees white paper rather than the gray workspace canvas flashing through.
1019
1140
  if (rects.length === 0) {
1020
1141
  return (
1021
1142
  <div
@@ -1041,6 +1162,7 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
1041
1162
  );
1042
1163
  }
1043
1164
 
1165
+ const rectsAreSkeletal = rects.every((rect) => rect.source === "skeletal");
1044
1166
  return (
1045
1167
  <div
1046
1168
  ref={overlayRootRef}
@@ -1048,31 +1170,38 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
1048
1170
  aria-hidden="true"
1049
1171
  data-testid={testId ?? "page-stack-overlay"}
1050
1172
  data-page-count={rects.length}
1173
+ data-fallback-paper={rectsAreSkeletal ? "skeletal" : undefined}
1051
1174
  >
1052
- {rects.map((rect) => (
1053
- <div
1054
- key={rect.pageId}
1055
- className="wre-page-stack-overlay-frame absolute"
1056
- data-page-index={rect.pageIndex}
1057
- data-page-id={rect.pageId}
1058
- style={{
1059
- top: `${rect.topPx}px`,
1060
- height: `${rect.heightPx}px`,
1061
- left: 0,
1062
- right: 0,
1063
- // N1 (L8 Phase D): this component is placed at z-0 BEFORE
1064
- // the z-10 PM wrapper inside `wre-page-surface`, so an opaque
1065
- // page background here sits behind PM text rather than on top
1066
- // of it. White card + border + shadow gives the 'N distinct
1067
- // papers on a gray canvas' appearance.
1068
- backgroundColor: "var(--color-page-bg, white)",
1069
- border: "1px solid var(--color-page-border, rgba(148,163,184,0.2))",
1070
- borderRadius: "var(--radius-page, 4px)",
1071
- boxShadow:
1072
- "0 8px 24px -20px var(--color-page-shadow, rgba(15,23,42,0.38))",
1073
- }}
1074
- />
1075
- ))}
1175
+ {rects.map((rect) => {
1176
+ const anchorAttrs = buildPageAnchorAttributes(rect.pageId, rect.pageIndex);
1177
+ return (
1178
+ <div
1179
+ key={rect.pageId}
1180
+ {...anchorAttrs}
1181
+ className={`wre-page-stack-overlay-frame absolute${
1182
+ rect.source === "skeletal"
1183
+ ? " wre-page-stack-overlay-frame--skeletal"
1184
+ : ""
1185
+ }`}
1186
+ style={{
1187
+ top: `${rect.topPx}px`,
1188
+ height: `${rect.heightPx}px`,
1189
+ left: 0,
1190
+ right: 0,
1191
+ // N1 (L8 Phase D): this component is placed at z-0 BEFORE
1192
+ // the z-10 PM wrapper inside `wre-page-surface`, so an opaque
1193
+ // page background here sits behind PM text rather than on top
1194
+ // of it. White card + border + shadow gives the 'N distinct
1195
+ // papers on a gray canvas' appearance.
1196
+ backgroundColor: "var(--color-page-bg, white)",
1197
+ border: "1px solid var(--color-page-border, rgba(148,163,184,0.2))",
1198
+ borderRadius: "var(--radius-page, 4px)",
1199
+ boxShadow:
1200
+ "0 8px 24px -20px var(--color-page-shadow, rgba(15,23,42,0.38))",
1201
+ }}
1202
+ />
1203
+ );
1204
+ })}
1076
1205
  </div>
1077
1206
  );
1078
1207
  };
@@ -0,0 +1,157 @@
1
+ import * as React from "react";
2
+
3
+ import type {
4
+ GeometryRect,
5
+ UiOverlayLaneEntry,
6
+ UiOverlayLaneSnapshot,
7
+ } from "../../api/v3/ui/_types.ts";
8
+ import { useUiApi } from "../ui-api-context.tsx";
9
+
10
+ const FALLBACK_CURSOR_COLOR = "#6b7280";
11
+
12
+ /**
13
+ * L11 lane painter for L10's presence overlay lane.
14
+ *
15
+ * The runtime/shell owns awareness and cursor projection. This component is
16
+ * deliberately only a painter: it consumes `ui.overlays.getLane("presence")`
17
+ * snapshots, subscribes to the same lane, and renders resolved overlay rects.
18
+ */
19
+ export function TwPresenceOverlayLane(): React.ReactElement | null {
20
+ const ui = useUiApi();
21
+ const [lane, setLane] = React.useState<UiOverlayLaneSnapshot | null>(() =>
22
+ ui?.overlays.getLane("presence") ?? null,
23
+ );
24
+
25
+ React.useEffect(() => {
26
+ if (!ui) {
27
+ setLane(null);
28
+ return undefined;
29
+ }
30
+
31
+ setLane(ui.overlays.getLane("presence"));
32
+
33
+ try {
34
+ return ui.overlays.subscribeLane("presence", setLane);
35
+ } catch {
36
+ return undefined;
37
+ }
38
+ }, [ui]);
39
+
40
+ const markers = React.useMemo(() => resolvedPresenceMarkers(lane), [lane]);
41
+ if (markers.length === 0) return null;
42
+
43
+ return (
44
+ <div
45
+ aria-hidden="true"
46
+ className="wre-presence-overlay-lane pointer-events-none absolute inset-0"
47
+ data-presence-overlay-lane=""
48
+ data-lane-source={lane?.source ?? "unavailable"}
49
+ data-lane-revision={lane?.revision ?? 0}
50
+ >
51
+ {markers.map((marker) => (
52
+ <div
53
+ className="wre-presence-overlay-lane__marker pointer-events-none absolute"
54
+ data-presence-cursor={marker.userId}
55
+ key={marker.key}
56
+ style={marker.style}
57
+ title={marker.displayName}
58
+ >
59
+ <span
60
+ className="wre-presence-overlay-lane__caret absolute"
61
+ style={marker.caretStyle}
62
+ />
63
+ {marker.displayName ? (
64
+ <span
65
+ className="wre-presence-overlay-lane__label absolute whitespace-nowrap rounded-sm px-1 py-0 text-[11px] font-medium leading-tight text-white shadow-sm"
66
+ style={marker.labelStyle}
67
+ >
68
+ {marker.displayName}
69
+ </span>
70
+ ) : null}
71
+ </div>
72
+ ))}
73
+ </div>
74
+ );
75
+ }
76
+
77
+ interface PresenceMarker {
78
+ key: string;
79
+ userId: string;
80
+ displayName: string;
81
+ style: React.CSSProperties;
82
+ caretStyle: React.CSSProperties;
83
+ labelStyle: React.CSSProperties;
84
+ }
85
+
86
+ export function resolvedPresenceMarkers(
87
+ lane: UiOverlayLaneSnapshot | null,
88
+ ): PresenceMarker[] {
89
+ if (!lane || lane.kind !== "presence" || lane.status !== "resolved") {
90
+ return [];
91
+ }
92
+
93
+ const markers: PresenceMarker[] = [];
94
+ for (const entry of lane.entries) {
95
+ if (entry.status !== "resolved" || !entry.rects?.length) continue;
96
+
97
+ const data = entry.data ?? {};
98
+ const userId = stringValue(data.userId) ?? entry.id;
99
+ const displayName = stringValue(data.displayName) ?? userId;
100
+ const color = safePresenceColor(data.color);
101
+
102
+ entry.rects.forEach((rect, index) => {
103
+ markers.push({
104
+ key: `${entry.id}:${index}`,
105
+ userId,
106
+ displayName,
107
+ style: markerStyle(rect),
108
+ caretStyle: caretStyle(rect, color),
109
+ labelStyle: labelStyle(color),
110
+ });
111
+ });
112
+ }
113
+ return markers;
114
+ }
115
+
116
+ function markerStyle(rect: GeometryRect): React.CSSProperties {
117
+ return {
118
+ left: `${rect.leftPx}px`,
119
+ top: `${rect.topPx}px`,
120
+ width: `${Math.max(rect.widthPx, 2)}px`,
121
+ height: `${Math.max(rect.heightPx, 12)}px`,
122
+ };
123
+ }
124
+
125
+ function caretStyle(rect: GeometryRect, color: string): React.CSSProperties {
126
+ return {
127
+ left: 0,
128
+ top: 0,
129
+ width: "2px",
130
+ height: `${Math.max(rect.heightPx, 12)}px`,
131
+ backgroundColor: color,
132
+ borderRadius: "1px",
133
+ };
134
+ }
135
+
136
+ function labelStyle(color: string): React.CSSProperties {
137
+ return {
138
+ left: 0,
139
+ top: "-1.35em",
140
+ backgroundColor: color,
141
+ };
142
+ }
143
+
144
+ function stringValue(value: unknown): string | null {
145
+ return typeof value === "string" && value.trim().length > 0 ? value : null;
146
+ }
147
+
148
+ function safePresenceColor(raw: unknown): string {
149
+ if (typeof raw !== "string") return FALLBACK_CURSOR_COLOR;
150
+ const value = raw.trim();
151
+ if (value.length === 0 || value.length > 32) return FALLBACK_CURSOR_COLOR;
152
+ if (/^#[0-9a-fA-F]{3}$|^#[0-9a-fA-F]{6}$|^#[0-9a-fA-F]{8}$/.test(value)) return value;
153
+ if (/^rgba?\(\s*\d+(\.\d+)?%?(\s*,\s*\d+(\.\d+)?%?){2,3}\s*\)$/.test(value)) return value;
154
+ return FALLBACK_CURSOR_COLOR;
155
+ }
156
+
157
+ export default TwPresenceOverlayLane;