@beyondwork/docx-react-component 1.0.83 → 1.0.85

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 (55) hide show
  1. package/package.json +1 -1
  2. package/src/api/internal/build-ref-projections.ts +3 -0
  3. package/src/api/public-types.ts +86 -4
  4. package/src/api/v3/_runtime-handle.ts +15 -0
  5. package/src/api/v3/runtime/content.ts +148 -1
  6. package/src/api/v3/runtime/formatting.ts +41 -0
  7. package/src/api/v3/runtime/review.ts +98 -0
  8. package/src/api/v3/runtime/workflow.ts +154 -6
  9. package/src/core/commands/index.ts +81 -25
  10. package/src/core/state/editor-state.ts +15 -0
  11. package/src/io/export/serialize-main-document.ts +72 -6
  12. package/src/io/ooxml/header-footer-reference.ts +38 -0
  13. package/src/io/ooxml/parse-headers-footers.ts +11 -23
  14. package/src/io/ooxml/parse-main-document.ts +7 -10
  15. package/src/io/ooxml/workflow-payload-validator.ts +24 -0
  16. package/src/io/ooxml/workflow-payload.ts +12 -0
  17. package/src/model/canonical-document.ts +9 -0
  18. package/src/model/review/comment-types.ts +2 -0
  19. package/src/runtime/document-runtime.ts +718 -68
  20. package/src/runtime/formatting/field/resolver.ts +73 -8
  21. package/src/runtime/layout/layout-engine-version.ts +31 -12
  22. package/src/runtime/layout/paginated-layout-engine.ts +18 -11
  23. package/src/runtime/layout/public-facet.ts +119 -16
  24. package/src/runtime/layout/resolve-page-fields.ts +68 -6
  25. package/src/runtime/layout/resolve-page-previews.ts +1 -1
  26. package/src/runtime/scopes/_scope-dependencies.ts +1 -0
  27. package/src/runtime/scopes/action-validation.ts +54 -45
  28. package/src/runtime/scopes/workflow-overlap.ts +41 -9
  29. package/src/runtime/suggestions-snapshot.ts +24 -0
  30. package/src/runtime/surface-projection.ts +59 -2
  31. package/src/runtime/workflow/coordinator.ts +66 -14
  32. package/src/runtime/workflow/scope-writer.ts +83 -5
  33. package/src/shell/ref-commands.ts +3 -354
  34. package/src/shell/session-bootstrap.ts +10 -0
  35. package/src/ui/WordReviewEditor.tsx +99 -9
  36. package/src/ui/editor-command-bag.ts +3 -1
  37. package/src/ui/headless/revision-decoration-model.ts +13 -0
  38. package/src/ui/headless/selection-tool-types.ts +2 -0
  39. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +7 -3
  40. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +175 -25
  41. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +1 -1
  42. package/src/ui-tailwind/editor-surface/pm-decorations.ts +12 -0
  43. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +18 -30
  44. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +1 -1
  45. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +1 -1
  46. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +20 -11
  47. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +9 -4
  48. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +12 -7
  49. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +29 -10
  50. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +1 -1
  51. package/src/ui-tailwind/review-workspace/types.ts +3 -2
  52. package/src/ui-tailwind/review-workspace/use-page-markers.ts +11 -1
  53. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +2 -1
  54. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +2 -1
  55. package/src/ui-tailwind/tw-review-workspace.tsx +18 -2
@@ -13,7 +13,9 @@ import type {
13
13
  ParagraphNode,
14
14
  StylesCatalog,
15
15
  } from "../../../model/canonical-document.ts";
16
+ import { resolvePageFieldDisplayText } from "../../layout/resolve-page-fields.ts";
16
17
  import type { RuntimePageGraph, RuntimePageNode } from "../../layout/page-graph.ts";
18
+ import { formatPageNumber } from "./page-number-format.ts";
17
19
 
