@beyondwork/docx-react-component 1.0.70 → 1.0.72

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 (75) hide show
  1. package/README.md +964 -75
  2. package/package.json +1 -1
  3. package/src/api/public-types.ts +243 -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/replacement.ts +8 -0
  9. package/src/api/v3/ai/review.ts +342 -0
  10. package/src/api/v3/ai/stats.ts +62 -0
  11. package/src/api/v3/runtime/viewport.ts +181 -0
  12. package/src/api/v3/runtime/workflow.ts +114 -1
  13. package/src/api/v3/ui/_types.ts +35 -0
  14. package/src/api/v3/ui/index.ts +1 -0
  15. package/src/api/v3/ui/viewport.ts +112 -0
  16. package/src/compare/diff-engine.ts +2 -0
  17. package/src/core/commands/formatting-commands.ts +1 -0
  18. package/src/core/commands/table-structure-commands.ts +1 -0
  19. package/src/io/export/serialize-headers-footers.ts +1 -0
  20. package/src/io/export/serialize-main-document.ts +13 -0
  21. package/src/io/export/serialize-paragraph-formatting.ts +34 -0
  22. package/src/io/export/split-review-boundaries.ts +1 -0
  23. package/src/io/normalize/normalize-text.ts +11 -0
  24. package/src/io/ooxml/parse-main-document.ts +21 -5
  25. package/src/io/ooxml/parse-paragraph-formatting.ts +105 -0
  26. package/src/model/canonical-document.ts +401 -1
  27. package/src/runtime/formatting/formatting-context.ts +2 -1
  28. package/src/runtime/geometry/overlay-rects.ts +7 -10
  29. package/src/runtime/layout/layout-engine-version.ts +257 -1
  30. package/src/runtime/layout/paginated-layout-engine.ts +134 -8
  31. package/src/runtime/layout/resolved-formatting-state.ts +108 -13
  32. package/src/runtime/markdown-sanitizer.ts +21 -4
  33. package/src/runtime/render/render-kernel.ts +21 -1
  34. package/src/runtime/scopes/audit-bundle.ts +8 -0
  35. package/src/runtime/scopes/compiler-service.ts +1 -0
  36. package/src/runtime/scopes/enumerate-scopes.ts +61 -3
  37. package/src/runtime/scopes/replacement/apply.ts +49 -3
  38. package/src/runtime/scopes/semantic-scope-types.ts +8 -0
  39. package/src/runtime/surface-projection.ts +22 -0
  40. package/src/runtime/workflow/coordinator.ts +3 -0
  41. package/src/runtime/workflow/scope-writer.ts +34 -0
  42. package/src/session/export/embedded-reconstitute.ts +37 -3
  43. package/src/session/import/embedded-offload.ts +26 -1
  44. package/src/shell/media-previews.ts +8 -6
  45. package/src/ui/WordReviewEditor.tsx +1 -0
  46. package/src/ui/editor-surface-controller.tsx +11 -0
  47. package/src/ui/headless/selection-helpers.ts +2 -2
  48. package/src/ui/runtime-shortcut-dispatch.ts +4 -4
  49. package/src/ui-tailwind/chrome/tw-runtime-repl-dialog.tsx +22 -4
  50. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +11 -11
  51. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -1
  52. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +5 -0
  53. package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +18 -1
  54. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +22 -6
  55. package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +18 -1
  56. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +98 -3
  57. package/src/ui-tailwind/editor-surface/pm-schema.ts +18 -4
  58. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +8 -1
  59. package/src/ui-tailwind/editor-surface/search-plugin.ts +2 -4
  60. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +37 -0
  61. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +29 -4
  62. package/src/ui-tailwind/index.ts +4 -2
  63. package/src/ui-tailwind/page-chrome-model.ts +5 -7
  64. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +5 -2
  65. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +4 -1
  66. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +4 -1
  67. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +10 -1
  68. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +4 -1
  69. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +7 -1
  70. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +7 -1
  71. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +73 -8
  72. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +1 -1
  73. package/src/ui-tailwind/review-workspace/page-chrome.ts +4 -4
  74. package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +1 -1
  75. package/src/ui-tailwind/tw-review-workspace.tsx +1 -0
