@beyondwork/docx-react-component 1.0.71 → 1.0.73

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 (87) hide show
  1. package/README.md +964 -75
  2. package/package.json +1 -1
  3. package/src/api/public-types.ts +280 -1
  4. package/src/api/v3/_create.ts +16 -1
  5. package/src/api/v3/_runtime-handle.ts +2 -0
  6. package/src/api/v3/ai/evaluate.ts +113 -0
  7. package/src/api/v3/ai/outline.ts +140 -0
  8. package/src/api/v3/ai/policy.ts +31 -0
  9. package/src/api/v3/ai/replacement.ts +8 -0
  10. package/src/api/v3/ai/review.ts +342 -0
  11. package/src/api/v3/ai/stats.ts +62 -0
  12. package/src/api/v3/runtime/viewport.ts +181 -0
  13. package/src/api/v3/runtime/workflow.ts +114 -1
  14. package/src/api/v3/ui/_types.ts +35 -0
  15. package/src/api/v3/ui/chrome-preset-model.ts +6 -0
  16. package/src/api/v3/ui/index.ts +1 -0
  17. package/src/api/v3/ui/viewport.ts +112 -0
  18. package/src/compare/diff-engine.ts +2 -0
  19. package/src/core/commands/formatting-commands.ts +1 -0
  20. package/src/core/commands/table-structure-commands.ts +1 -0
  21. package/src/core/state/editor-state.ts +49 -6
  22. package/src/io/export/serialize-footnotes.ts +6 -0
  23. package/src/io/export/serialize-headers-footers.ts +7 -0
  24. package/src/io/export/serialize-main-document.ts +20 -0
  25. package/src/io/export/serialize-paragraph-formatting.ts +34 -0
  26. package/src/io/export/split-review-boundaries.ts +1 -0
  27. package/src/io/normalize/normalize-text.ts +49 -2
  28. package/src/io/ooxml/parse-headers-footers.ts +31 -0
  29. package/src/io/ooxml/parse-main-document.ts +148 -7
  30. package/src/io/ooxml/parse-paragraph-formatting.ts +105 -0
  31. package/src/model/canonical-document.ts +401 -1
  32. package/src/runtime/formatting/formatting-context.ts +2 -1
  33. package/src/runtime/geometry/overlay-rects.ts +7 -10
  34. package/src/runtime/layout/layout-engine-version.ts +278 -1
  35. package/src/runtime/layout/paginated-layout-engine.ts +181 -8
  36. package/src/runtime/layout/resolved-formatting-state.ts +108 -13
  37. package/src/runtime/markdown-sanitizer.ts +21 -4
  38. package/src/runtime/render/render-kernel.ts +21 -1
  39. package/src/runtime/scopes/action-validation.ts +30 -4
  40. package/src/runtime/scopes/audit-bundle.ts +8 -0
  41. package/src/runtime/scopes/compiler-service.ts +1 -0
  42. package/src/runtime/scopes/enumerate-scopes.ts +61 -3
  43. package/src/runtime/scopes/replacement/apply.ts +50 -3
  44. package/src/runtime/scopes/scope-kinds/paragraph.ts +170 -7
  45. package/src/runtime/scopes/semantic-scope-types.ts +27 -0
  46. package/src/runtime/surface-projection.ts +77 -0
  47. package/src/runtime/workflow/coordinator.ts +3 -0
  48. package/src/runtime/workflow/scope-writer.ts +34 -0
  49. package/src/session/export/embedded-reconstitute.ts +37 -3
  50. package/src/session/import/embedded-offload.ts +26 -1
  51. package/src/session/import/loader-types.ts +18 -0
  52. package/src/session/import/loader.ts +2 -0
  53. package/src/shell/media-previews.ts +8 -6
  54. package/src/ui/WordReviewEditor.tsx +1 -0
  55. package/src/ui/editor-surface-controller.tsx +11 -0
  56. package/src/ui/headless/selection-helpers.ts +2 -2
  57. package/src/ui/runtime-shortcut-dispatch.ts +4 -4
  58. package/src/ui-tailwind/chrome/tw-runtime-repl-dialog.tsx +22 -4
  59. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +11 -11
  60. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -1
  61. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +5 -0
  62. package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +18 -1
  63. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +22 -6
  64. package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +18 -1
  65. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +98 -3
  66. package/src/ui-tailwind/editor-surface/pm-schema.ts +50 -4
  67. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +6 -0
  68. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +8 -1
  69. package/src/ui-tailwind/editor-surface/search-plugin.ts +2 -4
  70. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +114 -0
  71. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +12 -4
  72. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +29 -4
  73. package/src/ui-tailwind/index.ts +4 -2
  74. package/src/ui-tailwind/page-chrome-model.ts +5 -7
  75. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +54 -34
  76. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +4 -1
  77. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +4 -1
  78. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +10 -1
  79. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +8 -1
  80. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +11 -1
  81. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +7 -1
  82. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +139 -10
  83. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +1 -1
  84. package/src/ui-tailwind/review-workspace/page-chrome.ts +4 -4
  85. package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +1 -1
  86. package/src/ui-tailwind/theme/editor-theme.css +15 -16
  87. package/src/ui-tailwind/tw-review-workspace.tsx +22 -14
