@beyondwork/docx-react-component 1.0.67 → 1.0.70

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 (384) hide show
  1. package/README.md +75 -932
  2. package/package.json +26 -27
  3. package/src/api/anchor-conversion.ts +43 -0
  4. package/src/api/editor-state-types.ts +2 -1
  5. package/src/api/public-types.ts +504 -101
  6. package/src/api/session-state.ts +4 -0
  7. package/src/api/v3/README.md +91 -0
  8. package/src/api/v3/_create.ts +146 -0
  9. package/src/api/v3/_layer-metadata.ts +362 -0
  10. package/src/api/v3/_mocks.ts +84 -0
  11. package/src/api/v3/_runtime-handle.ts +162 -0
  12. package/src/api/v3/_ux-response.ts +73 -0
  13. package/src/api/v3/ai/_metadata-audit.ts +225 -0
  14. package/src/api/v3/ai/attach.ts +235 -0
  15. package/src/api/v3/ai/bundle.ts +132 -0
  16. package/src/api/v3/ai/explain.ts +144 -0
  17. package/src/api/v3/ai/export.ts +54 -0
  18. package/src/api/v3/ai/inspect.ts +118 -0
  19. package/src/api/v3/ai/policy.ts +77 -0
  20. package/src/api/v3/ai/replacement.ts +341 -0
  21. package/src/api/v3/ai/resolve.ts +133 -0
  22. package/src/api/v3/index.ts +79 -0
  23. package/src/api/v3/runtime/chart.ts +310 -0
  24. package/src/api/v3/runtime/clipboard.ts +81 -0
  25. package/src/api/v3/runtime/collab.ts +331 -0
  26. package/src/api/v3/runtime/content.ts +236 -0
  27. package/src/api/v3/runtime/document.ts +282 -0
  28. package/src/api/v3/runtime/formatting.ts +186 -0
  29. package/src/api/v3/runtime/geometry.ts +349 -0
  30. package/src/api/v3/runtime/layout.ts +108 -0
  31. package/src/api/v3/runtime/review.ts +129 -0
  32. package/src/api/v3/runtime/search.ts +74 -0
  33. package/src/api/v3/runtime/table.ts +63 -0
  34. package/src/api/v3/runtime/workflow.ts +434 -0
  35. package/src/api/v3/ui/_context.ts +86 -0
  36. package/src/api/v3/ui/_create.ts +65 -0
  37. package/src/api/v3/ui/_types.ts +520 -0
  38. package/src/api/v3/ui/chrome-composition.ts +342 -0
  39. package/src/{ui-tailwind/chrome → api/v3/ui}/chrome-preset-model.ts +11 -1
  40. package/src/api/v3/ui/chrome.ts +476 -0
  41. package/src/api/v3/ui/debug.ts +124 -0
  42. package/src/api/v3/ui/index.ts +64 -0
  43. package/src/api/v3/ui/overlays-visibility.ts +170 -0
  44. package/src/api/v3/ui/overlays.ts +427 -0
  45. package/src/api/v3/ui/scope.ts +71 -0
  46. package/src/api/v3/ui/session.ts +100 -0
  47. package/src/api/v3/ui/surface.ts +170 -0
  48. package/src/api/v3/ui/viewport.ts +303 -0
  49. package/src/core/commands/index.ts +28 -6
  50. package/src/core/commands/list-commands.ts +3 -2
  51. package/src/core/commands/section-layout-commands.ts +9 -8
  52. package/src/core/schema/text-schema.ts +16 -0
  53. package/src/core/selection/mapping.ts +33 -72
  54. package/src/core/state/editor-state.ts +96 -189
  55. package/src/index.ts +23 -4
  56. package/src/io/chart-preview-resolver.ts +1 -1
  57. package/src/io/docx-session.ts +36 -4797
  58. package/src/io/export/build-app-properties-xml.ts +1 -1
  59. package/src/io/export/serialize-comments.ts +1 -1
  60. package/src/io/export/serialize-headers-footers.ts +6 -1
  61. package/src/io/export/serialize-main-document.ts +45 -0
  62. package/src/io/export/serialize-run-formatting.ts +17 -2
  63. package/src/io/export/twip.ts +1 -1
  64. package/src/io/normalize/normalize-text.ts +27 -20
  65. package/src/io/ooxml/chart/parse-series.ts +1 -1
  66. package/src/io/ooxml/chart/resolve-color.ts +2 -2
  67. package/src/io/ooxml/chart/types.ts +1 -1
  68. package/src/io/ooxml/classify-embedding.ts +83 -33
  69. package/src/io/ooxml/parse-fill.ts +1 -1
  70. package/src/io/ooxml/parse-main-document.ts +71 -1
  71. package/src/io/ooxml/parse-object.ts +14 -10
  72. package/src/io/ooxml/parse-run-formatting.ts +47 -1
  73. package/src/io/ooxml/property-grab-bag.ts +2 -2
  74. package/src/io/ooxml/units.ts +11 -0
  75. package/src/io/ooxml/workflow-payload.ts +282 -7
  76. package/src/model/anchor.ts +85 -0
  77. package/src/model/canonical-document.ts +351 -15
  78. package/src/model/chart-types.ts +1 -1
  79. package/src/model/layout/index.ts +83 -0
  80. package/src/model/layout/page-graph-types.ts +181 -0
  81. package/src/model/layout/page-layout-snapshot.ts +105 -0
  82. package/src/model/layout/resolved-layout-types.ts +47 -0
  83. package/src/model/layout/runtime-page-graph-types.ts +102 -0
  84. package/src/model/paragraph-scope-ids.ts +72 -0
  85. package/src/model/review/comment-types.ts +112 -0
  86. package/src/model/review/index.ts +2 -0
  87. package/src/model/review/revision-types.ts +215 -0
  88. package/src/model/snapshot.ts +32 -0
  89. package/src/review/store/comment-store.ts +21 -47
  90. package/src/review/store/revision-types.ts +40 -198
  91. package/src/runtime/collab/base-doc-fingerprint.ts +6 -1
  92. package/src/runtime/collab/runtime-collab-sync.ts +13 -3
  93. package/src/runtime/collab-session.ts +1 -1
  94. package/src/runtime/debug/build-debug-inspector-snapshot.ts +686 -0
  95. package/src/runtime/debug/event-ring-buffer.ts +64 -0
  96. package/src/runtime/debug/probability-sampler.ts +18 -0
  97. package/src/runtime/debug/runtime-debug-facet.ts +67 -0
  98. package/src/runtime/debug/stage-tokens.ts +31 -0
  99. package/src/runtime/debug/telemetry-bus.ts +271 -0
  100. package/src/runtime/debug/types.ts +275 -0
  101. package/src/runtime/debug/wrap-ref-for-telemetry.ts +118 -0
  102. package/src/runtime/document-layout.ts +8 -6
  103. package/src/runtime/document-runtime.ts +843 -1141
  104. package/src/runtime/document-search.ts +1 -1
  105. package/src/runtime/edit-ops/index.ts +1 -1
  106. package/src/runtime/external-send-runtime.ts +1 -1
  107. package/src/runtime/formatting/document-lookup.ts +235 -0
  108. package/src/runtime/formatting/field/registry.ts +41 -0
  109. package/src/runtime/{field-resolver.ts → formatting/field/resolver.ts} +27 -2
  110. package/src/runtime/formatting/font-resolution.ts +83 -0
  111. package/src/runtime/formatting/formatting-context.ts +903 -0
  112. package/src/runtime/formatting/formatting-types.ts +157 -0
  113. package/src/runtime/{hyperlink-color-resolver.ts → formatting/hyperlink-color.ts} +2 -2
  114. package/src/runtime/formatting/index.ts +125 -0
  115. package/src/runtime/{resolved-numbering-geometry.ts → formatting/numbering/geometry.ts} +1 -1
  116. package/src/runtime/{numbering-prefix.ts → formatting/numbering/prefix.ts} +170 -3
  117. package/src/runtime/formatting/paragraph-style-resolver.ts +92 -0
  118. package/src/runtime/formatting/projector.ts +75 -0
  119. package/src/runtime/formatting/resolve-effective.ts +407 -0
  120. package/src/runtime/formatting/revision-display.ts +105 -0
  121. package/src/runtime/{paragraph-style-resolver.ts → formatting/style-cascade.ts} +84 -141
  122. package/src/runtime/{table-style-resolver.ts → formatting/table-style-resolver.ts} +1 -1
  123. package/src/runtime/formatting/telemetry-bridge.ts +106 -0
  124. package/src/runtime/{theme-color-resolver.ts → formatting/theme-color.ts} +2 -30
  125. package/src/runtime/geometry/caret-geometry.ts +164 -0
  126. package/src/runtime/geometry/geometry-facet.ts +364 -0
  127. package/src/runtime/geometry/geometry-types.ts +256 -0
  128. package/src/runtime/geometry/hit-test.ts +125 -0
  129. package/src/runtime/geometry/index.ts +71 -0
  130. package/src/runtime/geometry/inert-geometry-facet.ts +43 -0
  131. package/src/runtime/geometry/invalidation.ts +35 -0
  132. package/src/runtime/geometry/object-handles.ts +77 -0
  133. package/src/runtime/geometry/overlay-rects.ts +85 -0
  134. package/src/runtime/geometry/project-anchors.ts +100 -0
  135. package/src/runtime/geometry/project-fragments.ts +216 -0
  136. package/src/runtime/geometry/projector.ts +129 -0
  137. package/src/runtime/geometry/replacement-envelope.ts +130 -0
  138. package/src/runtime/geometry/viewport.ts +218 -0
  139. package/src/runtime/layout/compat-input-ledger.ts +211 -0
  140. package/src/runtime/layout/index.ts +6 -1
  141. package/src/runtime/layout/inert-layout-facet.ts +12 -7
  142. package/src/runtime/layout/layout-engine-instance.ts +189 -11
  143. package/src/runtime/layout/layout-engine-version.ts +450 -1
  144. package/src/runtime/layout/layout-facet-types.ts +60 -0
  145. package/src/runtime/layout/layout-measurement-provider.ts +13 -0
  146. package/src/runtime/layout/measurement-backend-canvas.ts +14 -2
  147. package/src/runtime/layout/measurement-backend-empirical.ts +23 -4
  148. package/src/runtime/layout/page-graph.ts +62 -209
  149. package/src/runtime/layout/page-story-resolver.ts +7 -12
  150. package/src/runtime/layout/paginated-layout-engine.ts +186 -11
  151. package/src/runtime/layout/project-block-fragments.ts +11 -0
  152. package/src/runtime/layout/projector.ts +90 -0
  153. package/src/runtime/layout/public-facet.ts +187 -442
  154. package/src/runtime/layout/resolved-formatting-state.ts +158 -26
  155. package/src/runtime/layout/table-render-plan.ts +1 -1
  156. package/src/runtime/prerender/cache-envelope.ts +6 -1
  157. package/src/runtime/prerender/prerender-document.ts +18 -23
  158. package/src/runtime/render/decoration-resolver.ts +1 -1
  159. package/src/runtime/render/render-frame-types.ts +20 -0
  160. package/src/runtime/render/render-kernel.ts +94 -25
  161. package/src/runtime/scopes/_formatting-seam.ts +262 -0
  162. package/src/runtime/scopes/_scope-dependencies.ts +49 -0
  163. package/src/runtime/scopes/action-validation.ts +356 -0
  164. package/src/runtime/scopes/attach-explanation.ts +102 -0
  165. package/src/runtime/scopes/audit-bundle.ts +71 -0
  166. package/src/runtime/scopes/compile-scope-bundle.ts +163 -0
  167. package/src/runtime/scopes/compile-scope.ts +262 -0
  168. package/src/runtime/scopes/compiler-service.ts +431 -0
  169. package/src/runtime/scopes/create-issue.ts +107 -0
  170. package/src/runtime/scopes/enumerate-scopes.ts +543 -0
  171. package/src/runtime/scopes/evidence.ts +233 -0
  172. package/src/runtime/scopes/index.ts +150 -0
  173. package/src/runtime/scopes/position-map.ts +214 -0
  174. package/src/runtime/scopes/preservation-boundary.ts +91 -0
  175. package/src/runtime/scopes/projector.ts +49 -0
  176. package/src/runtime/scopes/replaceability.ts +87 -0
  177. package/src/runtime/scopes/replacement/apply.ts +228 -0
  178. package/src/runtime/scopes/replacement/compile.ts +59 -0
  179. package/src/runtime/scopes/replacement/propose.ts +42 -0
  180. package/src/runtime/scopes/resolve-reference.ts +347 -0
  181. package/src/runtime/scopes/review-bundle.ts +141 -0
  182. package/src/runtime/scopes/scope-kinds/_paragraph-text.ts +57 -0
  183. package/src/runtime/scopes/scope-kinds/_table-text.ts +42 -0
  184. package/src/runtime/scopes/scope-kinds/comment-thread.ts +59 -0
  185. package/src/runtime/scopes/scope-kinds/field.ts +65 -0
  186. package/src/runtime/scopes/scope-kinds/heading.ts +84 -0
  187. package/src/runtime/scopes/scope-kinds/list-item.ts +77 -0
  188. package/src/runtime/scopes/scope-kinds/paragraph.ts +182 -0
  189. package/src/runtime/scopes/scope-kinds/revision.ts +62 -0
  190. package/src/runtime/scopes/scope-kinds/table-cell.ts +57 -0
  191. package/src/runtime/scopes/scope-kinds/table-row.ts +61 -0
  192. package/src/runtime/scopes/scope-kinds/table.ts +55 -0
  193. package/src/runtime/scopes/scope-range.ts +208 -0
  194. package/src/runtime/scopes/semantic-scope-types.ts +454 -0
  195. package/src/runtime/scopes/workflow-overlap.ts +92 -0
  196. package/src/runtime/selection/index.ts +1 -1
  197. package/src/runtime/structure-ops/fragment-insert.ts +1 -1
  198. package/src/runtime/structure-ops/index.ts +1 -1
  199. package/src/runtime/surface-projection.ts +232 -262
  200. package/src/runtime/units.ts +4 -2
  201. package/src/runtime/workflow/coordinator.ts +1348 -0
  202. package/src/runtime/workflow/derived-scope-resolver.ts +125 -0
  203. package/src/runtime/workflow/index.ts +25 -0
  204. package/src/runtime/workflow/markup-mode-policy.ts +98 -0
  205. package/src/runtime/{workflow-markup.ts → workflow/markup.ts} +6 -6
  206. package/src/runtime/workflow/metadata-persistence.ts +306 -0
  207. package/src/runtime/workflow/metadata-writer.ts +123 -0
  208. package/src/runtime/workflow/overlay-store.ts +690 -0
  209. package/src/runtime/workflow/projector.ts +127 -0
  210. package/src/runtime/{query-scopes.ts → workflow/query-scopes.ts} +3 -3
  211. package/src/runtime/{workflow-rail-segments.ts → workflow/rail/compose.ts} +60 -165
  212. package/src/runtime/workflow/rail/types.ts +198 -0
  213. package/src/runtime/workflow/scope-rail-composer.ts +39 -0
  214. package/src/runtime/{scope-resolver.ts → workflow/scope-resolver.ts} +3 -3
  215. package/src/runtime/workflow/scope-writer.ts +188 -0
  216. package/src/runtime/{tamper-gate.ts → workflow/tamper-gate.ts} +1 -1
  217. package/src/runtime/workflow/visibility-policy.ts +129 -0
  218. package/src/session/_sync-legacy.ts +66 -0
  219. package/src/session/export/embedded-reconstitute.ts +104 -0
  220. package/src/session/export/export-diagnostics.ts +85 -0
  221. package/src/session/export/export-validation.ts +110 -0
  222. package/src/session/export/index.ts +34 -0
  223. package/src/session/export/preservation-reattach.ts +30 -0
  224. package/src/session/export/serialize-dispatch.ts +165 -0
  225. package/src/session/export/stateful-export-pipeline.ts +432 -0
  226. package/src/session/export/stateful-export.ts +684 -0
  227. package/src/session/import/canonical-assembly.ts +227 -0
  228. package/src/session/import/diagnostics-session.ts +54 -0
  229. package/src/session/import/embedded-discovery.ts +225 -0
  230. package/src/session/import/embedded-offload.ts +337 -0
  231. package/src/session/import/import-diagnostics.ts +69 -0
  232. package/src/session/import/loader-types.ts +313 -0
  233. package/src/session/import/loader.ts +1834 -0
  234. package/src/session/import/normalize.ts +195 -0
  235. package/src/session/import/package-parts.ts +217 -0
  236. package/src/session/import/package-read.ts +195 -0
  237. package/src/session/import/parse-orchestration.ts +105 -0
  238. package/src/session/import/part-constants.ts +70 -0
  239. package/src/session/import/part-discovery.ts +94 -0
  240. package/src/session/import/preservation-index.ts +46 -0
  241. package/src/{runtime/read-only-diagnostics-runtime.ts → session/import/read-only-diagnostics.ts} +24 -3
  242. package/src/session/import/review-import.ts +508 -0
  243. package/src/session/import/styles-consolidation.ts +281 -0
  244. package/src/session/import/workflow-scope-import.ts +256 -0
  245. package/src/session/index.ts +37 -0
  246. package/src/session/session-state.ts +69 -0
  247. package/src/session/session.ts +532 -0
  248. package/src/session/shared/protection.ts +228 -0
  249. package/src/session/shared/session-utils.ts +82 -0
  250. package/src/session/types.ts +499 -0
  251. package/src/shell/chart-snapshots.ts +96 -0
  252. package/src/shell/media-previews.ts +85 -0
  253. package/src/shell/overlay-anchor-bridge.ts +53 -0
  254. package/src/shell/paste-adapter.ts +23 -0
  255. package/src/shell/ref-commands.ts +1697 -0
  256. package/src/shell/ref-utilities.ts +48 -0
  257. package/src/shell/search.ts +51 -0
  258. package/src/{ui/editor-runtime-boundary.ts → shell/session-bootstrap.ts} +243 -67
  259. package/src/shell/ui-subscriber-channels.ts +81 -0
  260. package/src/shell/use-collab-sync.ts +116 -0
  261. package/src/ui/WordReviewEditor.tsx +496 -2051
  262. package/src/ui/editor-shell-view.tsx +30 -1
  263. package/src/ui/editor-surface-controller.tsx +49 -1
  264. package/src/ui/headless/revision-decoration-model.ts +83 -0
  265. package/src/{ui-tailwind/chrome → ui/headless}/role-action-sets.ts +1 -1
  266. package/src/ui/headless/scoped-chrome-policy.ts +2 -2
  267. package/src/ui/headless/selection-tool-context.ts +1 -1
  268. package/src/ui/headless/selection-tool-resolver.ts +1 -1
  269. package/src/ui/runtime-shortcut-dispatch.ts +46 -1
  270. package/src/ui/ui-controller-factory.ts +221 -0
  271. package/src/ui-tailwind/chart/ChartSurface.tsx +2 -2
  272. package/src/ui-tailwind/chart/layout/legend-layout.ts +1 -1
  273. package/src/ui-tailwind/chart/layout/plot-area.ts +2 -2
  274. package/src/ui-tailwind/chart/layout/title-layout.ts +1 -1
  275. package/src/ui-tailwind/chart/render/area.tsx +3 -3
  276. package/src/ui-tailwind/chart/render/bar-column.tsx +3 -3
  277. package/src/ui-tailwind/chart/render/bubble.tsx +3 -3
  278. package/src/ui-tailwind/chart/render/combo.tsx +2 -2
  279. package/src/ui-tailwind/chart/render/data-labels.tsx +2 -2
  280. package/src/ui-tailwind/chart/render/font-metrics.ts +2 -2
  281. package/src/ui-tailwind/chart/render/line.tsx +3 -3
  282. package/src/ui-tailwind/chart/render/pie.tsx +6 -6
  283. package/src/ui-tailwind/chart/render/scatter.tsx +3 -3
  284. package/src/ui-tailwind/chart/render/svg-primitives.ts +3 -3
  285. package/src/ui-tailwind/chart/render/unsupported.tsx +2 -2
  286. package/src/ui-tailwind/chrome/build-context-menu-entries.ts +88 -0
  287. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +1 -1
  288. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +1 -1
  289. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +1 -1
  290. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +1 -1
  291. package/src/ui-tailwind/chrome/editor-action-registry.ts +553 -0
  292. package/src/ui-tailwind/chrome/editor-actions-to-palette.ts +182 -0
  293. package/src/ui-tailwind/chrome/local-surface-arbiter.ts +534 -0
  294. package/src/ui-tailwind/chrome/resolve-target-kind.ts +226 -0
  295. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +38 -4
  296. package/src/ui-tailwind/chrome/tw-context-band.tsx +125 -0
  297. package/src/ui-tailwind/chrome/tw-context-menu-portal.tsx +248 -0
  298. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +42 -1
  299. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +8 -7
  300. package/src/ui-tailwind/chrome/tw-selection-tool-blocked.tsx +38 -4
  301. package/src/ui-tailwind/chrome/tw-selection-tool-comment.tsx +104 -6
  302. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +66 -7
  303. package/src/ui-tailwind/chrome/tw-selection-tool-workflow.tsx +54 -8
  304. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +7 -1
  305. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +33 -0
  306. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +78 -1
  307. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +16 -8
  308. package/src/ui-tailwind/chrome/tw-workspace-chrome-host.tsx +276 -0
  309. package/src/ui-tailwind/chrome/use-context-menu-controller.ts +201 -0
  310. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +1 -1
  311. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +22 -4
  312. package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +1 -1
  313. package/src/ui-tailwind/chrome-overlay/tw-locked-block-layer.tsx +1 -1
  314. package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +11 -5
  315. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +197 -3
  316. package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +1 -1
  317. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +35 -6
  318. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +24 -16
  319. package/src/ui-tailwind/chrome-overlay/tw-table-continuation-header.tsx +1 -1
  320. package/src/ui-tailwind/debug/README.md +57 -0
  321. package/src/ui-tailwind/debug/index.ts +3 -0
  322. package/src/ui-tailwind/debug/tw-debug-overlay.tsx +186 -0
  323. package/src/ui-tailwind/debug/tw-debug-presentation.tsx +80 -0
  324. package/src/ui-tailwind/debug/tw-debug-top-bar.tsx +83 -0
  325. package/src/ui-tailwind/editor-surface/chart-node-view.tsx +2 -2
  326. package/src/ui-tailwind/editor-surface/float-wrap-resolver.ts +1 -1
  327. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +135 -10
  328. package/src/ui-tailwind/editor-surface/pm-decorations.ts +40 -13
  329. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +1 -1
  330. package/src/ui-tailwind/editor-surface/pm-schema.ts +1 -1
  331. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +3 -3
  332. package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +1 -1
  333. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +2 -2
  334. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +91 -9
  335. package/src/ui-tailwind/editor-surface/shape-renderer.ts +1 -1
  336. package/src/ui-tailwind/editor-surface/surface-layer.ts +1 -1
  337. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +1 -1
  338. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +23 -6
  339. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +132 -22
  340. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +1 -1
  341. package/src/ui-tailwind/index.ts +0 -5
  342. package/src/ui-tailwind/overlay-anchor-bridge-context.tsx +33 -0
  343. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +66 -29
  344. package/src/ui-tailwind/page-stack/tw-floating-image-layer.tsx +25 -2
  345. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +15 -0
  346. package/src/ui-tailwind/review/tw-review-rail.tsx +92 -4
  347. package/src/ui-tailwind/review/tw-workflow-tab.tsx +1 -1
  348. package/src/ui-tailwind/review-workspace/page-chrome.ts +210 -0
  349. package/src/ui-tailwind/review-workspace/page-shell-metrics.ts +101 -0
  350. package/src/ui-tailwind/review-workspace/paragraph-layout.ts +115 -0
  351. package/src/ui-tailwind/review-workspace/selection-toolbar-placement.ts +97 -0
  352. package/src/ui-tailwind/review-workspace/tw-review-workspace-navigator.tsx +130 -0
  353. package/src/ui-tailwind/review-workspace/tw-review-workspace-page-toolbar.tsx +240 -0
  354. package/src/ui-tailwind/review-workspace/tw-review-workspace-rail.tsx +59 -0
  355. package/src/ui-tailwind/review-workspace/types.ts +408 -0
  356. package/src/ui-tailwind/review-workspace/use-chrome-policy.ts +104 -0
  357. package/src/ui-tailwind/review-workspace/use-derived-view-state.ts +151 -0
  358. package/src/ui-tailwind/review-workspace/use-diagnostics-signal.ts +70 -0
  359. package/src/ui-tailwind/review-workspace/use-grabbed-segment-offsets.ts +40 -0
  360. package/src/ui-tailwind/review-workspace/use-layout-facet-render-signal.ts +55 -0
  361. package/src/ui-tailwind/review-workspace/use-page-markers.ts +130 -0
  362. package/src/ui-tailwind/review-workspace/use-pm-surface-capture.ts +60 -0
  363. package/src/ui-tailwind/review-workspace/use-review-rail-state.ts +63 -0
  364. package/src/ui-tailwind/review-workspace/use-scope-card-state.ts +170 -0
  365. package/src/ui-tailwind/review-workspace/use-scroll-root-capture.ts +28 -0
  366. package/src/ui-tailwind/review-workspace/use-selection-toolbar-placement.ts +113 -0
  367. package/src/ui-tailwind/review-workspace/use-shell-selection-anchor-bridge.ts +120 -0
  368. package/src/ui-tailwind/review-workspace/use-status-bar-page-facts.ts +55 -0
  369. package/src/ui-tailwind/review-workspace/use-viewport-dimensions.ts +43 -0
  370. package/src/ui-tailwind/review-workspace/use-workspace-arbiter.ts +25 -0
  371. package/src/ui-tailwind/review-workspace/use-workspace-composition.ts +86 -0
  372. package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +150 -0
  373. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  374. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +2 -2
  375. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +61 -98
  376. package/src/ui-tailwind/tw-review-workspace.tsx +521 -1802
  377. package/src/ui-tailwind/ui-api-context.tsx +43 -0
  378. package/src/ui-tailwind/ui-shell-channels-context.tsx +49 -0
  379. package/src/validation/compatibility-engine.ts +6 -6
  380. package/src/runtime/styles-cascade.ts +0 -33
  381. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +0 -85
  382. /package/src/runtime/{page-number-format.ts → formatting/field/page-number-format.ts} +0 -0
  383. /package/src/runtime/{ai-action-policy.ts → workflow/ai-action-policy.ts} +0 -0
  384. /package/src/runtime/{scope-tag-registry.ts → workflow/scope-tag-registry.ts} +0 -0
