@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.
- package/README.md +964 -75
- package/package.json +1 -1
- package/src/api/public-types.ts +280 -1
- package/src/api/v3/_create.ts +16 -1
- package/src/api/v3/_runtime-handle.ts +2 -0
- package/src/api/v3/ai/evaluate.ts +113 -0
- package/src/api/v3/ai/outline.ts +140 -0
- package/src/api/v3/ai/policy.ts +31 -0
- package/src/api/v3/ai/replacement.ts +8 -0
- package/src/api/v3/ai/review.ts +342 -0
- package/src/api/v3/ai/stats.ts +62 -0
- package/src/api/v3/runtime/viewport.ts +181 -0
- package/src/api/v3/runtime/workflow.ts +114 -1
- package/src/api/v3/ui/_types.ts +35 -0
- package/src/api/v3/ui/chrome-preset-model.ts +6 -0
- package/src/api/v3/ui/index.ts +1 -0
- package/src/api/v3/ui/viewport.ts +112 -0
- package/src/compare/diff-engine.ts +2 -0
- package/src/core/commands/formatting-commands.ts +1 -0
- package/src/core/commands/table-structure-commands.ts +1 -0
- package/src/core/state/editor-state.ts +49 -6
- package/src/io/export/serialize-footnotes.ts +6 -0
- package/src/io/export/serialize-headers-footers.ts +7 -0
- package/src/io/export/serialize-main-document.ts +20 -0
- package/src/io/export/serialize-paragraph-formatting.ts +34 -0
- package/src/io/export/split-review-boundaries.ts +1 -0
- package/src/io/normalize/normalize-text.ts +49 -2
- package/src/io/ooxml/parse-headers-footers.ts +31 -0
- package/src/io/ooxml/parse-main-document.ts +148 -7
- package/src/io/ooxml/parse-paragraph-formatting.ts +105 -0
- package/src/model/canonical-document.ts +401 -1
- package/src/runtime/formatting/formatting-context.ts +2 -1
- package/src/runtime/geometry/overlay-rects.ts +7 -10
- package/src/runtime/layout/layout-engine-version.ts +278 -1
- package/src/runtime/layout/paginated-layout-engine.ts +181 -8
- package/src/runtime/layout/resolved-formatting-state.ts +108 -13
- package/src/runtime/markdown-sanitizer.ts +21 -4
- package/src/runtime/render/render-kernel.ts +21 -1
- package/src/runtime/scopes/action-validation.ts +30 -4
- package/src/runtime/scopes/audit-bundle.ts +8 -0
- package/src/runtime/scopes/compiler-service.ts +1 -0
- package/src/runtime/scopes/enumerate-scopes.ts +61 -3
- package/src/runtime/scopes/replacement/apply.ts +50 -3
- package/src/runtime/scopes/scope-kinds/paragraph.ts +170 -7
- package/src/runtime/scopes/semantic-scope-types.ts +27 -0
- package/src/runtime/surface-projection.ts +77 -0
- package/src/runtime/workflow/coordinator.ts +3 -0
- package/src/runtime/workflow/scope-writer.ts +34 -0
- package/src/session/export/embedded-reconstitute.ts +37 -3
- package/src/session/import/embedded-offload.ts +26 -1
- package/src/session/import/loader-types.ts +18 -0
- package/src/session/import/loader.ts +2 -0
- package/src/shell/media-previews.ts +8 -6
- package/src/ui/WordReviewEditor.tsx +1 -0
- package/src/ui/editor-surface-controller.tsx +11 -0
- package/src/ui/headless/selection-helpers.ts +2 -2
- package/src/ui/runtime-shortcut-dispatch.ts +4 -4
- package/src/ui-tailwind/chrome/tw-runtime-repl-dialog.tsx +22 -4
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +11 -11
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -1
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +5 -0
- package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +18 -1
- package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +22 -6
- package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +18 -1
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +98 -3
- package/src/ui-tailwind/editor-surface/pm-schema.ts +50 -4
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +6 -0
- package/src/ui-tailwind/editor-surface/scroll-anchor.ts +8 -1
- package/src/ui-tailwind/editor-surface/search-plugin.ts +2 -4
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +114 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +12 -4
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +29 -4
- package/src/ui-tailwind/index.ts +4 -2
- package/src/ui-tailwind/page-chrome-model.ts +5 -7
- package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +54 -34
- package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +4 -1
- package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +4 -1
- package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +10 -1
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +8 -1
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +11 -1
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +7 -1
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +139 -10
- package/src/ui-tailwind/review/comment-markdown-renderer.tsx +1 -1
- package/src/ui-tailwind/review-workspace/page-chrome.ts +4 -4
- package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +1 -1
- package/src/ui-tailwind/theme/editor-theme.css +15 -16
- package/src/ui-tailwind/tw-review-workspace.tsx +22 -14
|
@@ -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":
|
|
75
|
+
"arial": 4.9,
|
|
76
76
|
"calibri": 5.0,
|
|
77
77
|
"helvetica": 5.8,
|
|
78
78
|
"verdana": 6.7,
|
|
79
|
-
"tahoma":
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
...
|
|
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 (
|
|
420
|
-
return
|
|
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
|
-
//
|
|
56
|
-
//
|
|
57
|
-
if (
|
|
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
|
-
//
|
|
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
|
-
|
|
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,
|
|
@@ -111,6 +111,14 @@ export interface ComposeScopeValidationInputs {
|
|
|
111
111
|
* no blockers / warnings / approval.
|
|
112
112
|
*/
|
|
113
113
|
readonly actionId?: AIAction;
|
|
114
|
+
/**
|
|
115
|
+
* Caller-opt-in preservation policy. Honored at the preservation step
|
|
116
|
+
* only — when `opaqueFragments === true`, opaque-fragment findings
|
|
117
|
+
* downgrade from blockers to warnings (still surfaced on
|
|
118
|
+
* `validation.warnings` + audit). Other policy flags are advisory
|
|
119
|
+
* here; the compile step consumes them to drive per-step behavior.
|
|
120
|
+
*/
|
|
121
|
+
readonly preservePolicy?: ReplacementScope["preserve"];
|
|
114
122
|
}
|
|
115
123
|
|
|
116
124
|
/**
|
|
@@ -240,6 +248,7 @@ function collectGuardVerdict(
|
|
|
240
248
|
function collectPreservationVerdict(
|
|
241
249
|
inputs: ComposeScopeValidationInputs,
|
|
242
250
|
blockedReasons: string[],
|
|
251
|
+
warnings: ValidationIssue[],
|
|
243
252
|
): void {
|
|
244
253
|
const { document, scope, positionMap } = inputs;
|
|
245
254
|
if (!document) return;
|
|
@@ -250,10 +259,27 @@ function collectPreservationVerdict(
|
|
|
250
259
|
? (pm.markerScopes.get(scope.handle.stableRef.value) ?? null)
|
|
251
260
|
: null;
|
|
252
261
|
const verdict = computePreservationVerdict(document, range, pm);
|
|
253
|
-
if (
|
|
254
|
-
|
|
255
|
-
|
|
262
|
+
if (verdict.replaceable) return;
|
|
263
|
+
const opaqueOptIn = inputs.preservePolicy?.opaqueFragments === true;
|
|
264
|
+
for (const reason of verdict.reasons) {
|
|
265
|
+
const code = `preserve:${reason}`;
|
|
266
|
+
// Opt-in downgrade (2026-04-24): opaque-fragment reasons move from
|
|
267
|
+
// blockers → warnings when the caller opted in via
|
|
268
|
+
// `preservePolicy.opaqueFragments === true`. Scope-marker-inside
|
|
269
|
+
// reasons stay as blockers — they'd require the sibling scope to be
|
|
270
|
+
// destroyed, which is a different kind of safety (scope-identity,
|
|
271
|
+
// not preserve-only payload). Other preserve reasons keep blocker
|
|
272
|
+
// semantics until they grow their own opt-in knob.
|
|
273
|
+
if (opaqueOptIn && reason.startsWith("opaque-fragment:")) {
|
|
274
|
+
warnings.push({
|
|
275
|
+
code,
|
|
276
|
+
message:
|
|
277
|
+
"Opaque fragment present in target range; caller opted in to preserve — compile will narrow the replace range to text-only.",
|
|
278
|
+
source: "preserve",
|
|
279
|
+
});
|
|
280
|
+
continue;
|
|
256
281
|
}
|
|
282
|
+
blockedReasons.push(code);
|
|
257
283
|
}
|
|
258
284
|
}
|
|
259
285
|
|
|
@@ -339,7 +365,7 @@ export function composeScopeValidation(
|
|
|
339
365
|
const warnings: ValidationIssue[] = [];
|
|
340
366
|
|
|
341
367
|
collectGuardVerdict(inputs.scope, inputs.runtime, blockedReasons, warnings);
|
|
342
|
-
collectPreservationVerdict(inputs, blockedReasons);
|
|
368
|
+
collectPreservationVerdict(inputs, blockedReasons, warnings);
|
|
343
369
|
collectCompatibilityVerdict(inputs.runtime, blockedReasons, warnings);
|
|
344
370
|
|
|
345
371
|
const actionId =
|
|
@@ -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
|
|
|
@@ -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
|
-
|
|
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 {
|
|
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({
|
|
@@ -131,6 +147,7 @@ export function applyScopeReplacement(
|
|
|
131
147
|
document: docBefore,
|
|
132
148
|
enumeratedScope: resolvedEnumerated,
|
|
133
149
|
...(inputs.actionId ? { actionId: inputs.actionId } : {}),
|
|
150
|
+
...(proposed.preserve ? { preservePolicy: proposed.preserve } : {}),
|
|
134
151
|
});
|
|
135
152
|
|
|
136
153
|
if (!verdict.safe) {
|
|
@@ -139,6 +156,7 @@ export function applyScopeReplacement(
|
|
|
139
156
|
reason: "validation-blocked",
|
|
140
157
|
validation: verdict,
|
|
141
158
|
scope: resolvedScope,
|
|
159
|
+
authoredRevisionIds: Object.freeze([]),
|
|
142
160
|
};
|
|
143
161
|
}
|
|
144
162
|
|
|
@@ -190,19 +208,44 @@ export function applyScopeReplacement(
|
|
|
190
208
|
blockedReasons: Object.freeze([blocker]),
|
|
191
209
|
warnings: verdict.warnings,
|
|
192
210
|
};
|
|
211
|
+
// Coord-08 U5 — `reason` mirrors `blockers[0]` for symmetry. Agents
|
|
212
|
+
// reading either channel at the top level see the same
|
|
213
|
+
// most-actionable signal (`compile-refused:<kind>[:operation-not-
|
|
214
|
+
// implemented:<op>]`). Pre-U5 (before 2026-04-23) `reason` was the
|
|
215
|
+
// bare prefix `"compile-refused"` — callers had to descend into
|
|
216
|
+
// `validation.blockedReasons[0]` to recover the suffix.
|
|
193
217
|
return {
|
|
194
218
|
applied: false,
|
|
195
|
-
reason:
|
|
219
|
+
reason: blocker,
|
|
196
220
|
validation: refused,
|
|
197
221
|
scope: resolvedScope,
|
|
222
|
+
authoredRevisionIds: Object.freeze([]),
|
|
198
223
|
};
|
|
199
224
|
}
|
|
200
225
|
|
|
201
226
|
const documentHashBefore = documentHash(docBefore);
|
|
202
227
|
|
|
228
|
+
// Gap A — capture revision-map keys before dispatch so we can diff
|
|
229
|
+
// post-apply and surface the authored ids to agents. Suggest-mode
|
|
230
|
+
// dispatch (text-insert-tracked / text-delete-tracked) authors
|
|
231
|
+
// insertion + deletion `RevisionRecord` entries in
|
|
232
|
+
// `canonicalDocument.review.revisions`; direct-edit dispatch touches
|
|
233
|
+
// no revisions. The diff approach is side-channel-free and doesn't
|
|
234
|
+
// require plumbing a new return value through the runtime's
|
|
235
|
+
// applyTextCommandInActiveStory seam.
|
|
236
|
+
const revisionsBefore = new Set(
|
|
237
|
+
Object.keys(docBefore.review.revisions ?? {}),
|
|
238
|
+
);
|
|
239
|
+
|
|
203
240
|
inputs.sink.applyScopeReplacement(plan);
|
|
204
241
|
|
|
205
|
-
const
|
|
242
|
+
const docAfter = inputs.sink.getCanonicalDocument();
|
|
243
|
+
const documentHashAfter = documentHash(docAfter);
|
|
244
|
+
|
|
245
|
+
const authoredRevisionIds: string[] = [];
|
|
246
|
+
for (const id of Object.keys(docAfter.review.revisions ?? {})) {
|
|
247
|
+
if (!revisionsBefore.has(id)) authoredRevisionIds.push(id);
|
|
248
|
+
}
|
|
206
249
|
|
|
207
250
|
const audit = emitScopeActionAudit({
|
|
208
251
|
actionId: inputs.actionId ?? "replacement",
|
|
@@ -215,6 +258,9 @@ export function applyScopeReplacement(
|
|
|
215
258
|
plan,
|
|
216
259
|
validation: verdict,
|
|
217
260
|
emittedAtUtc: inputs.emittedAtUtc,
|
|
261
|
+
...(authoredRevisionIds.length > 0
|
|
262
|
+
? { authoredRevisionIds }
|
|
263
|
+
: {}),
|
|
218
264
|
...(inputs.bus ? { bus: inputs.bus } : {}),
|
|
219
265
|
});
|
|
220
266
|
|
|
@@ -224,5 +270,6 @@ export function applyScopeReplacement(
|
|
|
224
270
|
audit,
|
|
225
271
|
plan,
|
|
226
272
|
scope: resolvedScope,
|
|
273
|
+
authoredRevisionIds: Object.freeze([...authoredRevisionIds]),
|
|
227
274
|
};
|
|
228
275
|
}
|