@@ -0,0 +1,181 @@
1
+ /**
2
+ * @endStateApi v3 — `runtime.viewport` family.
3
+ *
4
+ * Closes coord-07 §2.9 / coord-10 §γ — page-anchor + page-geometry
5
+ * primitives the visual-fidelity harness (and any future "go to page N"
6
+ * host) consumes through `ui.viewport.scrollToPage(n)`.
7
+ *
8
+ * Background (coord-07 §2.9 / coord-10 §γ filed 2026-04-23). The
9
+ * visual-fidelity harness approximated page navigation via PM
10
+ * `[data-page-frame='N']` boundary widgets + `scrollIntoView`. Three
11
+ * problems surfaced:
12
+ * 1. Widget naming drifted once already (`data-page-index` → `data-page-frame`).
13
+ * 2. Boundary widgets are zero-height strips between pages, not on
14
+ * page content — scrolling one to viewport-top parks the camera
15
+ * between pages.
16
+ * 3. Page 1 + final page have no boundary widget (only N-1 widgets for
17
+ * N pages).
18
+ *
19
+ * A runtime-owned primitive backed by the layout page graph eliminates
20
+ * both the chrome-drift risk and the gap-vs-content problem. Pure
21
+ * reads; `stateClass: "A-canonical"` + `persistsTo: "canonical"` — the
22
+ * layout facet is derived canonical state.
23
+ *
24
+ * `elementId` stays `null` until L11's per-page content wrapper (coord-11
25
+ * §19 / §P) lands with a stable `data-page-frame-start="page-<section>-<page>"`
26
+ * id. Until then callers rely on `scrollY` / `pageRect` for positioning.
27
+ */
28
+
29
+ import type { RuntimeApiHandle } from "../_runtime-handle.ts";
30
+ import type { ApiV3FnMetadata } from "../_layer-metadata.ts";
31
+ import { DEFAULT_PX_PER_TWIP } from "../../public-types.ts";
32
+
33
+ /**
34
+ * Resolves a 1-based page number to a stable scroll target.
35
+ *
36
+ * - `elementId`: DOM id L11 populates on the per-page content wrapper.
37
+ * Returns `null` while the L11 wrapper contract (coord-11 §19 / §P)
38
+ * is still in flight — callers fall back to `scrollY`.
39
+ * - `scrollY`: scroll-container-relative Y offset of the page's top
40
+ * edge in CSS px. Computed as the sum of prior pages' heights via
41
+ * the layout facet's page graph.
42
+ * - `pageRect`: the page's content rectangle in the scroll container's
43
+ * coordinate space. `top === scrollY`; `left === 0`; `width/height`
44
+ * projected from the page's canonical `pageWidth` / `pageHeight`
45
+ * twips via `DEFAULT_PX_PER_TWIP`.
46
+ */
47
+ export interface PageAnchor {
48
+ readonly elementId: string | null;
49
+ readonly scrollY: number;
50
+ readonly pageRect: {
51
+ readonly left: number;
52
+ readonly top: number;
53
+ readonly width: number;
54
+ readonly height: number;
55
+ } | null;
56
+ }
57
+
58
+ /**
59
+ * Per-page geometry for deterministic capture + truth-comparison.
60
+ * Stable across layout-recompute re-runs within the same
61
+ * `LAYOUT_ENGINE_VERSION`.
62
+ */
63
+ export interface PageGeometry {
64
+ readonly widthPx: number;
65
+ readonly heightPx: number;
66
+ readonly marginsPx: {
67
+ readonly top: number;
68
+ readonly right: number;
69
+ readonly bottom: number;
70
+ readonly left: number;
71
+ };
72
+ /** CSS px-per-inch honoring the default kernel conversion (96 / 1440 × 1440 = 96). */
73
+ readonly dpi: number;
74
+ }
75
+
76
+ /* ================================================================== */
77
+ /* getPageAnchor */
78
+ /* ================================================================== */
79
+
80
+ export const getPageAnchorMetadata: ApiV3FnMetadata = {
81
+ name: "runtime.viewport.getPageAnchor",
82
+ status: "live-with-adapter",
83
+ sourceLayer: "layout-semantics",
84
+ liveEvidence: {
85
+ runnerTest: "test/api/v3/runtime/viewport-page-anchor.test.ts",
86
+ commit: "refactor-07-viewport-family-2026-04-24",
87
+ },
88
+ uxIntent: { uiVisible: false, expectsUxResponse: "none" },
89
+ agentMetadata: {
90
+ readOrMutate: "read",
91
+ boundedScope: "document",
92
+ auditCategory: "viewport-read",
93
+ },
94
+ stateClass: "A-canonical",
95
+ persistsTo: "canonical",
96
+ rwdReference:
97
+ "§Runtime API § runtime.viewport.getPageAnchor. Reads the kernel-authoritative page frame from `handle.geometry.getPage(index).frame` — same source `ui.viewport.scrollToPage(n)` consumes (coord-10 §γ shipment `b8116b97`), so values stay consistent across the two surfaces. `elementId` stays null until L11's per-page content wrapper (coord-11 §19 / §P) lands; until then callers position off `scrollY` / `pageRect`. Promotes to `live` when the geometry facet surfaces a direct `getPageAnchor(pageNumber)` reader or when elementId is populated.",
98
+ };
99
+
100
+ /* ================================================================== */
101
+ /* getPageGeometry */
102
+ /* ================================================================== */
103
+
104
+ export const getPageGeometryMetadata: ApiV3FnMetadata = {
105
+ name: "runtime.viewport.getPageGeometry",
106
+ status: "live-with-adapter",
107
+ sourceLayer: "layout-semantics",
108
+ liveEvidence: {
109
+ runnerTest: "test/api/v3/runtime/viewport-page-anchor.test.ts",
110
+ commit: "refactor-07-viewport-family-2026-04-24",
111
+ },
112
+ uxIntent: { uiVisible: false, expectsUxResponse: "none" },
113
+ agentMetadata: {
114
+ readOrMutate: "read",
115
+ boundedScope: "document",
116
+ auditCategory: "viewport-read",
117
+ },
118
+ stateClass: "A-canonical",
119
+ persistsTo: "canonical",
120
+ rwdReference:
121
+ "§Runtime API § runtime.viewport.getPageGeometry. Projects the selected page's `PageLayoutSnapshot` (pageWidth/Height + margins, all in twips) into CSS px via `DEFAULT_PX_PER_TWIP`. `dpi: 96` matches the kernel's default. Promotes to `live` when the layout facet projects PageLayoutSnapshot in px directly.",
122
+ };
123
+
124
+ /* ================================================================== */
125
+ /* family factory */
126
+ /* ================================================================== */
127
+
128
+ export function createViewportFamily(runtime: RuntimeApiHandle) {
129
+ const layout = runtime.layout;
130
+
131
+ return {
132
+ getPageAnchor(pageNumber: number): PageAnchor | null {
133
+ // @endStateApi — live-with-adapter. Reads the kernel-authoritative
134
+ // page frame from `handle.geometry.getPage(index)` — same source
135
+ // `ui.viewport.scrollToPage(n)` (coord-10 §γ shipment `b8116b97`)
136
+ // consumes. Geometry's `frame.topPx` includes page gaps + rounding
137
+ // the kernel applies, matching the DOM scroll-container's actual
138
+ // Y. Layout-only pageHeight summation would omit page gaps and
139
+ // diverge from both the DOM and the ui.viewport.scrollToPage return.
140
+ if (!layout) return null;
141
+ if (!Number.isInteger(pageNumber) || pageNumber < 1) return null;
142
+ if (pageNumber > layout.getPageCount()) return null;
143
+ const pageIndex = pageNumber - 1;
144
+ const geometry = runtime.geometry;
145
+ const page = geometry?.getPage(pageIndex);
146
+ if (!page) return null;
147
+ return {
148
+ elementId: null,
149
+ scrollY: page.frame.topPx,
150
+ pageRect: {
151
+ left: page.frame.leftPx,
152
+ top: page.frame.topPx,
153
+ width: page.frame.widthPx,
154
+ height: page.frame.heightPx,
155
+ },
156
+ };
157
+ },
158
+
159
+ getPageGeometry(pageNumber: number): PageGeometry | null {
160
+ // @endStateApi — live-with-adapter.
161
+ if (!layout) return null;
162
+ if (!Number.isInteger(pageNumber) || pageNumber < 1) return null;
163
+ const pageCount = layout.getPageCount();
164
+ if (pageNumber > pageCount) return null;
165
+ const page = layout.getPage(pageNumber - 1);
166
+ if (!page) return null;
167
+ const lay = page.layout;
168
+ return {
169
+ widthPx: lay.pageWidth * DEFAULT_PX_PER_TWIP,
170
+ heightPx: lay.pageHeight * DEFAULT_PX_PER_TWIP,
171
+ marginsPx: {
172
+ top: lay.marginTop * DEFAULT_PX_PER_TWIP,
173
+ right: lay.marginRight * DEFAULT_PX_PER_TWIP,
174
+ bottom: lay.marginBottom * DEFAULT_PX_PER_TWIP,
175
+ left: lay.marginLeft * DEFAULT_PX_PER_TWIP,
176
+ },
177
+ dpi: 96,
178
+ };
179
+ },
180
+ };
181
+ }
@@ -4,7 +4,8 @@
4
4
  * queryScopes (live) / getMarkup (live) / getGuard (live) /