18
20
  /**
19
21
  * Layer-03 alias for the layout module's `RuntimePageGraph`. Exists so
@@ -80,7 +82,11 @@ export function createFieldResolver(input: FieldResolverInput): FieldResolver {
80
82
  return { displayText: "", refreshStatus: "unresolvable" };
81
83
  }
82
84
  return {
83
- displayText: String(page.stories.displayPageNumber),
85
+ displayText: resolvePageFieldDisplayText(
86
+ "PAGE",
87
+ entry.displayText ?? "",
88
+ { page, graph: pageGraph },
89
+ ),
84
90
  refreshStatus: "current",
85
91
  };
86
92
  }
@@ -90,7 +96,34 @@ export function createFieldResolver(input: FieldResolverInput): FieldResolver {
90
96
  return { displayText: "", refreshStatus: "unresolvable" };
91
97
  }
92
98
  return {
93
- displayText: String(pageGraph.contentPageCount),
99
+ displayText: resolvePageFieldDisplayText(
100
+ "NUMPAGES",
101
+ entry.displayText ?? "",
102
+ { page: pageGraph.pages[activePageIndex] ?? pageGraph.pages[0]!, graph: pageGraph },
103
+ ),
104
+ refreshStatus: "current",
105
+ };
106
+ }
107
+
108
+ case "SECTIONPAGES": {
109
+ const page = pageGraph.pages[activePageIndex];
110
+ if (!page) {
111
+ return { displayText: "", refreshStatus: "unresolvable" };
112
+ }
113
+ const sectionPageCount = pageGraph.pages.filter(
114
+ (candidate) =>
115
+ !candidate.isBlankFiller &&
116
+ candidate.sectionIndex === page.sectionIndex,
117
+ ).length;
118
+ if (sectionPageCount === 0) {
119
+ return { displayText: "", refreshStatus: "unresolvable" };
120
+ }
121
+ return {
122
+ displayText: resolvePageFieldDisplayText(
123
+ "SECTIONPAGES",
124
+ entry.displayText ?? "",
125
+ { page, graph: pageGraph },
126
+ ),
94
127
  refreshStatus: "current",
95
128
  };
96
129
  }
@@ -125,7 +158,7 @@ export function createFieldResolver(input: FieldResolverInput): FieldResolver {
125
158
  return { displayText: "", refreshStatus: "unresolvable" };
126
159
  }
127
160
  return {
128
- displayText: String(page.stories.displayPageNumber),
161
+ displayText: resolvePageGraphFieldText("PAGE", page, pageGraph),
129
162
  refreshStatus: "current",
130
163
  ...(entry.switches?.hyperlink ? { asHyperlink: true as const } : {}),
131
164
  };
@@ -182,18 +215,16 @@ export function createFieldResolver(input: FieldResolverInput): FieldResolver {
182
215
  return undefined;
183
216
 
184
217
  case "NOTEREF":
185
- case "SECTIONPAGES":
186
- // These families are classified as SupportedFieldFamily (see
218
+ // NOTEREF is classified as SupportedFieldFamily (see
187
219
  // `src/model/canonical-document.ts::SupportedFieldFamily`) but
188
- // are NOT implemented in the resolver yet. Returning
220
+ // is NOT implemented in the resolver yet. Returning
189
221
  // `refreshStatus: "unresolvable"` is the honest answer — the
190
222
  // caller knows the family is recognized but the runtime has
191
223
  // no resolution path. This distinguishes them from TOC
192
224
  // (intentional `undefined` = preserve existing display) and
193
225
  // from PreserveOnlyFieldFamily (parse-only, no refresh slot).
194
226
  // Tracked in L03 audit 2026-04-22 as Task-4 follow-up: implement
195
- // NOTEREF via footnote/endnote anchor lookup + SECTIONPAGES via
196
- // pageGraph section-scoped page-count walk.
227
+ // NOTEREF via footnote/endnote anchor lookup.
197
228
  return { displayText: "", refreshStatus: "unresolvable" };
198
229
 
199
230
  default:
@@ -248,6 +279,40 @@ function findPageForOffset(
248
279
  return result;
249
280
  }
250
281
 
282
+ function resolvePageGraphFieldText(
283
+ family: "PAGE" | "NUMPAGES" | "SECTIONPAGES",
284
+ page: RuntimePageNode,
285
+ graph: RuntimePageGraph,
286
+ ): string {
287
+ switch (family) {
288
+ case "PAGE":
289
+ return formatPageNumber(
290
+ page.stories.displayPageNumber,
291
+ page.layout?.pageNumbering?.format,
292
+ );
293
+ case "NUMPAGES":
294
+ return String(graph.contentPageCount);
295
+ case "SECTIONPAGES":
296
+ return formatPageNumber(
297
+ countContentPagesInSection(graph, page.sectionIndex),
298
+ page.layout?.pageNumbering?.format,
299
+ );
300
+ }
301
+ }
302
+
303
+ function countContentPagesInSection(
304
+ graph: RuntimePageGraph,
305
+ sectionIndex: number,
306
+ ): number {
307
+ let count = 0;
308
+ for (const page of graph.pages) {
309
+ if (page.sectionIndex === sectionIndex && !page.isBlankFiller) {
310
+ count += 1;
311
+ }
312
+ }
313
+ return count;
314
+ }
315
+
251
316
  /**
252
317
  * Walk paragraphs in document order and invoke `visit` for each one.
253
318
  * Tables are traversed via their `rows` arrays, cells via their `children`.
@@ -948,15 +948,7 @@
948
948
  * invalidate because any future document with a real body framePr
949
949
  * paginates differently.
950
950
  *
951
- * 56 — perf(11b,07) `8d07de1b` multi-range viewport realization.
952
- * `WordReviewEditorLayoutFacet` gained `setVisibleBlockRanges` for
953
- * multi-range viewport realization (paired with coord-07 §2.9
954
- * `runtime.viewport.subscribe`). The contract widened but the
955
- * underlying layout algorithm is unchanged — persisted envelopes
956
- * remain shape-compatible. Bump is defensive so any consumer that
957
- * keyed on the facet contract refreshes the cache.
958
- *
959
- * 57 — viewport-cull flicker fix. The pre-v57 PM placeholder emitted
951
+ * 56 — viewport-cull flicker fix. The pre-v56 PM placeholder emitted
960
952
  * for `placeholder-culled` opaque blocks rendered at
961
953
  * `min-height: 20px` regardless of the real block's visual height
962
954
  * (`src/ui-tailwind/editor-surface/pm-schema.ts`), because neither
@@ -978,11 +970,38 @@
978
970
  * fallback when the attr is set.
979
971
  *
980
972
  * Pagination itself is untouched — this is purely a render-surface
981
- * fix. Cache envelopes from v56 invalidate because the exposed
973
+ * fix. Cache envelopes from v55 invalidate because the exposed
982
974
  * facet surface grew one public method; any consumer relying on
983
- * the prior interface shape re-derives its cache key under v57.
975
+ * the prior interface shape re-derives its cache key under v56.
976
+ *
977
+ * 57 — canvas pagination seam polish. The canvas-posture page-break
978
+ * widget no longer renders the `N / M` pagination marker as a rounded
979
+ * bubble/card. It now paints only the dotted seam line plus plain
980
+ * page-number text (`data-kind="canvas-seam-page-number"`), removing
981
+ * the old background, border, radius, and shadow from the widget DOM.
982
+ * Pagination geometry is unchanged, but cached render-frame DOM
983
+ * snapshots from v56 must invalidate because the widget shape changed.
984
+ *
985
+ * 58 — pagination honors style-derived paragraph flow flags where they
986
+ * are strong enough to affect page assignment. The paginated layout
987
+ * pass promotes cascaded `keepNext` only when the paragraph is also
988
+ * resolved with `keepLines` (direct `keepNext` is unchanged). This
989
+ * matches the built-in heading pattern used by the SOW redline pages
990
+ * without letting keep-next-only legal template headings or custom
991
+ * style page-break hints broadly over-paginate the CCEP corpus. Cache
992
+ * envelopes from v57 invalidate because page assignment can change for
993
+ * style-driven docs.
994
+ *
995
+ * 59 — page-field resolver reads from `public-facet`'s page-graph
996
+ * truth (`a9969e97`). `public-facet.ts` gained
997
+ * `getStoryBlocksForRegion(regionIndex): ReadonlyArray<BlockId>`
998
+ * and `resolve-page-fields.ts` was rewritten to consume it instead
999
+ * of the older cached resolver state that could go stale after
1000
+ * layout changes. The facet contract widened by one public method —
1001
+ * persisted cache envelopes from v58 must re-derive their page-field
1002
+ * projections.
984
1003
  */
