@beyondwork/docx-react-component 1.0.50 → 1.0.51

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 CHANGED
@@ -234,11 +234,14 @@ Engineering work is organized into 9 lanes (5 active now + 2 later polish + 2 fi
234
234
  | 3b | [**OOXML Fidelity & Round-Trip**](docs/plans/lane-3b-ooxml-fidelity.md) | **55%** | V1 + O3 + O4 + O2 (all 4 slices) + V7 closed; 🚨 O8 + L2.c + V6 + V2a/b/c + R3–R5 backlog |
235
235
  | 4 | [**Collab + CLM/Vallor**](docs/plans/lane-4-collab-clm-vallor.md) | **80%** | P1–P14 + all P11 sub-bullets + P12 + perf-parity + P13 A/B/C shipped; P15 / P16 / P17 left |
236
236
  | 5 | [**Charts (independent)**](docs/plans/lane-5-charts.md) | **30%** | Stages 0–2 shipped (parsers + theme); Stages 3–7 (SVG renderers + pixel-diff) left |
237
- | 6 | [**Visual Chrome / Layout Polish**](docs/plans/lane-6-visual-chrome-layout-polish.md) | **0%** | LATER activates after Lane 3b V2c + Lane 2 Phase 2.2 ship; discrete paper cards, native chrome, float-wrap, validation bar |
238
- | 7a | [**Visual Fidelity Register**](docs/plans/lane-7a-visual-fidelity-register.md) | **0%** | LATER (gated on 6a+6b)V5 covers, V6 REF/PAGEREF, V7 cascade audit |
239
- | 7b | [**Revision & Redline Completion**](docs/plans/lane-7b-revision-redline-completion.md) | **0%** | LATER (gated on 6a+6b)X4.a/b structural table revisions, X5 ffData, move-pairing |
240
- | 7c | [**Preservation & Format Coverage**](docs/plans/lane-7c-preservation-format-coverage.md) | **0%** | LATER (gated on 6a+6b) O6 Strict, OLE preserve-only, picture-SDT, w14/kern/VML defer |
241
- | 7d | [**Platform Hardening & Hygiene**](docs/plans/lane-7d-platform-hardening-hygiene.md) | **0%** | LATER (gated on 6a+6b) — harness-crash-hardening, fastload activation, worktree consolidation |
237
+ | 6a | [**Tokens & Theme Foundation**](docs/plans/lane-6a-tokens-theme-foundation.md) | **~25%** | Active (no gate) canonical token substrate: brand-accent flip, semantic families, scope-tint tokens, chart palette, hex eviction, density + reduced-motion plumbing |
238
+ | 6b | [**Shell & Workspace Chrome**](docs/plans/lane-6b-shell-workspace-chrome.md) | **~15%** | Gated on 6a.S1+S2shell header + toolbar + status + alert banner + unsaved modal + collab chrome restyle; mode-dock decommission; TwCommandPalette |
239
+ | 6c | [**Context & Review Surfaces**](docs/plans/lane-6c-context-review-surfaces.md) | **~15%** | Gated on 6a.S1+S2selection toolbar + suggestion card + rail + scope + context toolbars + health panel restyle; TwCommentPreview / TwEmptyState / TwShortcutHint |
240
+ | 6d | [**Visual Fidelity**](docs/plans/lane-6d-visual-fidelity.md) | **~20%** | Gated on 6a.S1+S2 + external-lane deps L8 A/B/C shipped; L8 Phase D + P11 overlays + P7 + P12 + P14 next |
241
+ | 7a | [**Visual Fidelity Register**](docs/plans/lane-7a-visual-fidelity-register.md) | **0%** | LATER (gated on 6a–6d) — V5 covers, V6 REF/PAGEREF, V7 cascade audit |
242
+ | 7b | [**Revision & Redline Completion**](docs/plans/lane-7b-revision-redline-completion.md) | **0%** | LATER (gated on 6a–6d) — X4.a/b structural table revisions, X5 ffData, move-pairing |
243
+ | 7c | [**Preservation & Format Coverage**](docs/plans/lane-7c-preservation-format-coverage.md) | **0%** | LATER (gated on 6a–6d) — O6 Strict, OLE preserve-only, picture-SDT, w14/kern/VML defer |
244
+ | 7d | [**Platform Hardening & Hygiene**](docs/plans/lane-7d-platform-hardening-hygiene.md) | **0%** | LATER (gated on 6a–6d) — harness-crash-hardening, fastload activation, worktree consolidation |
242
245
  | 8 | [**API Ergonomics / Errors / BC**](docs/plans/lane-8-api-ergonomics.md) | **40%** | LATER — Tracks A+C shipped (error catalog + ergonomics fixes); Tracks B+D+E + public-api.md end-to-end refactor remain |
243
246
  | 9 | [**Shipping (v2.0.0)**](docs/plans/lane-9-shipping.md) | **0%** | FINAL — API freeze, semver discipline, changelog, telemetry, customer migration guides, doc completeness audit |
244
247
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.50",
4
+ "version": "1.0.51",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "type": "module",
7
7
  "sideEffects": [
@@ -52,8 +52,49 @@
52
52
  * `frame.decorationIndex`. Cache envelopes from version 5 are
53
53
  * invalidated on load because the anchor-index public shape
54
54
  * changed even though pixel geometry did not.
55
+ * 7 — Lane 3a P9 Phase A2. Predicted-delta-aware anchor shifts.
56
+ * `byRuntimeOffset` and `bySelection` now compensate incoming
57
+ * offsets by `sumDeltasBefore(pendingDeltas, offset)` before the
58
+ * map lookup so chrome rects stay aligned with the caret during
59
+ * the predicted-dispatch window. No cached-geometry change; pure
60
+ * resolver behavior change. Cache envelopes from v6 auto-
61
+ * invalidate because the anchor-index now honors the previously-
62
+ * stubbed `pendingDeltas` input.
63
+ * 8 — Lane 3a P10 Phase B. `frame_diff` kernel event now carries a
64
+ * full `RenderFrameDiff` (addedPages / removedPages /
65
+ * unchangedPages / changedPages) instead of the prior
66
+ * `pageRange` payload. New module `src/runtime/render/render-frame-
67
+ * diff.ts` exports `diffRenderFrames(prev, next)` which computes
68
+ * the structural diff (blockId + rounded bbox + decoration-ref
69
+ * intersection — NOT reference equality, because the layout
70
+ * engine rebuilds fragment objects on every pass). Page-stack
71
+ * consumers subscribe to `frame_diff` and memoize unchanged pages
72
+ * to skip React reconciliation. No cached-geometry change; cache
73
+ * envelopes from v7 invalidate because the event shape changed.
74
+ * 9 — Lane 3a P10 Phase C. `analyzeInvalidation` for
75
+ * `section-change` now returns `scope: "bounded"` with
76
+ * `dirtyPageRange.firstPageIndex` set to the affected section's
77
+ * first rendered page (was `scope: "full"`). Pages in earlier
78
+ * sections retain pagination; pages in and after the affected
79
+ * section rebuild. Fallback to full rebuild when the target
80
+ * section has no materialized pages. No cached-geometry change;
81
+ * cache envelopes from v8 invalidate because the invalidation
82
+ * classifier changed the scope contract.
83
+ * 10 — Lane 3a P10 Phase D1. `spliceGraph` detects structural
84
+ * convergence between the fresh tail and the prior tail and
85
+ * reuses the prior `RuntimePageNode` reference for every page
86
+ * that matches on (sectionIndex, pageInSection, startOffset,
87
+ * endOffset, isBlankFiller, body / header / footer / footnote
88
+ * fragmentId lists). Downstream caches — render-frame-diff
89
+ * (`unchangedPages`), prerender cache (stable pageId), React
90
+ * page keys — observe stable references for the common case
91
+ * where a localized edit doesn't shift content forward. Fresh
92
+ * nodes are substituted from the first mismatch onward. No
93
+ * pixel-geometry change; cache envelopes from v9 invalidate
94
+ * because page-node identity semantics on bounded splices
95
+ * changed.
55
96
  */
56
- export const LAYOUT_ENGINE_VERSION = 6 as const;
97
+ export const LAYOUT_ENGINE_VERSION = 10 as const;
57
98
 
58
99
  /**
59
100
  * Serialization schema version for the LayCache payload (the cache envelope
@@ -7,6 +7,21 @@
7
7
  * boundary inside the dirty range, style change, numbering change, etc.) we
8
8
  * fall back to a full rebuild. The `scope` field declares which path the
9
9
  * engine should follow.
10
+ *
11
+ * Bounded scopes today:
12
+ * - `content-edit`: bounded to the first page whose range overlaps the
13
+ * edit offsets (§analyzeContentEdit).
14
+ * - `section-change`: bounded to the first page of the affected section
15
+ * onward — P10 Phase C (§analyzeSectionChange).
16
+ * - `numbering-change`: bounded to the whole document (dirtyPageRange
17
+ * spans pages [0..end]); tightening to the earliest page referencing
18
+ * the numbering instance requires per-fragment numbering metadata
19
+ * on `RuntimeBlockFragment` which is not available today.
20
+ *
21
+ * Remaining full-rebuild triggers:
22
+ * - `styles-change` / `theme-change`: without per-fragment style-chain
23
+ * metadata on the graph, we cannot safely tighten these. Flagged as
24
+ * follow-up for when `RuntimeBlockFragment.resolvedStyleChain` lands.
10
25
  */
11
26
 
12
27
  import type { LayoutInvalidationReason } from "./paginated-layout-engine.ts";
@@ -252,15 +267,57 @@ function analyzeSectionChange(
252
267
  reason: Extract<LayoutInvalidationReason, { kind: "section-change" }>,
253
268
  graph: RuntimePageGraph,
254
269
  ): InvalidationResult {
255
- // Section property changes affect from that section onward; conservative
256
- // fallback is a full recompute.
270
+ // P10 Phase C — section property changes affect pages from the first
271
+ // page of the affected section onward. Earlier pages in earlier
272
+ // sections retain their pagination. When the target section has no
273
+ // materialized pages (e.g. section-change for a section that hasn't
274
+ // rendered yet), fall back to a full recompute.
275
+ const firstPageOfSection = findFirstPageIndexForSection(
276
+ graph,
277
+ reason.sectionIndex,
278
+ );
279
+ if (firstPageOfSection < 0) {
280
+ return {
281
+ scope: "full",
282
+ requiresFullRecompute: true,
283
+ dirtySectionRange: {
284
+ from: reason.sectionIndex,
285
+ to: Math.max(0, graph.sections.length - 1),
286
+ },
287
+ dirtyFieldFamilies: ["PAGE", "NUMPAGES", "SECTIONPAGES"],
288
+ };
289
+ }
290
+
291
+ // Bounded: rebuild from the affected section's first page to the end
292
+ // of the document. Rebuilding to the end (not just the section's last
293
+ // page) is required because a section property change — page size,
294
+ // margins, column count — can propagate to downstream sections if a
295
+ // `nextPage` / `evenPage` / `oddPage` break shifts.
257
296
  return {
258
- scope: "full",
259
- requiresFullRecompute: true,
297
+ scope: "bounded",
298
+ requiresFullRecompute: false,
299
+ dirtyPageRange: {
300
+ firstPageIndex: firstPageOfSection,
301
+ lastPageIndex: Math.max(0, graph.pages.length - 1),
302
+ },
260
303
  dirtySectionRange: {
261
304
  from: reason.sectionIndex,
262
- to: graph.sections.length - 1,
305
+ to: Math.max(0, graph.sections.length - 1),
263
306
  },
264
307
  dirtyFieldFamilies: ["PAGE", "NUMPAGES", "SECTIONPAGES"],
265
308
  };
266
309
  }
310
+
311
+ function findFirstPageIndexForSection(
312
+ graph: RuntimePageGraph,
313
+ sectionIndex: number,
314
+ ): number {
315
+ for (let i = 0; i < graph.pages.length; i += 1) {
316
+ const page = graph.pages[i]!;
317
+ if (page.isBlankFiller) continue;
318
+ if (page.sectionIndex === sectionIndex) return i;
319
+ }
320
+ // No page found for this section — section may be empty or past the
321
+ // rendered tail. Caller treats this as a full-rebuild trigger.
322
+ return -1;
323
+ }
@@ -522,7 +522,34 @@ export function spliceGraph(
522
522
  graphRevision += 1;
523
523
  const clampedFirst = Math.max(0, Math.min(firstDirtyIndex, prior.pages.length));
524
524
  const preserved = prior.pages.slice(0, clampedFirst);
525
- const nextPages: RuntimePageNode[] = [...preserved, ...freshPages];
525
+
526
+ // P10 Phase D1 — convergence-point tail reuse. When a fresh page at
527
+ // index `clampedFirst + i` structurally matches the prior graph's
528
+ // page at the same index (same startOffset, endOffset, sectionIndex,
529
+ // fragment count), substitute the prior `RuntimePageNode` so
530
+ // downstream caches (render-frame-diff, prerender cache, React page
531
+ // keys) observe a stable reference. Stops at the first mismatch —
532
+ // everything after that re-pagination produced fresh has to flow
533
+ // through the fresh nodes because pagination may have shifted it.
534
+ // When prior has fewer pages past clampedFirst than fresh (e.g., an
535
+ // edit added pages), `reusedCount` caps at the prior length so we
536
+ // don't index past the prior tail.
537
+ const priorTail = prior.pages.slice(clampedFirst);
538
+ const reuseLimit = Math.min(priorTail.length, freshPages.length);
539
+ let reusedCount = 0;
540
+ while (
541
+ reusedCount < reuseLimit &&
542
+ pageNodesStructurallyEqual(priorTail[reusedCount]!, freshPages[reusedCount]!)
543
+ ) {
544
+ reusedCount += 1;
545
+ }
546
+ const stableTailPrefix = priorTail.slice(0, reusedCount);
547
+ const divergentTail = freshPages.slice(reusedCount);
548
+ const nextPages: RuntimePageNode[] = [
549
+ ...preserved,
550
+ ...stableTailPrefix,
551
+ ...divergentTail,
552
+ ];
526
553
 
527
554
  const survivingPageIds = new Set(nextPages.map((page) => page.pageId));
528
555
  const mergedFragments: RuntimeBlockFragment[] = [];
@@ -558,3 +585,69 @@ export function spliceGraph(
558
585
  contentPageCount,
559
586
  };
560
587
  }
588
+
589
+ /**
590
+ * Compare two `RuntimePageNode`s by the fields that matter for
591
+ * pagination identity — if these all match, the fresh node represents
592
+ * the same content the prior graph already paginated. Used by
593
+ * `spliceGraph` to detect convergence between the fresh tail and the
594
+ * prior tail (P10 Phase D1) so downstream caches see stable references.
595
+ *
596
+ * We deliberately compare fragmentIds rather than full fragment objects
597
+ * because fragments are re-minted on each build even when they project
598
+ * identical canonical content; their id is derived from a stable
599
+ * structural hash.
600
+ */
601
+ function pageNodesStructurallyEqual(
602
+ a: RuntimePageNode,
603
+ b: RuntimePageNode,
604
+ ): boolean {
605
+ if (a.sectionIndex !== b.sectionIndex) return false;
606
+ if (a.pageInSection !== b.pageInSection) return false;
607
+ if (a.startOffset !== b.startOffset) return false;
608
+ if (a.endOffset !== b.endOffset) return false;
609
+ if (a.isBlankFiller !== b.isBlankFiller) return false;
610
+ if (a.regions.body.fragmentIds.length !== b.regions.body.fragmentIds.length) {
611
+ return false;
612
+ }
613
+ for (let i = 0; i < a.regions.body.fragmentIds.length; i += 1) {
614
+ if (a.regions.body.fragmentIds[i] !== b.regions.body.fragmentIds[i]) {
615
+ return false;
616
+ }
617
+ }
618
+ // Header / footer fragment lists: compare if present on either side.
619
+ if (!optionalRegionFragmentsEqual(a.regions.header, b.regions.header)) {
620
+ return false;
621
+ }
622
+ if (!optionalRegionFragmentsEqual(a.regions.footer, b.regions.footer)) {
623
+ return false;
624
+ }
625
+ // Footnote-area regions: compare count + per-region fragment lists.
626
+ const aFoot = a.regions.footnotes ?? [];
627
+ const bFoot = b.regions.footnotes ?? [];
628
+ if (aFoot.length !== bFoot.length) return false;
629
+ for (let i = 0; i < aFoot.length; i += 1) {
630
+ if (!regionFragmentsEqual(aFoot[i]!, bFoot[i]!)) return false;
631
+ }
632
+ return true;
633
+ }
634
+
635
+ function optionalRegionFragmentsEqual(
636
+ a: RuntimePageRegion | undefined,
637
+ b: RuntimePageRegion | undefined,
638
+ ): boolean {
639
+ if (!a && !b) return true;
640
+ if (!a || !b) return false;
641
+ return regionFragmentsEqual(a, b);
642
+ }
643
+
644
+ function regionFragmentsEqual(
645
+ a: RuntimePageRegion,
646
+ b: RuntimePageRegion,
647
+ ): boolean {
648
+ if (a.fragmentIds.length !== b.fragmentIds.length) return false;
649
+ for (let i = 0; i < a.fragmentIds.length; i += 1) {
650
+ if (a.fragmentIds[i] !== b.fragmentIds[i]) return false;
651
+ }
652
+ return true;
653
+ }
@@ -30,6 +30,13 @@ export {
30
30
  type LockedRangeInput,
31
31
  } from "./decoration-resolver.ts";
32
32
 
33
+ export {
34
+ diffRenderFrames,
35
+ isEmptyDiff,
36
+ type ChangedPageEntry,
37
+ type RenderFrameDiff,
38
+ } from "./render-frame-diff.ts";
39
+
33
40
  export {
34
41
  DEFAULT_PX_PER_TWIP,
35
42
  EMPTY_DECORATION_INDEX,
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Render-frame diff (P10 Phase B).
3
+ *
4
+ * Computes a structural diff between two `RenderFrame`s so the page-stack
5
+ * view can skip React reconciliation on pages whose content, geometry,
6
+ * and decoration anchors are unchanged. The diff is a pure function over
7
+ * the two frames — it performs no DOM reads, holds no state, and can run
8
+ * in a worker-less main-thread slice safely.
9
+ *
10
+ * Critical: comparison is structural (blockId + rounded bbox +
11
+ * decoration-ref intersection), NEVER reference equality. The layout
12
+ * engine's `buildPageStackFrom` rebuilds fragment objects on every call,
13
+ * so reference compare would report every page as dirty and collapse the
14
+ * skip-render optimization.
15
+ */
16
+
17
+ import type { PublicPageRegion } from "../layout/public-facet.ts";
18
+ import type {
19
+ RenderBlock,
20
+ RenderFrame,
21
+ RenderFrameRect,
22
+ RenderPage,
23
+ RenderStoryRegion,
24
+ DecorationIndex,
25
+ } from "./render-frame-types.ts";
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Public shape
29
+ // ---------------------------------------------------------------------------
30
+
31
+ export type RegionKind = PublicPageRegion["kind"];
32
+
33
+ export interface ChangedPageEntry {
34
+ pageIndex: number;
35
+ /**
36
+ * Regions of the page that carry structural changes. Empty means the
37
+ * page itself changed (added / removed region or page-frame geometry)
38
+ * without a single region being identifiable.
39
+ */
40
+ regions: readonly {
41
+ kind: RegionKind;
42
+ /**
43
+ * Block ids whose bbox, kind, decoration refs, or membership
44
+ * differs between prev and next. `"<added>"` and `"<removed>"`
45
+ * sentinel ids indicate list-length changes.
46
+ */
47
+ changedBlockIds: readonly string[];
48
+ }[];
49
+ }
50
+
51
+ export interface RenderFrameDiff {
52
+ /** Page indices present in `next` but not `prev`. */
53
+ addedPages: readonly number[];
54
+ /** Page indices present in `prev` but not `next`. */
55
+ removedPages: readonly number[];
56
+ /** Pages present in both whose content is structurally equal. */
57
+ unchangedPages: readonly number[];
58
+ /** Pages present in both with at least one structural change. */
59
+ changedPages: readonly ChangedPageEntry[];
60
+ }
61
+
62
+ /**
63
+ * Convenience flag: a diff is empty when both frames project the same
64
+ * set of pages and every shared page is unchanged.
65
+ */
66
+ export function isEmptyDiff(diff: RenderFrameDiff): boolean {
67
+ return (
68
+ diff.addedPages.length === 0 &&
69
+ diff.removedPages.length === 0 &&
70
+ diff.changedPages.length === 0
71
+ );
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Diff entry point
76
+ // ---------------------------------------------------------------------------
77
+
78
+ export function diffRenderFrames(
79
+ prev: RenderFrame | null,
80
+ next: RenderFrame,
81
+ ): RenderFrameDiff {
82
+ if (!prev) {
83
+ return {
84
+ addedPages: next.pages.map((p) => p.page.pageIndex),
85
+ removedPages: [],
86
+ unchangedPages: [],
87
+ changedPages: [],
88
+ };
89
+ }
90
+
91
+ const prevByIndex = new Map<number, RenderPage>();
92
+ for (const page of prev.pages) {
93
+ prevByIndex.set(page.page.pageIndex, page);
94
+ }
95
+ const nextIndices = new Set<number>();
96
+ for (const page of next.pages) {
97
+ nextIndices.add(page.page.pageIndex);
98
+ }
99
+
100
+ const addedPages: number[] = [];
101
+ const removedPages: number[] = [];
102
+ const unchangedPages: number[] = [];
103
+ const changedPages: ChangedPageEntry[] = [];
104
+
105
+ for (const nextPage of next.pages) {
106
+ const pageIndex = nextPage.page.pageIndex;
107
+ const prevPage = prevByIndex.get(pageIndex);
108
+ if (!prevPage) {
109
+ addedPages.push(pageIndex);
110
+ continue;
111
+ }
112
+ const regions = diffPage(prevPage, nextPage, prev.decorationIndex, next.decorationIndex);
113
+ if (regions.length === 0 && rectEquals(prevPage.frame, nextPage.frame)) {
114
+ unchangedPages.push(pageIndex);
115
+ } else {
116
+ changedPages.push({ pageIndex, regions });
117
+ }
118
+ }
119
+
120
+ for (const prevPage of prev.pages) {
121
+ if (!nextIndices.has(prevPage.page.pageIndex)) {
122
+ removedPages.push(prevPage.page.pageIndex);
123
+ }
124
+ }
125
+
126
+ return { addedPages, removedPages, unchangedPages, changedPages };
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Page / region / block compare
131
+ // ---------------------------------------------------------------------------
132
+
133
+ function diffPage(
134
+ prev: RenderPage,
135
+ next: RenderPage,
136
+ prevIndex: DecorationIndex,
137
+ nextIndex: DecorationIndex,
138
+ ): ChangedPageEntry["regions"] {
139
+ const changed: ChangedPageEntry["regions"][number][] = [];
140
+
141
+ const bodyChanges = diffRegion(prev.regions.body, next.regions.body, prevIndex, nextIndex);
142
+ if (bodyChanges.length > 0) {
143
+ changed.push({ kind: "body", changedBlockIds: bodyChanges });
144
+ }
145
+
146
+ const headerChanges = diffOptionalRegion(
147
+ prev.regions.header,
148
+ next.regions.header,
149
+ prevIndex,
150
+ nextIndex,
151
+ );
152
+ if (headerChanges.length > 0) {
153
+ changed.push({ kind: "header", changedBlockIds: headerChanges });
154
+ }
155
+
156
+ const footerChanges = diffOptionalRegion(
157
+ prev.regions.footer,
158
+ next.regions.footer,
159
+ prevIndex,
160
+ nextIndex,
161
+ );
162
+ if (footerChanges.length > 0) {
163
+ changed.push({ kind: "footer", changedBlockIds: footerChanges });
164
+ }
165
+
166
+ const prevFoot = prev.regions.footnotes ?? [];
167
+ const nextFoot = next.regions.footnotes ?? [];
168
+ if (prevFoot.length !== nextFoot.length) {
169
+ changed.push({ kind: "footnote-area", changedBlockIds: ["<count-changed>"] });
170
+ } else {
171
+ for (let i = 0; i < prevFoot.length; i += 1) {
172
+ const fChanges = diffRegion(prevFoot[i]!, nextFoot[i]!, prevIndex, nextIndex);
173
+ if (fChanges.length > 0) {
174
+ changed.push({ kind: "footnote-area", changedBlockIds: fChanges });
175
+ }
176
+ }
177
+ }
178
+
179
+ return changed;
180
+ }
181
+
182
+ function diffOptionalRegion(
183
+ prev: RenderStoryRegion | undefined,
184
+ next: RenderStoryRegion | undefined,
185
+ prevIndex: DecorationIndex,
186
+ nextIndex: DecorationIndex,
187
+ ): readonly string[] {
188
+ if (!prev && !next) return [];
189
+ if (!prev && next) return ["<added>"];
190
+ if (prev && !next) return ["<removed>"];
191
+ return diffRegion(prev!, next!, prevIndex, nextIndex);
192
+ }
193
+
194
+ function diffRegion(
195
+ prev: RenderStoryRegion,
196
+ next: RenderStoryRegion,
197
+ prevIndex: DecorationIndex,
198
+ nextIndex: DecorationIndex,
199
+ ): readonly string[] {
200
+ const changed: string[] = [];
201
+ if (!rectEquals(prev.frame, next.frame)) {
202
+ changed.push("<region-frame>");
203
+ }
204
+ // Index blocks by blockId. Same blockId across frames is expected when a
205
+ // block is stable even if the containing RenderBlock object is
206
+ // different — this is the critical structural-compare invariant.
207
+ const prevBlocks = new Map<string, RenderBlock>();
208
+ for (const block of prev.blocks) {
209
+ prevBlocks.set(block.fragment.blockId, block);
210
+ }
211
+ const nextIds = new Set<string>();
212
+ for (const block of next.blocks) {
213
+ nextIds.add(block.fragment.blockId);
214
+ const prevBlock = prevBlocks.get(block.fragment.blockId);
215
+ if (!prevBlock) {
216
+ changed.push(block.fragment.blockId);
217
+ continue;
218
+ }
219
+ if (!blocksStructurallyEqual(prevBlock, block, prevIndex, nextIndex)) {
220
+ changed.push(block.fragment.blockId);
221
+ }
222
+ }
223
+ for (const blockId of prevBlocks.keys()) {
224
+ if (!nextIds.has(blockId)) changed.push(blockId);
225
+ }
226
+ return changed;
227
+ }
228
+
229
+ function blocksStructurallyEqual(
230
+ a: RenderBlock,
231
+ b: RenderBlock,
232
+ aIndex: DecorationIndex,
233
+ bIndex: DecorationIndex,
234
+ ): boolean {
235
+ if (a.kind !== b.kind) return false;
236
+ if (a.fragment.regionKind !== b.fragment.regionKind) return false;
237
+ if (a.fragment.from !== b.fragment.from) return false;
238
+ if (a.fragment.to !== b.fragment.to) return false;
239
+ if (!rectEquals(a.frame, b.frame)) return false;
240
+ if (a.lines.length !== b.lines.length) return false;
241
+ for (let i = 0; i < a.lines.length; i += 1) {
242
+ if (!rectEquals(a.lines[i]!.frame, b.lines[i]!.frame)) return false;
243
+ }
244
+ // Decoration-ref intersection: a block's decoration set changes when
245
+ // any lane in the decoration index mentions a rect whose frame equals
246
+ // this block's frame (coarse but sufficient for skip-render). A more
247
+ // precise walk would key by runtime-offset overlap; we defer that to a
248
+ // follow-up once decoration-resolver exposes per-block lanes directly.
249
+ const aHash = decorationHashForBlock(a.frame, aIndex);
250
+ const bHash = decorationHashForBlock(b.frame, bIndex);
251
+ if (aHash !== bHash) return false;
252
+ return true;
253
+ }
254
+
255
+ function decorationHashForBlock(
256
+ blockFrame: RenderFrameRect,
257
+ index: DecorationIndex,
258
+ ): string {
259
+ const tokens: string[] = [];
260
+ for (const lane of [index.workflow, index.comments, index.revisions, index.search, index.locked] as const) {
261
+ for (const entry of lane) {
262
+ if (rectIntersects(entry.frame, blockFrame)) {
263
+ tokens.push(`${entry.kind}:${entry.refId}`);
264
+ }
265
+ }
266
+ }
267
+ tokens.sort();
268
+ return tokens.join("|");
269
+ }
270
+
271
+ // ---------------------------------------------------------------------------
272
+ // Geometry compare
273
+ // ---------------------------------------------------------------------------
274
+
275
+ /**
276
+ * Compare two rects with sub-pixel tolerance so rounding differences
277
+ * between frame builds don't report spurious changes. 0.1 px matches the
278
+ * smallest meaningful chrome movement at 200% zoom.
279
+ */
280
+ const RECT_EPS = 0.1;
281
+
282
+ function rectEquals(a: RenderFrameRect, b: RenderFrameRect): boolean {
283
+ return (
284
+ Math.abs(a.leftPx - b.leftPx) < RECT_EPS &&
285
+ Math.abs(a.topPx - b.topPx) < RECT_EPS &&
286
+ Math.abs(a.widthPx - b.widthPx) < RECT_EPS &&
287
+ Math.abs(a.heightPx - b.heightPx) < RECT_EPS
288
+ );
289
+ }
290
+
291
+ function rectIntersects(a: RenderFrameRect, b: RenderFrameRect): boolean {
292
+ return !(
293
+ a.leftPx + a.widthPx <= b.leftPx ||
294
+ b.leftPx + b.widthPx <= a.leftPx ||
295
+ a.topPx + a.heightPx <= b.topPx ||
296
+ b.topPx + b.heightPx <= a.topPx
297
+ );
298
+ }
@@ -294,7 +294,14 @@ export type RenderKernelEvent =
294
294
  | {
295
295
  kind: "frame_diff";
296
296
  revision: number;
297
- pageRange: { fromPageIndex: number; toPageIndex: number };
297
+ /**
298
+ * Full structural diff between the previously-cached frame and the
299
+ * newly-built frame. Shipped with P10 Phase B (2026-04-19).
300
+ * Consumers read `diff.unchangedPages` to skip per-page React
301
+ * reconciliation and `diff.changedPages[].regions[].changedBlockIds`
302
+ * to drive targeted sub-tree updates.
303
+ */
304
+ diff: import("./render-frame-diff.ts").RenderFrameDiff;
298
305
  }
299
306
  | { kind: "decoration_resolved"; revision: number };
300
307