5
5
  * createScope (live-with-adapter) / attachMetadata (live-with-adapter) /
6
6
  * getVisibilityPolicy · getVisibilityPolicies · setVisibilityPolicy ·
7
- * clearVisibilityPolicy (live — W10 state-classes X1).
7
+ * clearVisibilityPolicy (live — W10 state-classes X1) /
8
+ * scopeTags (live — coord-10 L11-4 tag-catalog read).
8
9
  */
9
10
 
10
11
  import type { RuntimeApiHandle } from "../_runtime-handle.ts";
@@ -17,6 +18,13 @@ import type {
17
18
  import { emitUxResponse } from "../_ux-response.ts";
18
19
  import { createScopeFromBlockId } from "../../../runtime/workflow/scope-writer.ts";
19
20
  import { attachScopeMetadata } from "../../../runtime/workflow/metadata-writer.ts";
21
+ import {
22
+ DEFAULT_REGISTRY_ENTRIES,
23
+ DEFAULT_UNKNOWN_BEHAVIOR,
24
+ createScopeTagRegistry,
25
+ type ScopeTagBehavior,
26
+ type ScopeTagRegistry,
27
+ } from "../../../runtime/workflow/scope-tag-registry.ts";
20
28
 
21
29
  export const queryScopesMetadata: ApiV3FnMetadata = {
22
30
  name: "runtime.workflow.queryScopes",
@@ -79,6 +87,25 @@ export interface CreateScopeInput {
79
87
  * leak in). Agents pick per scope family per coord-09 §1.14.
80
88
  */
81
89
  readonly assoc?: { readonly start: -1 | 1; readonly end: -1 | 1 };
90
+ /**
91
+ * Coord-08 §9 / coord-09 §1.13 (A3) — caller-steerable identity
92
+ * strategy for the enumerated scope's `ScopeHandle.stableRef`.
93
+ * Passthrough to L06 scope-writer + L08 compiler. Honored for
94
+ * `"scope-id"` and `"semantic-path"` today; `"bookmark"` and
95
+ * `"runtime-handle"` fall back to the compiler default (bookmark
96
+ * lookup not wired in phase 1). See
97
+ * `src/runtime/scopes/enumerate-scopes.ts::stableRefHintForScopeId`.
98
+ *
99
+ * Ownership: L08 owns the selection policy; L06 owns the primitive
100
+ * (`scope-writer.ts::CreateScopeFromBlockIdInput.stableRefHint`);
101
+ * L07 is passthrough (this field); L02 owns the `ScopeStableRef`
102
+ * value shape.
103
+ */
104
+ readonly stableRefHint?:
105
+ | "scope-id"
106
+ | "bookmark"
107
+ | "semantic-path"
108
+ | "runtime-handle";
82
109
  }
83
110
 
84
111
  export interface CreateScopeResult {
@@ -273,6 +300,68 @@ export const subscribeMarkupModePolicyMetadata: ApiV3FnMetadata = {
273
300
  rwdReference: "§Runtime API § runtime.workflow.subscribeMarkupModePolicy",
274
301
  };
275
302
 
303
+ // ---------------------------------------------------------------------------
304
+ // Coord-10 L11-4 — scope-tag catalog read, graduating the pragmatic
305
+ // `createScopeTagRegistry` re-export through `src/api/public-types.ts`
306
+ // (refactor/11 §4.17 commit `7a2d2fc0`, 2026-04-24) to a proper v3 family
307
+ // entry. Three methods: factory + catalog enumeration + per-tag peek. All
308
+ // A-canonical — the catalog is code-derived but describes canonical-shape
309
+ // invariants: every tag it catalogs is encoded into customXml (via scope-
310
+ // marker / bookmark / comment structures) and broadcast via crdt along
311
+ // with scope-marker commits.
312
+ // ---------------------------------------------------------------------------
313
+
314
+ export const createScopeTagRegistryMetadata: ApiV3FnMetadata = {
315
+ name: "runtime.workflow.createScopeTagRegistry",
316
+ status: "live",
317
+ sourceLayer: "workflow-review",
318
+ liveEvidence: {
319
+ runnerTest: "test/api/v3/runtime/workflow-scope-tags.test.ts",
320
+ commit: "coord-10-l11-4",
321
+ },
322
+ uxIntent: { uiVisible: false, expectsUxResponse: "none" },
323
+ agentMetadata: { readOrMutate: "read", boundedScope: "session", auditCategory: "scope-tag-catalog" },
324
+ stateClass: "A-canonical",
325
+ persistsTo: "customXml",
326
+ broadcastsVia: "crdt",
327
+ rwdReference:
328
+ "§Runtime API § runtime.workflow.createScopeTagRegistry. Mints a fresh default-seeded ScopeTagRegistry. Hosts register custom annotation families on the returned registry via `.register(...)`. Retires the pragmatic `createScopeTagRegistry` re-export through public-types.ts (refactor/11 §4.17 2026-04-24) — the v3 family is the long-term home per coord-10 L11-4.",
329
+ };
330
+
331
+ export const listScopeTagsMetadata: ApiV3FnMetadata = {
332
+ name: "runtime.workflow.listScopeTags",
333
+ status: "live",
334
+ sourceLayer: "workflow-review",
335
+ liveEvidence: {
336
+ runnerTest: "test/api/v3/runtime/workflow-scope-tags.test.ts",
337
+ commit: "coord-10-l11-4",
338
+ },
339
+ uxIntent: { uiVisible: false, expectsUxResponse: "none" },
340
+ agentMetadata: { readOrMutate: "read", boundedScope: "session", auditCategory: "scope-tag-catalog" },
341
+ stateClass: "A-canonical",
342
+ persistsTo: "customXml",
343
+ broadcastsVia: "crdt",
344
+ rwdReference:
345
+ "§Runtime API § runtime.workflow.listScopeTags. Enumerates the shipped default catalog as `[tagType, behavior]` pairs without constructing a registry. Does not reflect host-registered custom tags — callers that want those iterate `createScopeTagRegistry().list()` on their own instance.",
346
+ };
347
+
348
+ export const getScopeTagMetadata: ApiV3FnMetadata = {
349
+ name: "runtime.workflow.getScopeTag",
350
+ status: "live",
351
+ sourceLayer: "workflow-review",
352
+ liveEvidence: {
353
+ runnerTest: "test/api/v3/runtime/workflow-scope-tags.test.ts",
354
+ commit: "coord-10-l11-4",
355
+ },
356
+ uxIntent: { uiVisible: false, expectsUxResponse: "none" },
357
+ agentMetadata: { readOrMutate: "read", boundedScope: "session", auditCategory: "scope-tag-catalog" },
358
+ stateClass: "A-canonical",
359
+ persistsTo: "customXml",
360
+ broadcastsVia: "crdt",
361
+ rwdReference:
362
+ "§Runtime API § runtime.workflow.getScopeTag. Peeks a default tag's behavior by name. Returns DEFAULT_UNKNOWN_BEHAVIOR for unknown tag types (bail-on-cross) — matches `createScopeTagRegistry().get(unknownTagType)`.",
363
+ };
364
+
276
365
  export function createWorkflowFamily(runtime: RuntimeApiHandle) {
277
366
  return {
278
367
  queryScopes(filter?: unknown) {
@@ -301,6 +390,9 @@ export function createWorkflowFamily(runtime: RuntimeApiHandle) {
301
390
  mode: input.mode,
302
391
  label: input.label,
303
392
  ...(input.assoc ? { assoc: input.assoc } : {}),
393
+ ...(input.stableRefHint
394
+ ? { stableRefHint: input.stableRefHint }
395
+ : {}),
304
396
  });
305
397
  emitUxResponse(runtime, {
306
398
  apiFn: createScopeMetadata.name,
@@ -430,5 +522,26 @@ export function createWorkflowFamily(runtime: RuntimeApiHandle) {
430
522
  }
431
523
  return { status: "scope-not-found" };
432
524
  },
525
+
526
+ createScopeTagRegistry(): ScopeTagRegistry {
527
+ // @endStateApi — live. Coord-10 L11-4. Mints a fresh default-
528
+ // seeded registry. Hosts register custom annotation families on
529
+ // the returned registry via `.register(...)`.
530
+ return createScopeTagRegistry();
531
+ },
532
+
533
+ listScopeTags(): readonly (readonly [string, ScopeTagBehavior])[] {
534
+ // @endStateApi — live. Coord-10 L11-4. Enumerates the shipped
535
+ // default catalog as `[tagType, behavior]` pairs. Does not
536
+ // reflect host-registered customs on any specific registry.
537
+ return Object.entries(DEFAULT_REGISTRY_ENTRIES);
538
+ },
539
+
540
+ getScopeTag(tagType: string): ScopeTagBehavior {
541
+ // @endStateApi — live. Coord-10 L11-4. Peeks a default tag's
542
+ // behavior by name. Returns `DEFAULT_UNKNOWN_BEHAVIOR` (bail-on-
543
+ // cross) for unknown tag types.
544
+ return DEFAULT_REGISTRY_ENTRIES[tagType] ?? DEFAULT_UNKNOWN_BEHAVIOR;
545
+ },
433
546
  };
434
547
  }
@@ -233,6 +233,26 @@ export type ScrollTarget =
233
233
  | { kind: "revision"; value: string; behavior?: ScrollTargetBehavior }
234
234
  | { kind: "page"; value: number; behavior?: ScrollTargetBehavior };
235
235
 
236
+ /**
237
+ * Result of `ui.viewport.scrollToPage(n)` — the settled state after
238
+ * the scroll dispatch lands.
239
+ *
240
+ * - `actualPage` is the 1-based page number that was actually
241
+ * targeted. Differs from the requested `pageNumber` when the caller
242
+ * asked for a page beyond the document's resolved bounds —
243
+ * implementation clamps and reports the clamped value here.
244
+ * - `scrollY` is the document-relative Y offset (in CSS px) of the
245
+ * target page's top edge, sourced from
246
+ * `handle.geometry.getPage(index).frame.topPx`. Useful for consumers
247
+ * that want to verify their own scroll container landed at the
248
+ * expected coordinate (visual-fidelity harness: deterministic
249
+ * per-page capture).
250
+ */
251
+ export interface ScrollToPageResult {
252
+ readonly actualPage: number;
253
+ readonly scrollY: number;
254
+ }
255
+
236
256
  export interface SelectionRangeInput {
237
257
  readonly anchor: number;
238
258
  readonly head: number;
@@ -369,6 +389,21 @@ export interface ApiV3UiViewport {
369
389
  get(): ViewportState;
370
390
  subscribe(listener: UiListener<ViewportState>): UiUnsubscribe;
371
391
 
392
+ /**
393
+ * Scroll the mounted surface to a specific 1-based page number.
394
+ * Resolves via `handle.geometry.getPage`; dispatches through the
395
+ * bound controller's `dispatchScroll` hook. Returns the settled
396
+ * `{ actualPage, scrollY }` or `null` when geometry cannot resolve
397
+ * any page / no controller bound / no dispatchScroll hook.
398
+ * Clamps pageNumber to the document's valid range; `actualPage`
399
+ * reflects the clamp. coord-10 §γ first-class replacement for the
400
+ * visual-fidelity harness's DOM-scrape fallback.
401
+ */
402
+ scrollToPage(
403
+ pageNumber: number,
404
+ opts?: { behavior?: ScrollTargetBehavior },
405
+ ): Promise<ScrollToPageResult | null>;
406
+
372
407
  /**
373
408
  * X5 · Composed effective markup mode. Merges L06's class-A
374
409
  * `WorkflowMarkupModePolicy` (via `handle.getMarkupModePolicy()`)
@@ -116,6 +116,7 @@ export function resolveChromeVisibilityForPreset(input: {
116
116
  pageChrome: true,
117
117
  statusBar: true,
118
118
  reviewRail: false,
119
+ shellHeader: false,
119
120
  },
120
121
  simple: {
121
122
  toolbar: true,
@@ -126,6 +127,7 @@ export function resolveChromeVisibilityForPreset(input: {
126
127
  pageChrome: true,
127
128
  statusBar: true,
128
129
  reviewRail: false,
130
+ shellHeader: true,
129
131
  },
130
132
  advanced: {
131
133
  toolbar: true,
@@ -136,6 +138,7 @@ export function resolveChromeVisibilityForPreset(input: {
136
138
  pageChrome: true,
137
139
  statusBar: true,
138
140
  reviewRail: true,
141
+ shellHeader: true,
139
142
  },
140
143
  review: {
141
144
  toolbar: true,
@@ -146,6 +149,7 @@ export function resolveChromeVisibilityForPreset(input: {
146
149
  pageChrome: true,
147
150
  statusBar: true,
148
151
  reviewRail: options.showReviewRail,
152
+ shellHeader: true,
149
153
  },
150
154
  workflow: {
151
155
  toolbar: true,
@@ -156,6 +160,7 @@ export function resolveChromeVisibilityForPreset(input: {
156
160
  pageChrome: true,
157
161
  statusBar: true,
158
162
  reviewRail: options.showReviewRail,
163
+ shellHeader: true,
159
164
  },
160
165
  collab: {
161
166
  toolbar: true,
@@ -166,6 +171,7 @@ export function resolveChromeVisibilityForPreset(input: {
166
171
  pageChrome: true,
167
172
  statusBar: true,
168
173
  reviewRail: options.showReviewRail,
174
+ shellHeader: true,
169
175
  },
170
176
  };
171
177
 
@@ -24,6 +24,7 @@ export type {
24
24
  ViewportState,
25
25
  ScrollTarget,
26
26
  ScrollTargetBehavior,
27
+ ScrollToPageResult,
27
28
  SelectionRangeInput,
28
29
  OverlayAnchorQuery,
29
30
  OverlayKind,
@@ -27,6 +27,8 @@ import type {
27
27
  UiListener,
28
28
  UiUnsubscribe,
29
29
  WorkflowMarkupMode,
30
+ ScrollTargetBehavior,
31
+ ScrollToPageResult,
30
32
  } from "./_types.ts";
31
33
  import type { UiApiContext } from "./_context.ts";
32
34
  import { readComposedViewport } from "./_context.ts";
@@ -84,6 +86,35 @@ export const subscribeMetadata: ApiV3FnMetadata = {
84
86
  rwdReference: "§UI API § ui.viewport.subscribe. Adapter delegates to UiController.subscribeViewport; throws when the active binding has no hook. Subscribe call emits one `ux.response.ui.viewport.subscribe` acknowledgement; per-tick ViewportState deliveries flow through the listener (rAF-coalesced, U7).",
85
87
  };
86
88
 
89
+ // ----- scrollToPage (coord-10 §γ — visual-fidelity / Go-to-page UX) -----
90
+
91
+ export const scrollToPageMetadata: ApiV3FnMetadata = {
92
+ name: "ui.viewport.scrollToPage",
93
+ status: "live-with-adapter",
94
+ sourceLayer: "presentation",
95
+ liveEvidence: {
96
+ runnerTest: "test/api/v3/ui/scroll-to-page.test.ts",
97
+ commit: "refactor-10-slice-scroll-to-page",
98
+ },
99
+ uxIntent: {
100
+ uiVisible: true,
101
+ expectsUxResponse: "surface-refresh",
102
+ expectedDelta: "mounted surface scrolls to the target page's top edge",
103
+ },
104
+ agentMetadata: {
105
+ readOrMutate: "mutate",
106
+ boundedScope: "session",
107
+ auditCategory: "ui-viewport-scroll",
108
+ },
109
+ // Scroll position is a class-C local view state (per-session /
110
+ // per-mounted-instance); changing it doesn't mutate canonical document
111
+ // state and is not broadcast across collab peers.
112
+ stateClass: "C-local",
113
+ persistsTo: "none",
114
+ rwdReference:
115
+ "§UI API § ui.viewport.scrollToPage. Resolves pageNumber → scrollY via handle.geometry.getPage(pageIndex); dispatches through controller.dispatchScroll({ kind:'page', value, behavior }); returns the settled {actualPage, scrollY}. 1-based page numbers; clamps to [1, pageCount]. First-class API for visual-fidelity harness + 'Go to page N' UX — replaces DOM-scrape fallback (coord-10 §γ). Parity note: reads the same `handle.geometry.getPage(i).frame.topPx` source as `runtime.viewport.getPageAnchor` (L07 coord-07 §2.9, shipped 2026-04-24 in `src/api/v3/runtime/viewport.ts`), so `actualPage + scrollY` here and `{scrollY, pageRect}` on the runtime side stay consistent by construction. No direct delegation today because `scripts/ci-check-ui-api-layer-purity.mjs` restricts `src/api/v3/ui/**` from importing `src/api/v3/runtime/**`; both surfaces are thin wrappers over the shared geometry facet.",
116
+ };
117
+
87
118
  // ----- X5 markup-mode metadata (state-classes cross-cutting Slice X5) -----
88
119
 
89
120
  /**
@@ -249,6 +280,87 @@ export function createViewportFamily(ctx: UiApiContext) {
249
280
  return unsubscribe;
250
281
  },
251
282
 
283
+ // ----- scrollToPage (coord-10 §γ) -----
284
+
285
+ /**
286
+ * Scroll the mounted surface to a specific 1-based page number.
287
+ *
288
+ * Resolution: `handle.geometry.getPage(pageNumber - 1)` supplies the
289
+ * page's `frame.topPx` (deterministic, renderer-frame coordinates).
290
+ * Dispatch: `controller.dispatchScroll({ kind: "page", value, behavior })`
291
+ * — same seam as `ui.surface.scrollTo`, so any consumer-specific
292
+ * scroll-container routing lives on the shell side.
293
+ *
294
+ * Clamping: `pageNumber < 1` resolves to page 1. A request beyond
295
+ * the document's page count returns the last valid page's scrollY;
296
+ * `actualPage` reflects the clamp so callers can detect it.
297
+ *
298
+ * Returns `null` when (a) no controller is bound, (b) the controller
299
+ * has no `dispatchScroll` hook, or (c) geometry cannot resolve any
300
+ * page (pre-paint / empty doc). Callers that need explicit failure
301
+ * handling check `result !== null` before trusting the scroll.
302
+ */
303
+ async scrollToPage(
304
+ pageNumber: number,
305
+ opts?: { behavior?: ScrollTargetBehavior },
306
+ ): Promise<ScrollToPageResult | null> {
307
+ const controller = ctx.binding?.controller;
308
+ if (!controller) {
309
+ throw new Error(
310
+ "ui.viewport.scrollToPage: no controller bound — call ui.session.bind(controller) first",
311
+ );
312
+ }
313
+ if (!controller.dispatchScroll) {
314
+ throw new Error(
315
+ `ui.viewport.scrollToPage: controller of kind "${controller.kind}" did not provide a dispatchScroll hook`,
316
+ );
317
+ }
318
+
319
+ // Clamp to [1, pageCount]. The geometry facet's `getPage(index)`
320
+ // takes a 0-based index; we accept 1-based input and convert.
321
+ // Walk forward from the clamped target to find the first page the
322
+ // facet actually resolves — this handles sparse / pre-paint
323
+ // states where higher indices may be null.
324
+ const getPage = ctx.handle.geometry?.getPage;
325
+ if (typeof getPage !== "function") return null;
326
+
327
+ const requestedClampedLow = Math.max(1, Math.floor(pageNumber));
328
+ // Try the requested page; if null, scan downward through lower
329
+ // indices to land on the largest resolvable page (the doc's last
330
+ // populated page). If nothing resolves, return null.
331
+ let resolved: { pageIndex: number; scrollY: number } | null = null;
332
+ for (let i = requestedClampedLow - 1; i >= 0; i--) {
333
+ const page = getPage.call(ctx.handle.geometry, i);
334
+ if (page) {
335
+ resolved = { pageIndex: i, scrollY: page.frame.topPx };
336
+ break;
337
+ }
338
+ }
339
+ if (!resolved) return null;
340
+
341
+ const actualPage = resolved.pageIndex + 1;
342
+ const behavior = opts?.behavior;
343
+ await controller.dispatchScroll({
344
+ kind: "page",
345
+ value: actualPage,
346
+ ...(behavior ? { behavior } : {}),
347
+ });
348
+
349
+ emitUxResponse(ctx.handle, {
350
+ apiFn: scrollToPageMetadata.name,
351
+ intent: scrollToPageMetadata.uxIntent.expectedDelta ?? "",
352
+ mockOrLive: "live-with-adapter",
353
+ uiVisible: true,
354
+ expectedDelta: scrollToPageMetadata.uxIntent.expectedDelta,
355
+ actualDelta: {
356
+ kind: "surface-refresh",
357
+ payload: { page: actualPage, scrollY: resolved.scrollY },
358
+ },
359
+ });
360
+
361
+ return { actualPage, scrollY: resolved.scrollY };
362
+ },
363
+
252
364
  // ----- X5 markup-mode (state-classes cross-cutting Slice X5) -----
253
365
 
254
366
  getEffectiveMarkupMode(): WorkflowMarkupMode {
@@ -509,6 +509,7 @@ function getInlineLength(node: InlineNode): number {
509
509
  case "tab":
510
510
  case "hard_break":
511
511
  case "column_break":
512
+ case "page_break":
512
513
  case "symbol":
513
514
  case "image":
514
515
  case "opaque_inline":
@@ -555,6 +556,7 @@ function getInlineDisplayText(node: InlineNode): string {
555
556
  return "\t";
556
557
  case "hard_break":
557
558
  case "column_break":
559
+ case "page_break":
558
560
  return "\n";
559
561
  case "symbol":
560
562
  return node.char;
@@ -1030,6 +1030,7 @@ function inlineNodeLength(node: InlineNode): number {
1030
1030
  );
1031
1031
  case "hard_break":
1032
1032
  case "column_break":
1033
+ case "page_break":
1033
1034
  case "tab":
1034
1035
  case "symbol":
1035
1036
  case "image":
@@ -329,6 +329,7 @@ export function getTableStructureContext(
329
329
  columnCount,
330
330
  selectedCellCount,
331
331
  isSimpleTable: simpleTable,
332
+ alignment: target.alignment ?? null,
332
333
  currentCell: {
333
334
  rowIndex: effectiveSelection.anchorCell.rowIndex,
334
335
  columnIndex: effectiveSelection.anchorCell.columnIndex,
@@ -582,14 +582,57 @@ export function createPersistedEditorSnapshot(
582
582
  }
583
583
 
584
584
  function estimateParagraphCount(content: unknown): number {
585
- if (Array.isArray(content)) {
586
- return content.length;
587
- }
585
+ // Canonical shape: `{type:"doc", children: BlockNode[]}`. Older
586
+ // shapes (array / `.blocks`) handled for persistence-snapshot
587
+ // fallback. KI-P4 (2026-04-23): pre-fix the array + .blocks
588
+ // branches never matched the current envelope, so the fallback
589
+ // returned 1 on any non-empty document regardless of paragraph
590
+ // count. Fix counts ParagraphNode entries recursively, descending
591
+ // into table cells + SDT / customXml blocks so nested paragraphs
592
+ // contribute to the total.
593
+ let count = 0;
594
+ const walk = (node: unknown): void => {
595
+ if (!node || typeof node !== "object") return;
596
+ const typed = node as { type?: unknown };
597
+ if (typed.type === "paragraph") {
598
+ count += 1;
599
+ return;
600
+ }
601
+ if (typed.type === "table") {
602
+ const rows = (node as { rows?: unknown[] }).rows;
603
+ if (Array.isArray(rows)) {
604
+ for (const row of rows) {
605
+ const cells = (row as { cells?: unknown[] }).cells;
606
+ if (Array.isArray(cells)) {
607
+ for (const cell of cells) {
608
+ const children = (cell as { children?: unknown[] }).children;
609
+ if (Array.isArray(children)) children.forEach(walk);
610
+ }
611
+ }
612
+ }
613
+ }
614
+ return;
615
+ }
616
+ const children = (node as { children?: unknown[] }).children;
617
+ if (Array.isArray(children)) children.forEach(walk);
618
+ };
588
619
 
589
- if (content && typeof content === "object" && Array.isArray((content as { blocks?: unknown[] }).blocks)) {
590
- return ((content as { blocks: unknown[] }).blocks).length;
620
+ if (content && typeof content === "object") {
621
+ const children = (content as { children?: unknown[] }).children;
622
+ if (Array.isArray(children)) {
623
+ children.forEach(walk);
624
+ return count;
625
+ }
626
+ const blocks = (content as { blocks?: unknown[] }).blocks;
627
+ if (Array.isArray(blocks)) {
628
+ blocks.forEach(walk);
629
+ return count;
630
+ }
631
+ }
632
+ if (Array.isArray(content)) {
633
+ content.forEach(walk);
634
+ return count;
591
635
  }
592
-
593
636
  return extractText(content).length > 0 ? 1 : 0;
594
637
  }
595
638