985
- export const LAYOUT_ENGINE_VERSION = 57 as const;
1004
+ export const LAYOUT_ENGINE_VERSION = 59 as const;
986
1005
 
987
1006
  /**
988
1007
  * Serialization schema version for the LayCache payload (the cache envelope
@@ -1637,36 +1637,43 @@ export function paginateSectionBlocksWithSplits(
1637
1637
  index,
1638
1638
  );
1639
1639
 
1640
+ const formatting = block.kind === "paragraph"
1641
+ ? resolveBlockFormatting(block, defaultTabInterval, themeFonts)
1642
+ : null;
1643
+ const keepLinesActive = formatting?.keepLines ?? false;
1644
+ const keepNextActive = block.kind === "paragraph"
1645
+ ? Boolean(block.keepNext ?? (block.resolvedParagraphFormatting?.keepNext && keepLinesActive))
1646
+ : false;
1647
+ const pageBreakBeforeActive = block.kind === "paragraph"
1648
+ ? Boolean(block.pageBreakBefore)
1649
+ : false;
1650
+
1640
1651
  // keepNext: this paragraph must stay with the next one on the same page
1652
+ const nextBlock = blocks[index + 1];
1641
1653
  const keepWithNextHeight =
1642
- block.kind === "paragraph" && block.keepNext
1654
+ block.kind === "paragraph" && keepNextActive && nextBlock
1643
1655
  ? baseHeight +
1644
1656
  applyContextualSpacingAdjustment(
1645
1657
  measureBlockHeight(
1646
- blocks[index + 1],
1658
+ nextBlock,
1647
1659
  columnWidth,
1648
1660
  measurementProvider,
1649
1661
  cache,
1650
1662
  defaultTabInterval,
1651
1663
  themeFonts,
1652
1664
  ),
1653
- blocks[index + 1],
1665
+ nextBlock,
1654
1666
  index + 1,
1655
1667
  )
1656
1668
  : baseHeight;
1657
1669
 
1658
1670
  // keepLines: the entire paragraph must fit on one page.
1659
1671
  // If it doesn't fit and there's already content on this page, break before it.
1660
- const formatting = block.kind === "paragraph"
1661
- ? resolveBlockFormatting(block, defaultTabInterval, themeFonts)
1662
- : null;
1663
- const keepLinesActive = formatting?.keepLines ?? false;
1664
-
1665
1672
  const noteHeight = estimateFootnoteReservation(block, footnotes, columnWidth, reservedNotes);
1666
1673
  const projectedHeight = columnHeight + keepWithNextHeight + reservedNoteHeight + noteHeight;
1667
1674
 
1668
1675
  // pageBreakBefore
1669
- if (block.kind === "paragraph" && block.pageBreakBefore && pageStart < block.from) {
1676
+ if (block.kind === "paragraph" && pageBreakBeforeActive && pageStart < block.from) {
1670
1677
  pushPage(block.from);
1671
1678
  continue;
1672
1679
  }
@@ -1785,7 +1792,7 @@ export function paginateSectionBlocksWithSplits(
1785
1792
  block.kind === "paragraph" &&
1786
1793
  formatting &&
1787
1794
  !keepLinesActive &&
1788
- !block.keepNext
1795
+ !keepNextActive
1789
1796
  ) {
1790
1797
  const availableHeight = usableHeight - columnHeight - reservedNoteHeight;
1791
1798
  const cachedLineCount = cache?.getLineCount(block, columnWidth);
@@ -1809,7 +1816,7 @@ export function paginateSectionBlocksWithSplits(
1809
1816
  availableLines,
1810
1817
  keepLines: keepLinesActive,
1811
1818
  widowControl: formatting.widowControl,
1812
- keepNext: Boolean(block.keepNext),
1819
+ keepNext: keepNextActive,
1813
1820
  isLastBlockOnPage: index === blocks.length - 1,
1814
1821
  });
1815
1822
  if (splitRule) {
@@ -77,6 +77,7 @@ import {
77
77
  import { createFormattingContext } from "../formatting/formatting-context.ts";
78
78
  import type { ResolvedTableStyleResolution } from "../formatting/table-style-resolver.ts";
79
79
  import { buildTableRenderPlan } from "./table-render-plan.ts";
80
+ import { resolvePageFieldDisplayText } from "./resolve-page-fields.ts";
80
81
  // Geometry helpers are no longer imported here:
81
82
  // - `hitTest` + `getAnchorRects` moved to the geometry facet in the
82
83
  // refactor/05 Slice 6 wrapper-deletion pass (2026-04-22).
@@ -90,6 +91,7 @@ import { collectLineBoxesForRegion } from "../geometry/project-fragments.ts";
90
91
  // can add its own instrumentation at call site if needed.
91
92
  import type {
92
93
  SurfaceBlockSnapshot,
94
+ SurfaceInlineSegment,
93
95
  } from "../../api/public-types";
94
96
 
95
97
  export type {
@@ -828,9 +830,14 @@ export function createLayoutFacet(
828
830
  if (!story) return Object.freeze([]);
829
831
  // P8.2.1 — share the projected `SurfaceBlockSnapshot[]` across every
830
832
  // page that renders the same story target.
831
- const projectedBlocks = getStoryProjectionCached(story, document);
833
+ const projectedBlocks = resolvePageScopedFieldsInBlocks(
834
+ getStoryProjectionCached(story, document),
835
+ node,
836
+ graph,
837
+ );
832
838
  return resolveHeaderFooterRegionBlocks(
833
- node.pageIndex,
839
+ node,
840
+ graph,
834
841
  region,
835
842
  story,
836
843
  projectedBlocks,
@@ -1866,7 +1873,8 @@ function resolveColumnRegionBlocks(
1866
1873
  }
1867
1874
 
1868
1875
  function resolveHeaderFooterRegionBlocks(
1869
- pageIndex: number,
1876
+ node: RuntimePageNode,
1877
+ graph: RuntimePageGraph,
1870
1878
  regionKind: "header" | "footer",
1871
1879
  storyTarget: EditorStoryTarget,
1872
1880
  projectedBlocks: readonly SurfaceBlockSnapshot[],
@@ -1875,21 +1883,117 @@ function resolveHeaderFooterRegionBlocks(
1875
1883
  if (projectedBlocks.length === 0) return Object.freeze([]);
1876
1884
  const fragmentBase = `story-${storyTarget.kind}-${storyTarget.relationshipId}-${storyTarget.variant}-${storyTarget.sectionIndex ?? "_"}`;
1877
1885
  return Object.freeze(
1878
- projectedBlocks.map((blockSnapshot, index): PublicRegionBlock => ({
1879
- blockId: blockSnapshot.blockId,
1880
- fragmentId: `${fragmentBase}-${index}`,
1881
- pageIndex,
1882
- regionKind,
1883
- runtimeFromOffset: blockSnapshot.from,
1884
- runtimeToOffset: blockSnapshot.to,
1885
- // Header/footer fragments aren't measured per-block — consumers read
1886
- // the region's `heightTwips` from `getStoryRegionsOnPage`.
1887
- heightTwips: 0,
1888
- blockSnapshot,
1889
- })),
1886
+ projectedBlocks.map((blockSnapshot, index): PublicRegionBlock => {
1887
+ const resolvedBlockSnapshot = resolvePageInstanceFieldsInBlock(
1888
+ blockSnapshot,
1889
+ node,
1890
+ graph,
1891
+ );
1892
+ return {
1893
+ blockId: blockSnapshot.blockId,
1894
+ fragmentId: `${fragmentBase}-${index}`,
1895
+ pageIndex: node.pageIndex,
1896
+ regionKind,
1897
+ runtimeFromOffset: blockSnapshot.from,
1898
+ runtimeToOffset: blockSnapshot.to,
1899
+ // Header/footer fragments aren't measured per-block — consumers read
1900
+ // the region's `heightTwips` from `getStoryRegionsOnPage`.
1901
+ heightTwips: 0,
1902
+ blockSnapshot: resolvedBlockSnapshot,
1903
+ };
1904
+ }),
1890
1905
  );
1891
1906
  }
1892
1907
 
1908
+ const PAGE_INSTANCE_FIELD_FAMILIES = new Set([
1909
+ "PAGE",
1910
+ "NUMPAGES",
1911
+ "SECTIONPAGES",
1912
+ ]);
1913
+
1914
+ function resolvePageScopedFieldsInBlocks(
1915
+ blocks: readonly SurfaceBlockSnapshot[],
1916
+ page: RuntimePageNode,
1917
+ graph: RuntimePageGraph,
1918
+ ): readonly SurfaceBlockSnapshot[] {
1919
+ let changed = false;
1920
+ const resolvedBlocks = blocks.map((block) => {
1921
+ const resolved = resolvePageInstanceFieldsInBlock(block, page, graph);
1922
+ if (resolved !== block) changed = true;
1923
+ return resolved;
1924
+ });
1925
+ return changed ? resolvedBlocks : blocks;
1926
+ }
1927
+
1928
+ function resolvePageInstanceFieldsInBlock(
1929
+ block: SurfaceBlockSnapshot,
1930
+ page: RuntimePageNode,
1931
+ graph: RuntimePageGraph,
1932
+ ): SurfaceBlockSnapshot {
1933
+ switch (block.kind) {
1934
+ case "paragraph": {
1935
+ const segments = resolvePageInstanceFieldsInSegments(block.segments, page, graph);
1936
+ return segments === block.segments ? block : { ...block, segments };
1937
+ }
1938
+ case "table": {
1939
+ let changed = false;
1940
+ const rows = block.rows.map((row) => {
1941
+ let rowChanged = false;
1942
+ const cells = row.cells.map((cell) => {
1943
+ let cellChanged = false;
1944
+ const content = cell.content.map((child) => {
1945
+ const resolved = resolvePageInstanceFieldsInBlock(child, page, graph);
1946
+ if (resolved !== child) cellChanged = true;
1947
+ return resolved;
1948
+ });
1949
+ if (!cellChanged) return cell;
1950
+ rowChanged = true;
1951
+ return { ...cell, content };
1952
+ });
1953
+ if (!rowChanged) return row;
1954
+ changed = true;
1955
+ return { ...row, cells };
1956
+ });
1957
+ return changed ? { ...block, rows } : block;
1958
+ }
1959
+ case "sdt_block": {
1960
+ let changed = false;
1961
+ const children = block.children.map((child) => {
1962
+ const resolved = resolvePageInstanceFieldsInBlock(child, page, graph);
1963
+ if (resolved !== child) changed = true;
1964
+ return resolved;
1965
+ });
1966
+ return changed ? { ...block, children } : block;
1967
+ }
1968
+ default:
1969
+ return block;
1970
+ }
1971
+ }
1972
+
1973
+ function resolvePageInstanceFieldsInSegments(
1974
+ segments: SurfaceInlineSegment[],
1975
+ page: RuntimePageNode,
1976
+ graph: RuntimePageGraph,
1977
+ ): SurfaceInlineSegment[] {
1978
+ let changed = false;
1979
+ const resolvedSegments = segments.map((segment) => {
1980
+ if (segment.kind !== "field_ref" || !PAGE_INSTANCE_FIELD_FAMILIES.has(segment.fieldFamily)) {
1981
+ return segment;
1982
+ }
1983
+ const displayText = resolvePageFieldDisplayText(
1984
+ segment.fieldFamily,
1985
+ segment.displayText ?? segment.label,
1986
+ { page, graph },
1987
+ );
1988
+ if (segment.displayText === displayText && segment.refreshStatus === "current") {
1989
+ return segment;
1990
+ }
1991
+ changed = true;
1992
+ return { ...segment, displayText, refreshStatus: "current" as const };
1993
+ });
1994
+ return changed ? resolvedSegments : segments;
1995
+ }
1996
+
1893
1997
  function resolveFootnoteAreaRegionBlocks(
1894
1998
  node: RuntimePageNode,
1895
1999
  document: CanonicalDocumentEnvelope,
@@ -1945,4 +2049,3 @@ function resolveFootnoteAreaRegionBlocks(
1945
2049
  }
1946
2050
  return Object.freeze(blocks);
1947
2051
  }
1948
-
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Resolve `PAGE` and `NUMPAGES` field values per page.
2
+ * Resolve `PAGE`, `NUMPAGES`, and `SECTIONPAGES` field values per page.
3
3
  *
4
4
  * These two field families (now in `SupportedFieldFamily`) compute differently
5
5
  * from `REF` / `PAGEREF` because the resolved value depends on the *owning
@@ -13,8 +13,10 @@
13
13
  * preserve-only text that currently sits inside the paragraph inline atom.
14
14
  */