@@ -72,11 +72,11 @@ const FONT_AVG_CHAR_WIDTH: Record<string, number> = {
72
72
  "palatino linotype": 5.5,
73
73
  "cambria": 5.3,
74
74
  // Proportional sans-serif (xAvgCharWidth ≈ 0.50-0.60 em)
75
- "arial": 5.8,
75
+ "arial": 4.9,
76
76
  "calibri": 5.0,
77
77
  "helvetica": 5.8,
78
78
  "verdana": 6.7,
79
- "tahoma": 5.9,
79
+ "tahoma": 6.3,
80
80
  "segoe ui": 5.4,
81
81
  "trebuchet ms": 5.6,
82
82
  // Monospace (every glyph ≈ 0.60-0.67 em)
@@ -157,9 +157,21 @@ export interface ResolvedTableRowFormatting {
157
157
  // Paragraph formatting resolution
158
158
  // ---------------------------------------------------------------------------
159
159
 
160
+ /**
161
+ * Resolved theme-font lookup. When a segment's
162
+ * `resolvedRunFormatting.*Theme` carries a slot reference (`minorHAnsi`,
163
+ * `majorAscii`, …) but no literal `fontFamily*` name, the dominant-font
164
+ * resolver consults these fallbacks. Source: `document.subParts?.resolvedTheme`.
165
+ */
166
+ export interface LayoutThemeFonts {
167
+ minorFont?: string;
168
+ majorFont?: string;
169
+ }
170
+
160
171
  export function resolveBlockFormatting(
161
172
  block: SurfaceBlockSnapshot,
162
173
  defaultTabInterval = 720,
174
+ themeFonts?: LayoutThemeFonts,
163
175
  ): ResolvedParagraphFormatting | null {
164
176
  if (block.kind !== "paragraph") {
165
177
  return null;
@@ -167,7 +179,7 @@ export function resolveBlockFormatting(
167
179
 
168
180
  const spacing = resolveSpacing(block);
169
181
  const indent = resolveIndentation(block);
170
- const fontInfo = resolveDominantFont(block);
182
+ const fontInfo = resolveDominantFont(block, themeFonts);
171
183
  const lineHeight = resolveLineHeight(spacing, fontInfo.fontSizeHalfPoints);
172
184
  const markerLane = block.resolvedNumbering?.geometry?.markerLane;
173
185
 
@@ -293,6 +305,7 @@ function resolveIndentation(
293
305
 
294
306
  function resolveDominantFont(
295
307
  block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
308
+ themeFonts?: LayoutThemeFonts,
296
309
  ): { fontSizeHalfPoints: number; avgCharWidth: number; fontFamily: string | undefined } {
297
310
  // L03 → L04 contract: read formatting from the canonical L03 output
298
311
  // (`segment.resolvedRunFormatting`) first; fall back to the surface
@@ -304,10 +317,27 @@ function resolveDominantFont(
304
317
  // to combine layers for performance" permits layout to ride the
305
318
  // surface snapshot for locality, but the authoritative values must
306
319
  // come from L03.
320
+ //
321
+ // Theme-slot fallback (Task A4 follow-up, 2026-04-23): when the L03
322
+ // cascade leaves `fontFamilyAscii` absent but carries an `asciiTheme`
323
+ // slot reference (ECMA-376 §17.3.2.26 — `<w:rFonts w:asciiTheme="minorHAnsi"/>`),
324
+ // resolve through `themeFonts` — mirrors L03's own
325
+ // `resolveRunFontFamily` behavior so the empirical measurement
326
+ // backend doesn't fall to the DEFAULT factor for theme-referenced
327
+ // paragraphs. CCEP templates with Tahoma/Calibri theme-bound bodies
328
+ // (e.g. `EU & Global IT Services SOW`) land here.
307
329
  let fontFamily: string | undefined;
308
330
  let fontSizeHalfPoints: number | undefined;
331
+ let resolvedViaTheme = false;
309
332
  let maxTextLength = 0;
310
333
 
334
+ const resolveThemeSlot = (
335
+ slot: string | undefined,
336
+ ): string | undefined => {
337
+ if (!slot || !themeFonts) return undefined;
338
+ return slot.startsWith("major") ? themeFonts.majorFont : themeFonts.minorFont;
339
+ };
340
+
311
341
  for (const segment of block.segments) {
312
342
  if (segment.kind !== "text") continue;
313
343
  const resolved = segment.resolvedRunFormatting;
@@ -315,11 +345,23 @@ function resolveDominantFont(
315
345
  // already layered docDefaults → paragraph style → character style →
316
346
  // direct overrides; the markAttrs fallback only applies when the
317
347
  // block was viewport-culled (resolvedRunFormatting absent).
318
- const segFontFamily =
319
- resolved?.fontFamilyAscii ??
320
- resolved?.fontFamily ??
321
- resolved?.fontFamilyHAnsi ??
322
- segment.markAttrs?.fontFamily;
348
+ // Script precedence: ascii → hAnsi → eastAsia → cs. Within each
349
+ // script, concrete family wins over theme slot.
350
+ let segResolvedViaTheme = false;
351
+ let segFontFamily: string | undefined;
352
+ if (resolved?.fontFamilyAscii) {
353
+ segFontFamily = resolved.fontFamilyAscii;
354
+ } else if (resolved?.asciiTheme && themeFonts) {
355
+ segFontFamily = resolveThemeSlot(resolved.asciiTheme);
356
+ if (segFontFamily) segResolvedViaTheme = true;
357
+ } else if (resolved?.fontFamilyHAnsi) {
358
+ segFontFamily = resolved.fontFamilyHAnsi;
359
+ } else if (resolved?.hAnsiTheme && themeFonts) {
360
+ segFontFamily = resolveThemeSlot(resolved.hAnsiTheme);
361
+ if (segFontFamily) segResolvedViaTheme = true;
362
+ } else {
363
+ segFontFamily = resolved?.fontFamily ?? segment.markAttrs?.fontFamily;
364
+ }
323
365
  const segFontSize =
324
366
  resolved?.fontSizeHalfPoints ?? segment.markAttrs?.fontSize;
325
367
  const textLength = segment.text.length;
@@ -328,6 +370,7 @@ function resolveDominantFont(
328
370
  maxTextLength = textLength;
329
371
  if (typeof segFontFamily === "string") {
330
372
  fontFamily = segFontFamily;
373
+ resolvedViaTheme = segResolvedViaTheme;
331
374
  }
332
375
  if (typeof segFontSize === "number") {
333
376
  fontSizeHalfPoints = segFontSize;
@@ -341,8 +384,32 @@ function resolveDominantFont(
341
384
  ? (FONT_AVG_CHAR_WIDTH[normalizedFamily] ?? DEFAULT_FONT_AVG_CHAR_WIDTH)
342
385
  : DEFAULT_FONT_AVG_CHAR_WIDTH;
343
386
 
387
+ // Theme-slot safety margin: xAvgCharWidth is derived from a fixed
388
+ // character-frequency sample and undercounts character-class-skewed
389
+ // content (legal-contract prose — heavy on capitalized defined terms
390
+ // and digits). When L03 left the paragraph bound to a theme slot and
391
+ // we resolved through `themeFonts`, the actual rendered width on these
392
+ // paragraphs is systematically wider than `xAvgCharWidth × chars`.
393
+ // Apply a safety multiplier — calibrated against 6 CCEP docs
394
+ // (coord-04 §1.18.2 regression finding). 2.4× is the minimum that
395
+ // restores `eu-global-it-services-sow` to its pre-v47 exact-parity
396
+ // (17/17) without regressing any of the 4 docs that won at v47.
397
+ // Concrete-family paragraphs keep the `xAvgCharWidth` factor untouched.
398
+ // The v49 safety multiplier was calibrated against a corpus where
399
+ // `<w:br w:type="page"/>` was being silently dropped by
400
+ // `src/io/normalize/normalize-text.ts::normalizeInlineChildren` (no
401
+ // `case "page_break"` → fell through). With the normalize fix landed
402
+ // alongside this change, real page-breaks now force pagination and
403
+ // SOW lands at 17/17 exact parity at multiplier 1.0 — i.e. theme-slot
404
+ // resolution is still useful for identifying the font, but the
405
+ // character-width factor does not need additional inflation.
406
+ const themeSafetyMultiplier = 1.0;
407
+ const effectiveFactor = resolvedViaTheme
408
+ ? charWidthFactor * themeSafetyMultiplier
409
+ : charWidthFactor;
410
+
344
411
  // Average char width in twips = factor * (fontSize in half-points)
345
- const avgCharWidth = Math.max(96, Math.round(charWidthFactor * effectiveSize));
412
+ const avgCharWidth = Math.max(96, Math.round(effectiveFactor * effectiveSize));
346
413
 
347
414
  return { fontSizeHalfPoints: effectiveSize, avgCharWidth, fontFamily };
348
415
  }
@@ -403,11 +470,39 @@ function resolveTabStops(
403
470
  leader: tab.leader,
404
471
  });
405
472
 
406
- if (numSurfaceTabs && numSurfaceTabs.length > 0 && surfaceTabs && surfaceTabs.length > 0) {
473
+ // Style-cascaded tab stops (coord-04 §1.19.a). L03's
474
+ // `resolveEffectiveParagraphFormatting` merges `docDefaults.paragraph.tabs`
475
+ // → basedOn-chain style tabs → direct `w:tabs`, with direct winning on
476
+ // conflicting positions. The resolved merged set is deposited on
477
+ // `block.resolvedParagraphFormatting.tabStops` in canonical shape
478
+ // (`{ position, align, leader }`). Paragraphs styled TOC1/TOC2/etc.
479
+ // typically have no direct `w:tabs`; the style cascade is the only
480
+ // source. Without this fallback, TOC entries measured under L04 behave
481
+ // as if no tab-stops exist — right-aligned page-number columns get no
482
+ // dedicated tab-stop and the paragraph measurement collapses the tab
483
+ // advance to the default tab interval.
484
+ const cascadeTabs = block.resolvedParagraphFormatting?.tabStops;
485
+ const normalizeCanonical = (tab: {
486
+ position: number;
487
+ align: string;
488
+ leader?: string;
489
+ }): LayoutTabStop => ({
490
+ position: tab.position,
491
+ align: tab.align,
492
+ leader: tab.leader,
493
+ });
494
+
495
+ const paraTabs: LayoutTabStop[] = surfaceTabs && surfaceTabs.length > 0
496
+ ? surfaceTabs.map(normalize)
497
+ : cascadeTabs && cascadeTabs.length > 0
498
+ ? cascadeTabs.map(normalizeCanonical)
499
+ : [];
500
+
501
+ if (numSurfaceTabs && numSurfaceTabs.length > 0 && paraTabs.length > 0) {
407
502
  const numPositions = new Set(numSurfaceTabs.map((t) => t.pos));
408
503
  const merged = [
409
504
  ...numSurfaceTabs.map(normalize),
410
- ...surfaceTabs.filter((t) => !numPositions.has(t.pos)).map(normalize),
505
+ ...paraTabs.filter((t) => !numPositions.has(t.position)),
411
506
  ];
412
507
  return merged.sort((a, b) => a.position - b.position);
413
508
  }
@@ -416,8 +511,8 @@ function resolveTabStops(
416
511
  return numSurfaceTabs.map(normalize).sort((a, b) => a.position - b.position);
417
512
  }
418
513
 
419
- if (surfaceTabs && surfaceTabs.length > 0) {
420
- return surfaceTabs.map(normalize).sort((a, b) => a.position - b.position);
514
+ if (paraTabs.length > 0) {
515
+ return [...paraTabs].sort((a, b) => a.position - b.position);
421
516
  }
422
517
 
423
518
  return [];
@@ -47,17 +47,34 @@ export function sanitizeMarkdown(raw: string): SanitizeResult {
47
47
  (_match, addr: string) => `<${addr}>`,
48
48
  );
49
49
 
50
+ // SEC-UI-01 (2026-04-23): strip HTML tags strictly.
51
+ //
52
+ // Prior implementation preserved any match containing `://`, `@`, or `:` as
53
+ // a heuristic for autolinks. That let `<img onerror="alert(1)" src="x:y">`
54
+ // and `<a href="javascript:alert(1)">` and SVG `<use xlink:href="..."/>`
55
+ // through — the colon in an attribute value triggered preservation.
56
+ //
57
+ // The autolink pre-passes above (scheme URI + email form) already leave
58
+ // proper autolinks as `<scheme:target>` or `<user@host>`. They are the only
59
+ // angle-bracketed forms that should survive this strip. Anything else —
60
+ // anything with attributes, anything with whitespace inside the brackets,
61
+ // anything with quoted attribute content — is HTML-shaped and must be
62
+ // removed regardless of whether its internals happen to contain `:` / `@`.
63
+ const AUTOLINK_SCHEME_RE =
64
+ /^<[a-zA-Z][a-zA-Z0-9+.-]*:[^\s<>"'`]+>$/;
65
+ const AUTOLINK_EMAIL_RE =
66
+ /^<[A-Za-z0-9][A-Za-z0-9._%+-]*@[A-Za-z0-9.-]+\.[A-Za-z]{2,}>$/;
50
67
  const strippedHtml = text.replace(
51
68
  // Require a recognisable HTML tag name so we do not strip safe
52
69
  // autolinks that survived the pass above (email autolinks, bare URIs).
53
70
  /<\/?([A-Za-z][A-Za-z0-9-]*)(\s[^>]*)?\/?>/g,
54
71
  (match, tagName: string) => {
55
- // Skip angle-bracket content that looks like an autolink we just
56
- // approved (email or scheme URI).
57
- if (match.includes("://") || match.includes("@") || match.includes(":")) {
72
+ // Preserve ONLY pure autolink shape. Any attributes / whitespace /
73
+ // quotes definitely HTML, strip.
74
+ if (AUTOLINK_SCHEME_RE.test(match) || AUTOLINK_EMAIL_RE.test(match)) {
58
75
  return match;
59
76
  }
60
- // Also skip plain URIs like `<example.com>` — rare, but preserve.
77
+ // Preserve plain URIs like `<example.com>` — rare, but benign.
61
78
  if (/^[a-z]+\.[a-z]/.test(tagName)) {
62
79
  return match;
63
80
  }
@@ -353,7 +353,27 @@ export function createRenderKernel(input: CreateRenderKernelInput): RenderKernel
353
353
  // Internals
354
354
  // ---------------------------------------------------------------------------
355
355
 
356
- const PAGE_GAP_PX = 16;
356
+ /**
357
+ * Inter-page gap in CSS pixels — the vertical gutter the kernel reserves
358
+ * between adjacent page frames in `RenderPage[]` Y coordinates.
359
+ *
360
+ * **Reconciled to 48 px on 2026-04-23 (refactor/10 Slice L11-3, delegated
361
+ * from refactor/05 closureBlockers.16/48px-gap-reconciliation +
362
+ * refactor/05 handover §5 deferred item 1).** Previously 16 px while the
363
+ * DOM page-break widget at `pm-page-break-decorations.ts:38` rendered a
364
+ * 48 px inter-page gap, causing a 32 px-per-page-boundary drift between
365
+ * kernel-reported `frame.pages[i].topPx` and the actual painted scroll
366
+ * position. Drift was previously compensated for by the chrome layer's
367
+ * DOM-measurement fallback path (overlay-rects "experimental" notice
368
+ * + scroll-anchor's `prepaintFallback`); reconciliation moves the
369
+ * geometry-direct warm path from "experimental" to "production".
370
+ *
371
+ * Consumer impact: every `topPx` value on `frame.pages[i]` for `i >= 1`
372
+ * shifts by +32 px per preceding page boundary. `LAYOUT_ENGINE_VERSION`
373
+ * bumped in the same commit so persisted laycache envelopes auto-
374
+ * invalidate.
375
+ */
376
+ const PAGE_GAP_PX = 48;
357
377
 
358
378
  function buildPage(
359
379
  page: PublicPageNode,
@@ -29,6 +29,11 @@ export interface EmitScopeActionAuditInputs {
29
29
  readonly validation: ValidationResult;
30
30
  readonly emittedAtUtc: string;
31
31
  readonly bus?: TelemetryBus;
32
+ /**
33
+ * Gap A — revision IDs the runtime authored during this apply.
34
+ * Populated for suggest-mode dispatch; omitted for direct-edit.
35
+ */
36
+ readonly authoredRevisionIds?: readonly string[];
32
37
  }
33
38
 
34
39
  export function buildScopeActionAudit(
@@ -52,6 +57,9 @@ export function buildScopeActionAudit(
52
57
  ),
53
58
  validation: inputs.validation,
54
59
  emittedAtUtc: inputs.emittedAtUtc,
60
+ ...(inputs.authoredRevisionIds && inputs.authoredRevisionIds.length > 0
61
+ ? { authoredRevisionIds: Object.freeze([...inputs.authoredRevisionIds]) }
62
+ : {}),
55
63
  };
56
64
  }
57
65
 
@@ -339,6 +339,7 @@ export function createScopeCompilerService(
339
339
  ]),
340
340
  warnings: Object.freeze([]),
341
341
  },
342
+ authoredRevisionIds: Object.freeze([]),
342
343
  };
343
344
  }
344
345
  // Caller-supplied proposedContent takes precedence (structured-
@@ -256,6 +256,46 @@ function buildClassificationIndex(
256
256
  return out;
257
257
  }
258
258
 
259
+ /**
260
+ * Coord-08 §9 / A3 — read a caller-set `stableRefHint` off an overlay
261
+ * scope's metadata. Returns the requested strategy when the field is
262
+ * present AND feasible for the compiler to honor, `null` otherwise
263
+ * (falls back to the default selection).
264
+ *
265
+ * Feasibility matrix:
266
+ * - `"scope-id"` / `"semantic-path"` — always feasible.
267
+ * - `"bookmark"` — requires a bookmark registered at the block; no
268
+ * bookmark-lookup is wired in phase 1, so falls back.
269
+ * - `"runtime-handle"` — not a persistent strategy; ignored.
270
+ * - Unknown string — ignored.
271
+ */
272
+ function stableRefHintForScopeId(
273
+ scopeId: string,
274
+ overlay: WorkflowOverlay | null | undefined,
275
+ ): ScopeHandle["stableRef"]["kind"] | null {
276
+ if (!overlay) return null;
277
+ const scope = overlay.scopes.find(
278
+ (s: WorkflowScope) => s.scopeId === scopeId,
279
+ );
280
+ if (!scope || !scope.metadata) return null;
281
+ const hintField = scope.metadata.find(
282
+ (f: WorkflowScopeMetadataField) => f.key === "stableRefHint",
283
+ );
284
+ if (!hintField || typeof hintField.value !== "string") return null;
285
+ switch (hintField.value) {
286
+ case "scope-id":
287
+ case "semantic-path":
288
+ return hintField.value;
289
+ case "bookmark":
290
+ case "runtime-handle":
291
+ // Declared-but-not-implementable: fall back. Adding bookmark
292
+ // lookup is a phase-2 extension; see coord-08 §9.
293
+ return null;
294
+ default:
295
+ return null;
296
+ }
297
+ }
298
+
259
299
  /**
260
300
  * Walk paragraph children and return the first scope-marker id that
261
301
  * *starts* inside the paragraph. When a paragraph carries multiple
@@ -417,6 +457,26 @@ export function enumerateScopes(
417
457
  markerScopeId !== null ? "marker-backed" : "derived";
418
458
  const rangePrecision: ScopeHandle["rangePrecision"] =
419
459
  markerScopeId !== null ? "marker-backed" : "canonical";
460
+ // Coord-08 §9 — honor caller-set stableRefHint when present. Only
461
+ // consulted on marker-backed paragraphs (derived scopes have no
462
+ // overlay entry to carry a hint).
463
+ const hint =
464
+ markerScopeId !== null
465
+ ? stableRefHintForScopeId(markerScopeId, inputs.overlay)
466
+ : null;
467
+ const defaultMarkerStable =
468
+ markerScopeId !== null
469
+ ? ({ kind: "scope-id", value: markerScopeId } as const)
470
+ : undefined;
471
+ const stableRefOverride =
472
+ hint === "semantic-path"
473
+ ? ({
474
+ kind: "semantic-path" as const,
475
+ value: semanticPath.join("/"),
476
+ })
477
+ : hint === "scope-id" && markerScopeId !== null
478
+ ? ({ kind: "scope-id" as const, value: markerScopeId })
479
+ : defaultMarkerStable;
420
480
  const handle = buildHandle(
421
481
  scopeId,
422
482
  documentId,
@@ -424,9 +484,7 @@ export function enumerateScopes(
424
484
  provenance,
425
485
  rangePrecision,
426
486
  undefined,
427
- markerScopeId !== null
428
- ? { kind: "scope-id", value: markerScopeId }
429
- : undefined,
487
+ stableRefOverride,
430
488
  );
431
489
  const classifications =
432
490
  markerScopeId !== null
@@ -66,6 +66,17 @@ export interface ApplyScopeReplacementResult {
66
66
  readonly audit?: ScopeActionAudit;
67
67
  readonly plan?: RuntimeOperationPlan;
68
68
  readonly scope?: SemanticScope;
69
+ /**
70
+ * Gap A (coord-08 post-Slice-7 integration) — revision IDs authored
71
+ * by the runtime during this apply. Populated for suggest-mode
72
+ * dispatch (where `text-insert-tracked` / `text-delete-tracked`
73
+ * commands author `RevisionRecord` entries in
74
+ * `review.revisions`); empty array for direct-edit (no revisions
75
+ * authored). Agents can pass each id into `ai.acceptRevision` /
76
+ * `ai.rejectRevision` to land or discard the proposal without
77
+ * having to diff the runtime's revision map themselves.
78
+ */
79
+ readonly authoredRevisionIds: readonly string[];
69
80
  }
70
81
 
71
82
  function documentHash(doc: CanonicalDocumentEnvelope): string {
@@ -117,7 +128,12 @@ export function applyScopeReplacement(
117
128
  ]),
118
129
  warnings: Object.freeze([]),
119
130
  };
120
- return { applied: false, reason: "scope-not-resolvable", validation };
131
+ return {
132
+ applied: false,
133
+ reason: "scope-not-resolvable",
134
+ validation,
135
+ authoredRevisionIds: Object.freeze([]),
136
+ };
121
137
  }
122
138
 
123
139
  const verdict = composeScopeValidation({
@@ -139,6 +155,7 @@ export function applyScopeReplacement(
139
155
  reason: "validation-blocked",
140
156
  validation: verdict,
141
157
  scope: resolvedScope,
158
+ authoredRevisionIds: Object.freeze([]),
142
159
  };
143
160
  }
144
161
 
@@ -190,19 +207,44 @@ export function applyScopeReplacement(
190
207
  blockedReasons: Object.freeze([blocker]),
191
208
  warnings: verdict.warnings,
192
209
  };
210
+ // Coord-08 U5 — `reason` mirrors `blockers[0]` for symmetry. Agents
211
+ // reading either channel at the top level see the same
212
+ // most-actionable signal (`compile-refused:<kind>[:operation-not-
213
+ // implemented:<op>]`). Pre-U5 (before 2026-04-23) `reason` was the
214
+ // bare prefix `"compile-refused"` — callers had to descend into
215
+ // `validation.blockedReasons[0]` to recover the suffix.
193
216
  return {
194
217
  applied: false,
195
- reason: "compile-refused",
218
+ reason: blocker,
196
219
  validation: refused,
197
220
  scope: resolvedScope,
221
+ authoredRevisionIds: Object.freeze([]),
198
222
  };
199
223
  }
200
224
 
201
225
  const documentHashBefore = documentHash(docBefore);
202
226
 
227
+ // Gap A — capture revision-map keys before dispatch so we can diff
228
+ // post-apply and surface the authored ids to agents. Suggest-mode
229
+ // dispatch (text-insert-tracked / text-delete-tracked) authors
230
+ // insertion + deletion `RevisionRecord` entries in
231
+ // `canonicalDocument.review.revisions`; direct-edit dispatch touches
232
+ // no revisions. The diff approach is side-channel-free and doesn't
233
+ // require plumbing a new return value through the runtime's
234
+ // applyTextCommandInActiveStory seam.
235
+ const revisionsBefore = new Set(
236
+ Object.keys(docBefore.review.revisions ?? {}),
237
+ );
238
+
203
239
  inputs.sink.applyScopeReplacement(plan);
204
240
 
205
- const documentHashAfter = documentHash(inputs.sink.getCanonicalDocument());
241
+ const docAfter = inputs.sink.getCanonicalDocument();
242
+ const documentHashAfter = documentHash(docAfter);
243
+
244
+ const authoredRevisionIds: string[] = [];
245
+ for (const id of Object.keys(docAfter.review.revisions ?? {})) {
246
+ if (!revisionsBefore.has(id)) authoredRevisionIds.push(id);
247
+ }
206
248
 
207
249
  const audit = emitScopeActionAudit({
208
250
  actionId: inputs.actionId ?? "replacement",
@@ -215,6 +257,9 @@ export function applyScopeReplacement(
215
257
  plan,
216
258
  validation: verdict,
217
259
  emittedAtUtc: inputs.emittedAtUtc,
260
+ ...(authoredRevisionIds.length > 0
261
+ ? { authoredRevisionIds }
262
+ : {}),
218
263
  ...(inputs.bus ? { bus: inputs.bus } : {}),
219
264
  });
220
265
 
@@ -224,5 +269,6 @@ export function applyScopeReplacement(
224
269
  audit,
225
270
  plan,
226
271
  scope: resolvedScope,
272
+ authoredRevisionIds: Object.freeze([...authoredRevisionIds]),
227
273
  };
228
274
  }
@@ -426,6 +426,14 @@ export interface ScopeActionAudit {
426
426
  }[];
427
427
  readonly validation: ValidationResult;
428
428
  readonly emittedAtUtc: string;
429
+ /**
430
+ * Gap A (coord-08 post-Slice-7 integration) — revision IDs authored
431
+ * by the runtime during this apply. Populated for suggest-mode
432
+ * dispatch (tracked insert + delete revisions); omitted for direct-
433
+ * edit. Agents chain into `ai.acceptRevision` / `ai.rejectRevision`
434
+ * to land / discard the proposal without diffing the revision map.
435
+ */
436
+ readonly authoredRevisionIds?: readonly string[];
429
437
  }
430
438
 
431
439
  /* -------------------------------------------------------------------------
@@ -1400,6 +1400,25 @@ function appendInlineSegments(
1400
1400
  state: "locked-preserve-only",
1401
1401
  });
1402
1402
  return { nextCursor: start + 1, lockedFragmentIds: [] };
1403
+ case "page_break":
1404
+ // coord-04 §1.18.5 / coord-03 §11 — `<w:br w:type="page"/>` forces
1405
+ // subsequent content onto a new page. Mirror `column_break`'s
1406
+ // quiet-marker emission (label-based detection). L04 pagination
1407
+ // reads `segment.label === "Page break"` via `hasPageBreak` and
1408
+ // forces `pushPage(block.to)` after placing the carrying block.
1409
+ paragraph.segments.push({
1410
+ segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
1411
+ kind: "opaque_inline",
1412
+ from: start,
1413
+ to: start + 1,
1414
+ fragmentId: "",
1415
+ warningId: "",
1416
+ label: "Page break",
1417
+ detail: "Word hard page break marker — pagination forces a new page here.",
1418
+ presentation: "quiet-marker",
1419
+ state: "locked-preserve-only",
1420
+ });
1421
+ return { nextCursor: start + 1, lockedFragmentIds: [] };
1403
1422
  case "footnote_ref":
1404
1423
  paragraph.segments.push({
1405
1424
  segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
@@ -2070,6 +2089,8 @@ function summarizePreviewInline(node: InlineNode): string {
2070
2089
  return node.char ? String.fromCodePoint(parseInt(node.char, 16)) : "\uFFFD";
2071
2090
  case "column_break":
2072
2091
  return "[Column break]";
2092
+ case "page_break":
2093
+ return "[Page break]";
2073
2094
  case "chart_preview":
2074
2095
  return "[Embedded chart]";
2075
2096
  case "smartart_preview":
@@ -2376,6 +2397,7 @@ function cloneMarks(marks: TextMark[]): {
2376
2397
  break;
2377
2398
  case "highlight":
2378
2399
  highlightColor = mark.color;
2400
+ supported.push("highlight");
2379
2401
  break;
2380
2402
  case "charSpacing":
2381
2403
  attrs.charSpacing = mark.val;
@@ -838,6 +838,9 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
838
838
  anchor: publicAnchor,
839
839
  ...(params.storyTarget ? { storyTarget: params.storyTarget } : {}),
840
840
  ...(params.label ? { label: params.label } : {}),
841
+ ...(params.scopeMetadataFields && params.scopeMetadataFields.length > 0
842
+ ? { metadata: [...params.scopeMetadataFields] }
843
+ : {}),
841
844
  };
842
845
 
843
846
  deps.dispatch({
@@ -34,6 +34,7 @@ import type {
34
34
  RuntimeRenderSnapshot,
35
35
  WorkflowMetadataEntry,
36
36
  WorkflowMetadataPersistence,
37
+ WorkflowScopeMetadataField,
37
38
  WorkflowScopeMode,
38
39
  } from "../../api/public-types.ts";
39
40
 
@@ -68,6 +69,26 @@ export interface CreateScopeFromBlockIdInput {
68
69
  * completeness since `BoundaryAssoc` typing allows them.
69
70
  */
70
71
  readonly assoc?: { readonly start: -1 | 1; readonly end: -1 | 1 };
72
+ /**
73
+ * Coord-08 §9 / coord-09 §1.13 (A3) — caller-steerable identity
74
+ * strategy for the enumerated scope's `ScopeHandle.stableRef`.
75
+ * Stored as an overlay-scope metadata field (`key: "stableRefHint"`)
76
+ * at creation time; the L08 compiler reads it back during
77
+ * enumeration and honors the requested kind when the strategy is
78
+ * feasible, falling back to its default selection otherwise (see
79
+ * `src/runtime/scopes/enumerate-scopes.ts::stableRefHintForScopeId`
80
+ * for the feasibility matrix).
81
+ *
82
+ * Today's honored values: `"scope-id"` / `"semantic-path"`. The
83
+ * `"bookmark"` + `"runtime-handle"` kinds currently fall back (no
84
+ * bookmark-lookup wired in phase 1; runtime-handle is a transient
85
+ * strategy with no durability meaning).
86
+ */
87
+ readonly stableRefHint?:
88
+ | "scope-id"
89
+ | "bookmark"
90
+ | "semantic-path"
91
+ | "runtime-handle";
71
92
  }
72
93
 
73
94
  export type CreateScopeFromBlockIdResult =
@@ -172,6 +193,16 @@ export function createScopeFromBlockId(
172
193
  if (!anchor) {
173
194
  return { status: "block-not-found", blockId: input.blockId };
174
195
  }
196
+ // Coord-08 §9 — encode stableRefHint as an overlay-scope metadata
197
+ // field so the L08 compiler can read it back at enumeration time.
198
+ const scopeMetadataFields: WorkflowScopeMetadataField[] = [];
199
+ if (input.stableRefHint !== undefined) {
200
+ scopeMetadataFields.push({
201
+ key: "stableRefHint",
202
+ valueType: "string",
203
+ value: input.stableRefHint,
204
+ });
205
+ }
175
206
  const result: AddScopeResult = runtime.addScope({
176
207
  anchor,
177
208
  mode: input.mode,
@@ -180,6 +211,9 @@ export function createScopeFromBlockId(
180
211
  metadata: input.metadata,
181
212
  storyTarget: input.storyTarget,
182
213
  label: input.label,
214
+ ...(scopeMetadataFields.length > 0
215
+ ? { scopeMetadataFields }
216
+ : {}),
183
217
  });
184
218
  return { status: "created", scopeId: result.scopeId, anchor: result.anchor };
185
219
  }