@@ -0,0 +1,534 @@
1
+ /**
2
+ * Local-surface arbiter — single authority over the editor's floating
3
+ * surfaces.
4
+ *
5
+ * Problem (audit §2.7 + RC-3): the editor mounts many floating
6
+ * surfaces — scope cards, suggestion cards, blocked-command cards,
7
+ * workflow cards, comment previews, selection-format tools, right-click
8
+ * context menus. Before this arbiter landed the only coordinator was
9
+ * the 150ms dwell timer inside `tw-selection-tool-host.tsx` (which only
10
+ * kept the selection toolbar from thrashing). Overlapping requests
11
+ * from peer surfaces regressed into visual thrash — two floats visible
12
+ * at once, or a high-priority surface losing to a lower-priority peer
13
+ * that rendered first.
14
+ *
15
+ * Contract:
16
+ * 1. At most ONE non-pinned surface is active per frame.
17
+ * 2. Priority is fixed and declared in `LOCAL_SURFACE_PRIORITY`;
18
+ * a higher-priority request displaces the current active peer and
19
+ * fires the displaced handle's `onDismiss` callback.
20
+ * 3. Equal-priority requests respect insertion order — first request
21
+ * wins; the later one is queued. Dismissing the active surface
22
+ * promotes the highest queued peer.
23
+ * 4. A PINNED surface is tracked separately from the single active
24
+ * slot. Pinned scope cards may coexist with any other active
25
+ * surface (e.g. the rail detail) — they are orthogonal slots.
26
+ * Only one pinned surface at a time; a second pinned request
27
+ * displaces the prior pin.
28
+ * 5. Subscribers are notified via a single rAF-coalesced tick —
29
+ * 50 `request()` calls in one synchronous block fire listeners
30
+ * exactly once. This is perf invariant #4: no widening of the
31
+ * wholesale-snapshot path.
32
+ * 6. The arbiter itself NEVER subscribes to PM transactions. It
33
+ * consumes user-event-driven `request()` calls only.
34
+ *
35
+ * Surface landscape (audit §2.7):
36
+ *
37
+ * scope-card > suggestion-card > blocked-card > workflow-card >
38
+ * comment-preview > selection-format > context-menu
39
+ *
40
+ * Usage: callers create a request and gate rendering on the returned
41
+ * handle's `isActive()` flag. Dismiss the handle (explicitly or on
42
+ * unmount) to release the slot.
43
+ */
44
+
45
+ import {
46
+ createContext,
47
+ useContext,
48
+ useEffect,
49
+ useRef,
50
+ useSyncExternalStore,
51
+ } from "react";
52
+
53
+ export type LocalSurfaceKind =
54
+ | "scope-card"
55
+ | "suggestion-card"
56
+ | "blocked-card"
57
+ | "workflow-card"
58
+ | "comment-preview"
59
+ | "selection-format"
60
+ | "context-menu";
61
+
62
+ /**
63
+ * Priority ladder per audit §2.7. Higher number wins. Tweaking this
64
+ * table is a design contract change — update the audit and the docs
65
+ * before the code.
66
+ */
67
+ export const LOCAL_SURFACE_PRIORITY: Readonly<Record<LocalSurfaceKind, number>> =
68
+ Object.freeze({
69
+ "scope-card": 7,
70
+ "suggestion-card": 6,
71
+ "blocked-card": 5,
72
+ "workflow-card": 4,
73
+ "comment-preview": 3,
74
+ "selection-format": 2,
75
+ "context-menu": 1,
76
+ });
77
+
78
+ export interface LocalSurfaceRequest {
79
+ readonly kind: LocalSurfaceKind;
80
+ /**
81
+ * Optional caller-provided id. Defaulted to a monotonic token when
82
+ * omitted. Useful for tests + audit tooling that wants to correlate
83
+ * a rendered surface with its request.
84
+ */
85
+ readonly id?: string;
86
+ /**
87
+ * When true, the surface is tracked in the pinned slot instead of the
88
+ * single active slot — it coexists with any other active surface.
89
+ * Used for pinned scope cards.
90
+ */
91
+ readonly pinned?: boolean;
92
+ /**
93
+ * Called when the arbiter releases the slot via priority displacement,
94
+ * `dismissTopmost`, or `dismissAll`. NOT called when the caller
95
+ * voluntarily invokes `handle.dismiss()` — that path is self-managed
96
+ * by the caller.
97
+ */
98
+ readonly onDismiss?: () => void;
99
+ }
100
+
101
+ export interface ArbiterState {
102
+ readonly activeKind: LocalSurfaceKind | null;
103
+ readonly activeId: string | null;
104
+ readonly pinnedKind: LocalSurfaceKind | null;
105
+ readonly pinnedId: string | null;
106
+ }
107
+
108
+ export interface LocalSurfaceHandle {
109
+ readonly kind: LocalSurfaceKind;
110
+ readonly id: string;
111
+ readonly pinned: boolean;
112
+ /** Whether this handle currently owns the active slot (or a pinned slot). */
113
+ isActive(): boolean;
114
+ /** Release this handle's slot without calling onDismiss. */
115
+ dismiss(): void;
116
+ }
117
+
118
+ export interface LocalSurfaceArbiter {
119
+ /** Submit a surface request. Returns a handle for gating + release. */
120
+ request(req: LocalSurfaceRequest): LocalSurfaceHandle;
121
+ /** Subscribe to arbiter state changes. Returns an unsubscribe fn. */
122
+ subscribe(listener: (state: ArbiterState) => void): () => void;
123
+ /** Synchronous state snapshot. */
124
+ getState(): ArbiterState;
125
+ /** Dismiss the non-pinned active slot; leaves pinned surface intact. */
126
+ dismissTopmost(): void;
127
+ /** Dismiss every tracked surface, pinned and active. */
128
+ dismissAll(): void;
129
+ }
130
+
131
+ interface InternalEntry {
132
+ /**
133
+ * Populated immediately after the entry is constructed — see the
134
+ * `request()` initialisation sequence. Typed as `LocalSurfaceHandle
135
+ * | undefined` rather than a non-null cast so no read site silently
136
+ * dereferences a pre-assignment entry (M2 — type safety).
137
+ */
138
+ handle: LocalSurfaceHandle | undefined;
139
+ readonly kind: LocalSurfaceKind;
140
+ readonly id: string;
141
+ readonly pinned: boolean;
142
+ readonly onDismiss?: () => void;
143
+ readonly sequence: number;
144
+ released: boolean;
145
+ }
146
+
147
+ /**
148
+ * rAF-coalesced dispatcher. All state mutations enqueue a single
149
+ * dispatch in the next animation frame; synchronous callers see the
150
+ * updated `getState()` immediately, but subscribers receive one tick
151
+ * per frame regardless of how many mutations happened.
152
+ *
153
+ * We check `typeof` before using `requestAnimationFrame` because the
154
+ * arbiter runs in test environments (node:test) that do not expose rAF.
155
+ * In that case we fall back to a microtask; tests can await it with
156
+ * `setTimeout(0)` and see exactly one notification per synchronous
157
+ * burst. This mirrors the pattern in
158
+ * `tw-page-stack-overlay-layer.tsx:327-344`.
159
+ */
160
+ function scheduleFrame(cb: () => void): void {
161
+ if (typeof globalThis.requestAnimationFrame === "function") {
162
+ globalThis.requestAnimationFrame(cb);
163
+ return;
164
+ }
165
+ // Fallback: microtask. Tests and non-browser runtimes take this
166
+ // path; production never does because DOM always exposes rAF.
167
+ queueMicrotask(cb);
168
+ }
169
+
170
+ let sequenceCounter = 0;
171
+ function nextSequence(): number {
172
+ sequenceCounter += 1;
173
+ return sequenceCounter;
174
+ }
175
+
176
+ export function createLocalSurfaceArbiter(): LocalSurfaceArbiter {
177
+ const entries: InternalEntry[] = [];
178
+ const listeners = new Set<(state: ArbiterState) => void>();
179
+ let pendingFrame = false;
180
+ // Last state emitted to subscribers — used to suppress redundant
181
+ // ticks when a mutation doesn't change the computed `ArbiterState`
182
+ // (N1 — a lower-priority request that simply queues behind an
183
+ // existing active would otherwise fire one subscriber tick per
184
+ // request even though no consumer's `isActive()` changed).
185
+ let lastEmittedSnapshot: string | null = null;
186
+
187
+ // Consumer `onDismiss` callbacks typically do `setState` on the
188
+ // dismissed surface's host (see `useContextMenuController.dismiss`).
189
+ // Priority displacement fires during another component's render
190
+ // (the requester that owns the slot now) because `useLocalSurfaceRequest`
191
+ // issues the request synchronously in render body to get first-paint
192
+ // correct behavior. A synchronous `setState` across components during
193
+ // render triggers React's "Cannot update a component while rendering
194
+ // a different component" warning and can be dropped in concurrent
195
+ // retry passes. Defer every consumer-triggered `onDismiss` by one
196
+ // microtask — the arbiter state itself already updated synchronously
197
+ // (entry.released = true), so the next subscriber tick + next
198
+ // `isActive()` read already see the correct verdict; only the
199
+ // cross-component state sync needs the microtask boundary.
200
+ function scheduleOnDismiss(cb: () => void): void {
201
+ queueMicrotask(cb);
202
+ }
203
+
204
+ function recomputeState(): ArbiterState {
205
+ let pinned: InternalEntry | null = null;
206
+ let active: InternalEntry | null = null;
207
+ for (const entry of entries) {
208
+ if (entry.released) continue;
209
+ if (entry.pinned) {
210
+ if (!pinned || entry.sequence > pinned.sequence) {
211
+ pinned = entry;
212
+ }
213
+ }
214
+ }
215
+ for (const entry of entries) {
216
+ if (entry.released || entry.pinned) continue;
217
+ if (!active) {
218
+ active = entry;
219
+ continue;
220
+ }
221
+ const activeP = LOCAL_SURFACE_PRIORITY[active.kind];
222
+ const candidateP = LOCAL_SURFACE_PRIORITY[entry.kind];
223
+ if (
224
+ candidateP > activeP ||
225
+ (candidateP === activeP && entry.sequence < active.sequence)
226
+ ) {
227
+ active = entry;
228
+ }
229
+ }
230
+ return {
231
+ activeKind: active ? active.kind : null,
232
+ activeId: active ? active.id : null,
233
+ pinnedKind: pinned ? pinned.kind : null,
234
+ pinnedId: pinned ? pinned.id : null,
235
+ };
236
+ }
237
+
238
+ function scheduleDispatch(): void {
239
+ if (pendingFrame) return;
240
+ pendingFrame = true;
241
+ scheduleFrame(() => {
242
+ pendingFrame = false;
243
+ const state = recomputeState();
244
+ // N1 — skip the subscriber tick when the snapshot is identical
245
+ // to the last one we emitted. Subscribers only need wake-ups on
246
+ // observable state transitions. Cheap stable serialization
247
+ // because the snapshot has exactly four primitive fields.
248
+ const serialized = `${state.activeKind ?? ""}:${state.activeId ?? ""}:${state.pinnedKind ?? ""}:${state.pinnedId ?? ""}`;
249
+ if (serialized === lastEmittedSnapshot) return;
250
+ lastEmittedSnapshot = serialized;
251
+ for (const listener of listeners) {
252
+ listener(state);
253
+ }
254
+ });
255
+ }
256
+
257
+ function currentHolders(): { active: InternalEntry | null; pinned: InternalEntry | null } {
258
+ const state = recomputeState();
259
+ const active =
260
+ entries.find((e) => !e.released && !e.pinned && e.id === state.activeId) ?? null;
261
+ const pinned =
262
+ entries.find((e) => !e.released && e.pinned && e.id === state.pinnedId) ?? null;
263
+ return { active, pinned };
264
+ }
265
+
266
+ function request(req: LocalSurfaceRequest): LocalSurfaceHandle {
267
+ const id = req.id ?? `local-surface-${nextSequence()}`;
268
+ const pinned = req.pinned ?? false;
269
+
270
+ // A new pinned request displaces any prior pinned entry — invariant
271
+ // "only one pinned surface at a time" per §2.7. Consumer onDismiss
272
+ // is deferred via `scheduleOnDismiss` to avoid cross-component
273
+ // setState during the requester's render (see C1 fix above).
274
+ if (pinned) {
275
+ for (const existing of entries) {
276
+ if (existing.released || !existing.pinned) continue;
277
+ existing.released = true;
278
+ const dismissCb = existing.onDismiss;
279
+ if (dismissCb) scheduleOnDismiss(dismissCb);
280
+ }
281
+ }
282
+
283
+ // For the non-pinned slot, priority rules apply. A higher-priority
284
+ // request displaces the current active; lower-priority requests
285
+ // simply queue and `isActive` returns false until they win.
286
+ if (!pinned) {
287
+ const { active } = currentHolders();
288
+ if (active) {
289
+ const activeP = LOCAL_SURFACE_PRIORITY[active.kind];
290
+ const candidateP = LOCAL_SURFACE_PRIORITY[req.kind];
291
+ if (candidateP > activeP) {
292
+ active.released = true;
293
+ const dismissCb = active.onDismiss;
294
+ if (dismissCb) scheduleOnDismiss(dismissCb);
295
+ }
296
+ }
297
+ }
298
+
299
+ const entry: InternalEntry = {
300
+ kind: req.kind,
301
+ id,
302
+ pinned,
303
+ sequence: nextSequence(),
304
+ released: false,
305
+ ...(req.onDismiss ? { onDismiss: req.onDismiss } : {}),
306
+ // Assigned immediately below. Typed as optional so no read path
307
+ // silently dereferences a pre-assignment entry.
308
+ handle: undefined,
309
+ };
310
+
311
+ const handle: LocalSurfaceHandle = {
312
+ kind: req.kind,
313
+ id,
314
+ pinned,
315
+ isActive: () => {
316
+ if (entry.released) return false;
317
+ const state = recomputeState();
318
+ return entry.pinned ? state.pinnedId === id : state.activeId === id;
319
+ },
320
+ dismiss: () => {
321
+ if (entry.released) return;
322
+ entry.released = true;
323
+ scheduleDispatch();
324
+ },
325
+ };
326
+ entry.handle = handle;
327
+
328
+ entries.push(entry);
329
+ scheduleDispatch();
330
+ return handle;
331
+ }
332
+
333
+ function subscribe(listener: (state: ArbiterState) => void): () => void {
334
+ listeners.add(listener);
335
+ return () => {
336
+ listeners.delete(listener);
337
+ };
338
+ }
339
+
340
+ function dismissTopmost(): void {
341
+ const { active } = currentHolders();
342
+ if (active) {
343
+ active.released = true;
344
+ const dismissCb = active.onDismiss;
345
+ if (dismissCb) scheduleOnDismiss(dismissCb);
346
+ scheduleDispatch();
347
+ }
348
+ }
349
+
350
+ function dismissAll(): void {
351
+ let changed = false;
352
+ for (const entry of entries) {
353
+ if (entry.released) continue;
354
+ entry.released = true;
355
+ const dismissCb = entry.onDismiss;
356
+ if (dismissCb) scheduleOnDismiss(dismissCb);
357
+ changed = true;
358
+ }
359
+ if (changed) scheduleDispatch();
360
+ }
361
+
362
+ // Cache the most recent state snapshot so successive `getState()` calls
363
+ // return reference-equal results — required by `useSyncExternalStore`,
364
+ // which calls `getSnapshot` on every render and bails out on
365
+ // `Object.is` equality. Without this cache, `useLocalSurfaceArbiterState`
366
+ // (Chrome Closure Pass · Task 2) re-renders on every commit and the
367
+ // host hits "Maximum update depth exceeded".
368
+ let cachedSnapshot: ArbiterState | null = null;
369
+ let cachedSnapshotKey: string | null = null;
370
+
371
+ function getStateCached(): ArbiterState {
372
+ const fresh = recomputeState();
373
+ const key = `${fresh.activeKind ?? ""}:${fresh.activeId ?? ""}:${fresh.pinnedKind ?? ""}:${fresh.pinnedId ?? ""}`;
374
+ if (cachedSnapshot && cachedSnapshotKey === key) {
375
+ return cachedSnapshot;
376
+ }
377
+ cachedSnapshot = fresh;
378
+ cachedSnapshotKey = key;
379
+ return fresh;
380
+ }
381
+
382
+ return {
383
+ request,
384
+ subscribe,
385
+ getState: getStateCached,
386
+ dismissTopmost,
387
+ dismissAll,
388
+ };
389
+ }
390
+
391
+ /**
392
+ * App-wide default arbiter — fallback for consumers that render outside
393
+ * the `LocalSurfaceArbiterContext.Provider` wrapped by `TwReviewWorkspace`.
394
+ *
395
+ * **Production contract:** the workspace always supplies a per-instance
396
+ * arbiter via context, so every in-tree chrome consumer gets workspace
397
+ * isolation for free. The default singleton exists for two narrow
398
+ * cases:
399
+ * 1. unit tests that render a consumer component directly without
400
+ * wrapping it in the provider;
401
+ * 2. host integrations that mount chrome components outside the
402
+ * `TwReviewWorkspace` subtree (rare, but supported).
403
+ *
404
+ * **Known limitation:** multiple `TwReviewWorkspace` instances on the
405
+ * same page each create their own arbiter (isolated); any two
406
+ * components that fall through to this default instead of a provider
407
+ * will share its slot table — symptoms are cross-editor priority
408
+ * displacement. Wrap every mount point in a provider to avoid.
409
+ */
410
+ export const defaultLocalSurfaceArbiter: LocalSurfaceArbiter =
411
+ createLocalSurfaceArbiter();
412
+
413
+ /**
414
+ * React context for the per-editor arbiter. `TwReviewWorkspace`
415
+ * creates a stable instance (via `useRef`) and provides it here so
416
+ * every local surface inside the workspace coordinates through the
417
+ * same object. Consumers read via `useLocalSurfaceArbiter()` which
418
+ * falls back to the default singleton when no provider exists.
419
+ */
420
+ export const LocalSurfaceArbiterContext =
421
+ createContext<LocalSurfaceArbiter | null>(null);
422
+
423
+ /**
424
+ * Resolve the arbiter from context, falling back to the default
425
+ * singleton. This is the canonical entry point for floating-surface
426
+ * consumers.
427
+ */
428
+ export function useLocalSurfaceArbiter(): LocalSurfaceArbiter {
429
+ return useContext(LocalSurfaceArbiterContext) ?? defaultLocalSurfaceArbiter;
430
+ }
431
+
432
+ /**
433
+ * Subscribe to arbiter state for read-only consumers (Chrome Closure
434
+ * Pass · Task 2). Returns the current `ArbiterState` and re-renders
435
+ * the consumer when it changes. Used by `TwContextMenuPortal` /
436
+ * `TwContextMenu` to project active + pinned `LocalSurfaceKind` into
437
+ * the menu's `ContextMenuContext` for dedupe-without-suppression
438
+ * (designsystem.md §6.24 — context menu must not vanish when local
439
+ * chrome is visible).
440
+ */
441
+ export function useLocalSurfaceArbiterState(): ArbiterState {
442
+ const arbiter = useLocalSurfaceArbiter();
443
+ return useSyncExternalStore(
444
+ arbiter.subscribe,
445
+ arbiter.getState,
446
+ arbiter.getState,
447
+ );
448
+ }
449
+
450
+ /**
451
+ * Declarative hook: given a request (or `null` when no surface is
452
+ * needed) returns whether the consumer currently owns its slot. The
453
+ * hook requests synchronously on first render so initial paint already
454
+ * reflects the arbiter's verdict — callers that read the rendered DOM
455
+ * in the same frame (e.g. imperative layoutEffect positioners) see the
456
+ * final active state, not the lag-one-render state that a plain
457
+ * `useState` + `useEffect` pair would produce.
458
+ *
459
+ * Passing `null` releases any prior handle. The hook intentionally
460
+ * reads the arbiter from context, not from a parameter, so tests that
461
+ * want isolation wrap consumers in a `LocalSurfaceArbiterContext.Provider`
462
+ * with a fresh arbiter.
463
+ */
464
+ export function useLocalSurfaceRequest(
465
+ request: LocalSurfaceRequest | null,
466
+ ): boolean {
467
+ const arbiter = useLocalSurfaceArbiter();
468
+
469
+ // Request shape we last issued to the arbiter. When the caller passes
470
+ // a new (kind, id, pinned) triple the hook dismisses the prior handle
471
+ // and issues a fresh one — synchronously, inside render — so the first
472
+ // paint sees the real verdict. Side effects in render are usually
473
+ // forbidden, but this is idempotent: repeated calls with the same key
474
+ // skip the request, and strict-mode double-invocation dismisses the
475
+ // spare on the cleanup pass.
476
+ //
477
+ // I3 fix — the dedup key intentionally excludes `onDismiss` identity
478
+ // so a consumer that rebuilds the callback per render doesn't thrash
479
+ // the arbiter. To prevent stale-closure leaks, the hook keeps the
480
+ // consumer's latest `onDismiss` in a ref and forwards it via a stable
481
+ // wrapper that the arbiter holds — the arbiter's view of "onDismiss"
482
+ // always reads through the ref, so priority-displacement callbacks
483
+ // see the freshest closure.
484
+ const handleRef = useRef<LocalSurfaceHandle | null>(null);
485
+ const onDismissRef = useRef<(() => void) | undefined>(request?.onDismiss);
486
+ onDismissRef.current = request?.onDismiss;
487
+ const currentKey =
488
+ request === null
489
+ ? null
490
+ : `${request.kind}:${request.id ?? ""}:${request.pinned ? "p" : "a"}`;
491
+ const lastKeyRef = useRef<string | null>(null);
492
+
493
+ if (currentKey !== lastKeyRef.current) {
494
+ handleRef.current?.dismiss();
495
+ if (request !== null) {
496
+ const { onDismiss: _consumerOnDismiss, ...rest } = request;
497
+ handleRef.current = arbiter.request({
498
+ ...rest,
499
+ onDismiss: () => onDismissRef.current?.(),
500
+ });
501
+ } else {
502
+ handleRef.current = null;
503
+ }
504
+ lastKeyRef.current = currentKey;
505
+ }
506
+
507
+ // Unmount cleanup — React runs this on component unmount, so the
508
+ // handle releases its arbiter slot. We re-issue in the render body
509
+ // above when `currentKey` changes, so this effect has an empty dep
510
+ // list intentionally.
511
+ useEffect(() => {
512
+ return () => {
513
+ handleRef.current?.dismiss();
514
+ handleRef.current = null;
515
+ lastKeyRef.current = null;
516
+ };
517
+ }, []);
518
+
519
+ // Subscribe via `useSyncExternalStore` so React suspends on arbiter
520
+ // state and paints the correct value on the first frame.
521
+ //
522
+ // The server snapshot reads the same `handleRef.current?.isActive()`
523
+ // as the client — the request is issued synchronously in the render
524
+ // body above, so the handle already exists when SSR / static rendering
525
+ // (e.g. `renderToStaticMarkup`) asks for the initial state. Returning
526
+ // `false` unconditionally here would cause static renders to skip
527
+ // every arbiter-gated consumer (regressed rail-scope-sync in Phase G
528
+ // full-suite run).
529
+ return useSyncExternalStore(
530
+ (listener) => arbiter.subscribe(listener),
531
+ () => handleRef.current?.isActive() ?? false,
532
+ () => handleRef.current?.isActive() ?? false,
533
+ );
534
+ }