15
15
 
16
- import type { RuntimePageGraph, RuntimePageNode } from "./page-graph.ts";
17
16
  import type { SupportedFieldFamily } from "../../model/canonical-document.ts";
17
+ import { formatPageNumber } from "../formatting/field/page-number-format.ts";
18
+ import type { PublicPageNode } from "./public-facet.ts";
19
+ import type { RuntimePageGraph, RuntimePageNode } from "./page-graph.ts";
18
20
 
19
21
  export interface PageFieldContext {
20
22
  /** The page this header/footer copy renders on. */
@@ -23,8 +25,16 @@ export interface PageFieldContext {
23
25
  graph: RuntimePageGraph;
24
26
  }
25
27
 
28
+ export interface PublicPageFieldContext {
29
+ /** The page this header/footer copy renders on. */
30
+ page: PublicPageNode;
31
+ /** All pages from the same layout facet snapshot. */
32
+ pages: readonly PublicPageNode[];
33
+ }
34
+
26
35
  /**
27
- * Resolve the display text for a PAGE / NUMPAGES field on a specific page.
36
+ * Resolve the display text for a PAGE / NUMPAGES / SECTIONPAGES field on a
37
+ * specific runtime page.
28
38
  *
29
39
  * Returns the original cached `displayText` for unsupported families so the
30
40
  * caller can use this as a single resolution helper.
@@ -36,11 +46,54 @@ export function resolvePageFieldDisplayText(
36
46
  ): string {
37
47
  switch (family) {
38
48
  case "PAGE":
39
- return String(context.page.stories.displayPageNumber);
49
+ return formatPageNumber(
50
+ context.page.stories.displayPageNumber,
51
+ context.page.layout?.pageNumbering?.format,
52
+ );
40
53
  case "NUMPAGES":
41
54
  // Blank fillers (evenPage/oddPage section breaks) don't count toward
42
55
  // NUMPAGES — the graph already excludes them from contentPageCount.
43
- return String(context.graph.contentPageCount);
56
+ return formatPageNumber(
57
+ context.graph.contentPageCount,
58
+ context.page.layout?.pageNumbering?.format,
59
+ );
60
+ case "SECTIONPAGES":
61
+ return formatPageNumber(
62
+ countRuntimeSectionContentPages(context.graph, context.page.sectionIndex),
63
+ context.page.layout?.pageNumbering?.format,
64
+ );
65
+ default:
66
+ return cachedDisplayText;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Public-facet equivalent used by page-stack chrome. Header/footer parts are
72
+ * shared stories, but each visible page copy needs its own page-field text.
73
+ */
74
+ export function resolvePublicPageFieldDisplayText(
75
+ family: SupportedFieldFamily | string,
76
+ cachedDisplayText: string,
77
+ context: PublicPageFieldContext,
78
+ ): string {
79
+ switch (family) {
80
+ case "PAGE":
81
+ return formatPageNumber(
82
+ context.page.displayPageNumber,
83
+ context.page.layout.pageNumbering?.format,
84
+ );
85
+ case "NUMPAGES":
86
+ return formatPageNumber(
87
+ context.pages.filter((page) => !page.isBlankFiller).length,
88
+ context.page.layout.pageNumbering?.format,
89
+ );
90
+ case "SECTIONPAGES":
91
+ return formatPageNumber(
92
+ context.pages.filter(
93
+ (page) => !page.isBlankFiller && page.sectionIndex === context.page.sectionIndex,
94
+ ).length,
95
+ context.page.layout.pageNumbering?.format,
96
+ );
44
97
  default:
45
98
  return cachedDisplayText;
46
99
  }
@@ -53,7 +106,7 @@ export function resolvePageFieldDisplayText(
53
106
  */
54
107
  export function buildPageFieldResolutionTable(
55
108
  graph: RuntimePageGraph,
56
- families: ReadonlySet<string> = new Set(["PAGE", "NUMPAGES"]),
109
+ families: ReadonlySet<string> = new Set(["PAGE", "NUMPAGES", "SECTIONPAGES"]),
57
110
  ): Map<string, Map<string, string>> {
58
111
  const result = new Map<string, Map<string, string>>();
59
112
  for (const page of graph.pages) {
@@ -68,3 +121,12 @@ export function buildPageFieldResolutionTable(
68
121
  }
69
122
  return result;
70
123
  }
124
+
125
+ function countRuntimeSectionContentPages(
126
+ graph: RuntimePageGraph,
127
+ sectionIndex: number,
128
+ ): number {
129
+ return graph.pages.filter(
130
+ (page) => !page.isBlankFiller && page.sectionIndex === sectionIndex,
131
+ ).length;
132
+ }
@@ -140,7 +140,7 @@ function collectInlineText(
140
140
  case "field": {
141
141
  const family = inline.fieldFamily ?? classifyFieldInstructionLocal(inline.instruction);
142
142
  const cached = flattenInline(inline.children);
143
- if (family === "PAGE" || family === "NUMPAGES") {
143
+ if (family === "PAGE" || family === "NUMPAGES" || family === "SECTIONPAGES") {
144
144
  out.push(
145
145
  resolvePageFieldDisplayText(family, cached, { page, graph }),
146
146
  );
@@ -37,6 +37,7 @@ export type {
37
37
  WorkflowMetadataSnapshot,
38
38
  WorkflowOverlay,
39
39
  WorkflowScope,
40
+ WorkflowScopeGuardPolicy,
40
41
  WorkflowScopeMetadataField,
41
42
  } from "../../api/public-types.ts";
42
43
 
@@ -204,55 +204,64 @@ function collectGuardVerdict(
204
204
  }
205
205
 
206
206
  const guard = runtime.getInteractionGuardSnapshot();
207
- if (
208
- guard.effectiveMode === "view" &&
209
- !blockedReasons.includes("guard:view-mode-active")
210
- ) {
211
- blockedReasons.push("guard:view-mode-active");
207
+ // Scope-targeted writes target `scope.handle.scopeId`, not the current
208
+ // editor selection. The target scope's own overlay posture was handled
209
+ // above through `scope.workflow.effectiveMode`; this second guard read is
210
+ // only allowed to contribute global/session-wide blockers.
211
+ const isSelectionScopeMembershipReason = (reason: { readonly code: string; readonly scopeId?: string }): boolean => {
212
+ if (reason.code === "outside_workflow_scope") return true;
213
+ if (
214
+ (reason.code === "workflow_view_only" ||
215
+ reason.code === "workflow_comment_only") &&
216
+ typeof reason.scopeId === "string" &&
217
+ reason.scopeId.length > 0
218
+ ) {
219
+ return true;
220
+ }
221
+ return false;
222
+ };
223
+ const rawReasons = guard.blockedReasons ?? [];
224
+ const nonSelectionScoped = rawReasons.filter(
225
+ (r) => !isSelectionScopeMembershipReason(r),
226
+ );
227
+ const pushTypedGuardBlocker = (code: string | undefined): void => {
228
+ const suffix = typeof code === "string" && code.length > 0
229
+ ? code
230
+ : "unspecified";
231
+ const typedBlocker = `guard:block-${suffix}`;
232
+ if (!blockedReasons.some((existing) => existing === typedBlocker)) {
233
+ blockedReasons.push(typedBlocker);
234
+ }
235
+ };
236
+ if (guard.effectiveMode === "view") {
237
+ const globalViewReason = nonSelectionScoped.find(
238
+ (reason) => reason.code === "workflow_view_only",
239
+ );
240
+ if (globalViewReason) {
241
+ pushTypedGuardBlocker(globalViewReason.code);
242
+ } else if (
243
+ rawReasons.length === 0 &&
244
+ !blockedReasons.includes("guard:view-mode-active")
245
+ ) {
246
+ blockedReasons.push("guard:view-mode-active");
247
+ }
248
+ }
249
+ if (guard.effectiveMode === "comment") {
250
+ const globalCommentReason = nonSelectionScoped.find(
251
+ (reason) => reason.code === "workflow_comment_only",
252
+ );
253
+ if (globalCommentReason) {
254
+ pushTypedGuardBlocker(globalCommentReason.code);
255
+ }
212
256
  }
213
257
  if (guard.effectiveMode === "blocked") {
214
258
  // Coord-06 §13e — promote the bare `guard:blocked` blocker to a typed
215
- // `guard:block-<reason>` suffix so agents can route intelligently on
216
- // boundary-paragraph / system-paragraph / read-only / protected-range
217
- // situations. The specific sub-reason is the first NON-selection-
218
- // scope-membership reason.
219
- //
220
- // Scope-targeted-write carve-out (coord-09, TemplateViewer repro
221
- // 2026-04-24): `applyReplacementScope`, `attachExplanation`, and
222
- // `createIssue` target a scopeId, not the current editor selection.
223
- // The scope's own `workflow.effectiveMode` already drove the
224
- // scope-level arm of `collectGuardVerdict` above (lines 159–197).
225
- // The selection-scoped coordinator guard, in contrast, evaluates
226
- // against the live `state.selection` — which, for scope-targeted
227
- // writes, may sit anywhere in the document. Reasons that depend on
228
- // selection-scope membership (`outside_workflow_scope`,
229
- // `workflow_view_only`, `workflow_comment_only`) are therefore
230
- // double-counting and must not block. Globally-scoped reasons
231
- // (`document_read_only`, `document_viewing_mode`) still apply — a
232
- // read-only doc rejects every write, scope-targeted or not.
233
- const SELECTION_SCOPE_MEMBERSHIP_CODES = new Set([
234
- "outside_workflow_scope",
235
- "workflow_view_only",
236
- "workflow_comment_only",
237
- ]);
238
- const rawReasons = guard.blockedReasons ?? [];
239
- const nonSelectionScoped = rawReasons.filter(
240
- (r) => !SELECTION_SCOPE_MEMBERSHIP_CODES.has(r.code),
241
- );
242
- // If every reason was selection-scope-membership for a scope-
243
- // targeted write, emit no blocker — the scope-level arm above is
244
- // authoritative. The defensive empty-array fallback
245
- // (guard:block-unspecified) still fires when the coordinator
246
- // produced effectiveMode:"blocked" without any reasons at all.
259
+ // `guard:block-<reason>` suffix. Selection-membership reasons are
260
+ // intentionally ignored here; global/session reasons such as read-only,
261
+ // protected ranges, shared workflow locks, and unsupported suggesting
262
+ // commands remain blockers.
247
263
  if (nonSelectionScoped.length > 0 || rawReasons.length === 0) {
248
- const primaryCode = nonSelectionScoped[0]?.code;
249
- const suffix = typeof primaryCode === "string" && primaryCode.length > 0
250
- ? primaryCode
251
- : "unspecified";
252
- const typedBlocker = `guard:block-${suffix}`;
253
- if (!blockedReasons.some((existing) => existing === typedBlocker)) {
254
- blockedReasons.push(typedBlocker);
255
- }
264
+ pushTypedGuardBlocker(nonSelectionScoped[0]?.code);
256
265
  }
257
266
  }
258
267
  for (const reason of guard.blockedReasons ?? []) {