@beyondwork/docx-react-component 1.0.77 → 1.0.78

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.77",
4
+ "version": "1.0.78",
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": [
@@ -10,7 +10,7 @@
10
10
  * preserved in the canonical node's rawXml field for lossless round-trip export.
11
11
  */
12
12
 
13
- import type { ShapeContent } from "../../model/canonical-document.ts";
13
+ import type { BlockNode, ShapeContent } from "../../model/canonical-document.ts";
14
14
  import { parseFill } from "./parse-fill.ts";
15
15
  import {
16
16
  type XmlElementNode,
@@ -40,7 +40,7 @@ export interface ParsedWpsShape {
40
40
  * shape-textbox paragraphs). Same shape + semantics as
41
41
  * `ShapeContent.txbxBlocks` on the drawing-frame path.
42
42
  */
43
- txbxBlocks?: ReadonlyArray<{ type: string; [key: string]: unknown }>;
43
+ txbxBlocks?: ReadonlyArray<BlockNode>;
44
44
  /** DrawML geometry preset, e.g. "rect", "roundRect". */
45
45
  geometry?: string;
46
46
  /** Original drawing XML for lossless round-trip export. */
@@ -122,10 +122,20 @@ export function parseShapeXml(
122
122
  // content (CCEP "Copyright CCEP STRICTLY CONFIDENTIAL" footer band)
123
123
  // is reachable only via the `.text` summary string — L03 cascade +
124
124
  // L11 render can't walk runs/marks.
125
- let txbxBlocks: ReadonlyArray<{ type: string; [key: string]: unknown }> | undefined;
125
+ let txbxBlocks: ReadonlyArray<BlockNode> | undefined;
126
126
  if (txbxContentXml && blockParser) {
127
127
  try {
128
- txbxBlocks = blockParser(txbxContentXml);
128
+ // The `blockParser` callback is supplied by parse-main-document.ts
129
+ // as a thin wrapper over `parseBlockStreamFromXml`. That function
130
+ // returns `ParsedBlockNode[]` — structurally identical to canonical
131
+ // `BlockNode[]` at runtime for shape-textbox content (verified on
132
+ // CCEP SOW footer fixture 2026-04-24: paragraph + text + TextMark
133
+ // shapes land end-to-end with zero `ParsedBlockNode`-only fields
134
+ // surfaced). The cast is safe here because the runtime output IS
135
+ // canonical; a structural `as unknown as BlockNode[]` preserves
136
+ // type safety at every consumer site (L03 cascade, L11 render,
137
+ // validator walk).
138
+ txbxBlocks = blockParser(txbxContentXml) as unknown as ReadonlyArray<BlockNode>;
129
139
  } catch {
130
140
  txbxBlocks = undefined;
131
141
  }
@@ -214,6 +224,17 @@ function extractAllText(node: XmlElementNode): string {
214
224
  // txbxContentXml, optional recursive txbxBlocks).
215
225
  // ───────────────────────────────────────────────────────────────────────────
216
226
 
227
+ /**
228
+ * Callback signature for the txbx-content block parser supplied by
229
+ * parse-main-document.ts / parse-headers-footers.ts. The actual
230
+ * implementation wraps `parseBlockStreamFromXml` which returns
231
+ * `ParsedBlockNode[]`; its runtime output is canonical `BlockNode[]`
232
+ * for shape-textbox content (no `ParsedBlockNode`-only fields surface
233
+ * at the shape boundary — verified on CCEP SOW footer fixture
234
+ * 2026-04-24). The structural `unknown` return keeps the parse layer
235
+ * layer-pure; `parseShapeContent` + `parseShapeXml` cast to canonical
236
+ * `BlockNode[]` at the assembly seam.
237
+ */
217
238
  export type TxbxBlockParser = (xml: string) => ReadonlyArray<{ type: string; [key: string]: unknown }>;
218
239
 
219
240
  export function parseShapeContent(
@@ -240,10 +261,15 @@ export function parseShapeContent(
240
261
  const txbxContent = txbx ? findFirstDescendant(txbx, "txbxContent") : undefined;
241
262
  const txbxContentXml = txbxContent ? extractRawXml(txbxContent) : undefined;
242
263
 
243
- let txbxBlocks: ReadonlyArray<{ type: string; [key: string]: unknown }> | undefined;
264
+ let txbxBlocks: ReadonlyArray<BlockNode> | undefined;
244
265
  if (txbxContentXml && blockParser) {
245
266
  try {
246
- txbxBlocks = blockParser(txbxContentXml);
267
+ // See `TxbxBlockParser` doc above: runtime output is canonical
268
+ // `BlockNode[]` for shape-textbox content (verified on CCEP SOW
269
+ // footer fixture 2026-04-24). Cast at the assembly seam so
270
+ // downstream consumers (L03, L11, validator) get canonical types
271
+ // without local `as unknown` ceremony.
272
+ txbxBlocks = blockParser(txbxContentXml) as unknown as ReadonlyArray<BlockNode>;
247
273
  } catch {
248
274
  // Preserve-only fallback: keep txbxContentXml for serialization; leave
249
275
  // txbxBlocks undefined so consumers know recursion did not succeed.
@@ -1786,12 +1786,29 @@ export interface SmartArtPreviewNode {
1786
1786
  /**
1787
1787
  * Read-only rendering of a wps:wsp WordprocessingShape. Text content is
1788
1788
  * extracted for display. The original drawing XML is preserved in rawXml.
1789
+ *
1790
+ * When the shape is a text-box (`isTextBox: true`), the raw textbox XML
1791
+ * is preserved in `txbxContentXml` for lossless round-trip, and the
1792
+ * parsed block structure lands in `txbxBlocks` — canonical `BlockNode[]`
1793
+ * with styles already resolved (coord-02 §14 / coord-11 §22 closed L01
1794
+ * side 2026-04-24 in `7d87f1189`; L02 type-promoted 2026-04-24 once the
1795
+ * runtime contract was confirmed canonical).
1789
1796
  */
1790
1797
  export interface ShapeNode {
1791
1798
  type: "shape";
1792
1799
  text?: string;
1793
1800
  geometry?: string;
1794
1801
  isTextBox?: boolean;
1802
+ /** Raw `<w:txbxContent>` XML, preserved for serialization + round-trip. */
1803
+ txbxContentXml?: string;
1804
+ /**
1805
+ * Parsed canonical block-level structure from `<w:txbxContent>`,
1806
+ * populated when the parse path supplies a `blockParser` callback
1807
+ * (headers/footers via `src/io/ooxml/parse-headers-footers.ts`;
1808
+ * body via `src/io/ooxml/parse-main-document.ts`). Shape + semantics
1809
+ * identical to `ShapeContent.txbxBlocks` on the drawing-frame path.
1810
+ */
1811
+ txbxBlocks?: ReadonlyArray<BlockNode>;
1795
1812
  rawXml: string;
1796
1813
  }
1797
1814
 
@@ -1971,14 +1988,16 @@ export interface ShapeContent {
1971
1988
  * Parsed block-level structure from `w:txbxContent`, populated when a
1972
1989
  * `blockParser` callback is supplied during parse (CO4 F3.3).
1973
1990
  *
1974
- * Type is deliberately structural (`{ type: string; ... }`) rather than
1975
- * canonical `BlockNode[]` because the recursion stops at the parse layer
1976
- * before the style + numbering normalization pass that converts
1977
- * `ParsedBlockNode` canonical `BlockNode`. Consumers that need the fully
1978
- * normalized form run normalization on this subtree explicitly. Testing
1979
- * that `txbxBlocks.length > 0` proves the recursion executed.
1991
+ * Canonical `BlockNode[]` the parse path produces fully-normalized
1992
+ * blocks (styles resolved, marks attached, no `ParsedBlockNode`-only
1993
+ * fields at runtime). Verified on the CCEP SOW footer fixture 2026-04-24:
1994
+ * paragraph + text + `TextMark` shapes land end-to-end. Type promoted
1995
+ * 2026-04-24 from the earlier weakly-typed escape hatch once the L01
1996
+ * shape-textbox parse (commit `7d87f1189`) confirmed the runtime
1997
+ * contract — unblocks L03 cascade + L11 render walking `txbxBlocks`
1998
+ * without `as unknown as BlockNode[]` casts at the consumer site.
1980
1999
  */
1981
- txbxBlocks?: ReadonlyArray<{ type: string; [key: string]: unknown }>;
2000
+ txbxBlocks?: ReadonlyArray<BlockNode>;
1982
2001
  rawXml: string;
1983
2002
  }
1984
2003
 
@@ -2860,11 +2879,29 @@ function validateDocumentNode(
2860
2879
  return;
2861
2880
  case "chart_preview":
2862
2881
  case "smartart_preview":
2863
- case "shape":
2864
2882
  case "wordart":
2865
2883
  case "vml_shape":
2866
2884
  expectString(record.rawXml, `${path}.rawXml`, issues);
2867
2885
  return;
2886
+ case "shape":
2887
+ expectString(record.rawXml, `${path}.rawXml`, issues);
2888
+ if (record.txbxBlocks !== undefined) {
2889
+ if (!Array.isArray(record.txbxBlocks)) {
2890
+ issues.push({
2891
+ path: `${path}.txbxBlocks`,
2892
+ message: "shape.txbxBlocks must be an array when present.",
2893
+ });
2894
+ } else {
2895
+ // coord-02 §14 follow-up (2026-04-24): `ShapeNode.txbxBlocks`
2896
+ // is canonical `BlockNode[]`. Walk it with the same validator
2897
+ // used for top-level document content so run marks / paragraph
2898
+ // structure / nested shapes all enforce the normal rules.
2899
+ record.txbxBlocks.forEach((child, index) => {
2900
+ validateDocumentNode(child, `${path}.txbxBlocks[${index}]`, issues);
2901
+ });
2902
+ }
2903
+ }
2904
+ return;
2868
2905
  case "drawing_frame": {
2869
2906
  const anchor = asPlainObject(record.anchor, `${path}.anchor`, issues);
2870
2907
  const content = asPlainObject(record.content, `${path}.content`, issues);
@@ -186,6 +186,16 @@ export interface TwChromeOverlayProps {
186
186
  /** Preview catalog threaded into the page-stack chrome so header /
187
187
  * footer / footnote / endnote regions render real <img>s. */
188
188
  mediaPreviews?: Record<string, import("../editor-surface/pm-state-from-snapshot").MediaPreviewDescriptor>;
189
+ /**
190
+ * Slice B (§6.20 reshape) — section-properties ribbon bundle forwarded
191
+ * to whichever per-page header/footer band is the active story. Pass
192
+ * `null` (or omit) when no header/footer is active. The bundle's `kind`
193
+ * is set by the band itself; do not pre-pin it.
194
+ */
195
+ activeBandRibbonProps?: Omit<
196
+ import("../page-stack/tw-active-band-ribbon").TwActiveBandRibbonProps,
197
+ "kind" | "data-testid"
198
+ > | null;
189
199
  }
190
200
 
191
201
  /**
@@ -230,6 +240,7 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
230
240
  pmView,
231
241
  visiblePageIndexRange,
232
242
  mediaPreviews,
243
+ activeBandRibbonProps,
233
244
  }) => {
234
245
  return (
235
246
  <div
@@ -248,6 +259,7 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
248
259
  pmView={pmView}
249
260
  visiblePageIndexRange={visiblePageIndexRange ?? null}
250
261
  mediaPreviews={mediaPreviews}
262
+ activeBandRibbonProps={activeBandRibbonProps ?? null}
251
263
  />
252
264
  ) : null}
253
265
  <TwScopeRailLayer
@@ -916,21 +916,21 @@ export const TwProseMirrorSurface = forwardRef<
916
916
  viewRef.current = view;
917
917
  recordPerfSample("pm.mount");
918
918
  } else {
919
- // Wave 1 Slice C · the single funnel for snapshot replacement.
919
+ // Wave 1 Slice C · snapshot-replacement funnel.
920
920
  //
921
- // `replaceStatePreservingPosition` encapsulates two invariants:
922
- // 1. Scroll position preservation capture the anchor block
923
- // before `view.updateState`, restore scroll after, so the
924
- // user's viewport doesn't jump when blocks above change
925
- // height (invariant 7: geometry-facet warm path, no DOM
926
- // measurement on the hot path).
927
- // 2. Echo-suppression ordering — `suppressSelectionEchoRef` is
928
- // set to `true` BEFORE the state swap and released in a
929
- // microtask AFTER, so PM's internal selection-change events
930
- // during the swap are swallowed by the selection-sync
931
- // plugin.
921
+ // `replaceStatePreservingPosition` owns the echo-suppression
922
+ // ordering around the state swap suppressSelectionEchoRef is
923
+ // set to `true` BEFORE updateState and released in a microtask
924
+ // AFTER, so PM's internal selection-change events during the
925
+ // swap are swallowed by the selection-sync plugin.
932
926
  //
933
- // Ordering is regression-guarded by
927
+ // Scroll-anchor preservation (`preserveScrollAnchor: true`) is
928
+ // currently OFF by default after the 2026-04-24 jump-to-top
929
+ // regression report (see hotfix commit). Re-enable under a
930
+ // diagnosed-safe codepath only; the capture/restore helpers
931
+ // remain tested and ready.
932
+ //
933
+ // Ordering invariant is regression-guarded by
934
934
  // `test/ui-tailwind/editor-surface/preserve-position-ordering.test.ts`.
935
935
  replaceStatePreservingPosition(
936
936
  {
@@ -0,0 +1,229 @@
1
+ import React from "react";
2
+
3
+ import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
4
+ import { TwPageRuler } from "../chrome/tw-page-ruler";
5
+ import type {
6
+ EditorViewStateSnapshot,
7
+ HeaderFooterLinkPatch,
8
+ PageLayoutSnapshot,
9
+ SectionBreakType,
10
+ SectionLayoutPatch,
11
+ SectionPageNumberingPatch,
12
+ } from "../../api/public-types";
13
+ import type { ActiveParagraphLayout } from "../review-workspace/paragraph-layout";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // TwActiveBandRibbon — Slice B of the §6.20 page-layout reshape.
17
+ //
18
+ // A quiet on-demand surface that floats above (header) or below (footer)
19
+ // an active header/footer band. Hosts the section-properties controls
20
+ // previously parked in `TwLayoutPanel` + `TwReviewWorkspacePageToolbar`,
21
+ // scoped to the active story only — matching Word's "Header & Footer"
22
+ // ribbon-tab mental model. Dismisses with the active band.
23
+ //
24
+ // Position context: the parent band uses `position: absolute`; this
25
+ // ribbon uses `position: absolute` with `bottom: 100%` (header) or
26
+ // `top: 100%` (footer) so it overflows the band frame without
27
+ // repositioning the band itself.
28
+ // ---------------------------------------------------------------------------
29
+
30
+ export interface TwActiveBandRibbonProps {
31
+ kind: "header" | "footer";
32
+ pageLayout: PageLayoutSnapshot;
33
+ viewState: EditorViewStateSnapshot;
34
+ paragraphLayout: ActiveParagraphLayout | null;
35
+ readOnly: boolean;
36
+ onCloseStory?: () => void;
37
+ onInsertSectionBreak?: (type: SectionBreakType) => void;
38
+ onUpdateSectionLayout?: (sectionIndex: number, patch: SectionLayoutPatch) => void;
39
+ onSetSectionPageNumbering?: (
40
+ sectionIndex: number,
41
+ patch: SectionPageNumberingPatch | null,
42
+ ) => void;
43
+ onSetHeaderFooterLink?: (sectionIndex: number, patch: HeaderFooterLinkPatch) => void;
44
+ onSetParagraphIndentation?: React.ComponentProps<typeof TwPageRuler>["onSetIndentation"];
45
+ onSetParagraphTabStops?: React.ComponentProps<typeof TwPageRuler>["onSetTabStops"];
46
+ "data-testid"?: string;
47
+ }
48
+
49
+ export const TwActiveBandRibbon: React.FC<TwActiveBandRibbonProps> = React.memo(({
50
+ kind,
51
+ pageLayout,
52
+ viewState,
53
+ paragraphLayout,
54
+ readOnly,
55
+ onCloseStory,
56
+ onInsertSectionBreak,
57
+ onUpdateSectionLayout,
58
+ onSetSectionPageNumbering,
59
+ onSetHeaderFooterLink,
60
+ onSetParagraphIndentation,
61
+ onSetParagraphTabStops,
62
+ "data-testid": testId,
63
+ }) => {
64
+ const sectionIndex = pageLayout.sectionIndex;
65
+ const nextOrientation =
66
+ pageLayout.orientation === "portrait" ? "landscape" : "portrait";
67
+ const titlePageEnabled = pageLayout.differentFirstPage;
68
+ const numberingFormat = pageLayout.pageNumbering?.format ?? "decimal";
69
+ const positionStyles: React.CSSProperties =
70
+ kind === "header"
71
+ ? { left: 0, right: 0, bottom: "100%" }
72
+ : { left: 0, right: 0, top: "100%" };
73
+ const linkVariant: HeaderFooterLinkPatch["variant"] =
74
+ pageLayout.headerVariants[0]?.variant ?? "default";
75
+
76
+ return (
77
+ <div
78
+ data-active-band-ribbon={kind}
79
+ data-testid={testId}
80
+ onMouseDown={preserveEditorSelectionMouseDown}
81
+ style={{
82
+ position: "absolute",
83
+ ...positionStyles,
84
+ pointerEvents: "auto",
85
+ zIndex: 2,
86
+ }}
87
+ className="flex flex-col gap-1 rounded-md border border-border/50 bg-canvas/95 px-2 py-1 shadow-sm backdrop-blur-sm"
88
+ >
89
+ <div className="flex flex-wrap items-center gap-1">
90
+ <span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-tertiary">
91
+ {kind === "header" ? "Header" : "Footer"} · Section {sectionIndex + 1}
92
+ </span>
93
+ {onCloseStory ? (
94
+ <RibbonButton
95
+ ariaLabel="Return to document body"
96
+ onClick={onCloseStory}
97
+ data-testid="active-band-ribbon-close"
98
+ >
99
+ Done
100
+ </RibbonButton>
101
+ ) : null}
102
+ <span aria-hidden="true" className="mx-1 h-3 w-px bg-border/60" />
103
+ <RibbonButton
104
+ ariaLabel={`Switch section to ${nextOrientation}`}
105
+ disabled={readOnly || !onUpdateSectionLayout}
106
+ onClick={() =>
107
+ onUpdateSectionLayout?.(sectionIndex, {
108
+ pageSize: {
109
+ orientation: nextOrientation,
110
+ width: pageLayout.pageHeight,
111
+ height: pageLayout.pageWidth,
112
+ },
113
+ })
114
+ }
115
+ data-testid="active-band-ribbon-orientation"
116
+ >
117
+ {nextOrientation === "landscape" ? "Landscape" : "Portrait"}
118
+ </RibbonButton>
119
+ <RibbonButton
120
+ ariaLabel="Insert next-page section break"
121
+ disabled={readOnly || !onInsertSectionBreak}
122
+ onClick={() => onInsertSectionBreak?.("nextPage")}
123
+ data-testid="active-band-ribbon-section-break"
124
+ >
125
+ Section break
126
+ </RibbonButton>
127
+ <RibbonButton
128
+ ariaLabel="Restart page numbering at 1"
129
+ disabled={readOnly || !onSetSectionPageNumbering}
130
+ onClick={() =>
131
+ onSetSectionPageNumbering?.(sectionIndex, {
132
+ ...(pageLayout.pageNumbering ?? {}),
133
+ start: 1,
134
+ })
135
+ }
136
+ data-testid="active-band-ribbon-restart-numbering"
137
+ >
138
+ Restart numbering
139
+ </RibbonButton>
140
+ <RibbonButton
141
+ ariaLabel={
142
+ numberingFormat === "roman"
143
+ ? "Switch numbering to decimal"
144
+ : "Switch numbering to roman"
145
+ }
146
+ disabled={readOnly || !onSetSectionPageNumbering}
147
+ onClick={() =>
148
+ onSetSectionPageNumbering?.(sectionIndex, {
149
+ ...(pageLayout.pageNumbering ?? {}),
150
+ format: numberingFormat === "roman" ? "decimal" : "roman",
151
+ })
152
+ }
153
+ data-testid="active-band-ribbon-numbering-format"
154
+ >
155
+ {numberingFormat === "roman" ? "Decimal" : "Roman"}
156
+ </RibbonButton>
157
+ <RibbonButton
158
+ ariaLabel="Toggle different first page"
159
+ disabled={readOnly || !onUpdateSectionLayout}
160
+ onClick={() =>
161
+ onUpdateSectionLayout?.(sectionIndex, {
162
+ titlePage: !titlePageEnabled,
163
+ })
164
+ }
165
+ data-testid="active-band-ribbon-title-page"
166
+ >
167
+ {titlePageEnabled ? "Same first page" : "Different first page"}
168
+ </RibbonButton>
169
+ {sectionIndex > 0 && onSetHeaderFooterLink ? (
170
+ <RibbonButton
171
+ ariaLabel={`Link ${kind} to previous section`}
172
+ disabled={readOnly}
173
+ onClick={() =>
174
+ onSetHeaderFooterLink(sectionIndex, {
175
+ kind,
176
+ variant: linkVariant,
177
+ linkToPrevious: true,
178
+ })
179
+ }
180
+ data-testid="active-band-ribbon-link-previous"
181
+ >
182
+ Link to previous
183
+ </RibbonButton>
184
+ ) : null}
185
+ </div>
186
+ <TwPageRuler
187
+ pageLayout={pageLayout}
188
+ viewState={viewState}
189
+ paragraphLayout={paragraphLayout}
190
+ readOnly={readOnly}
191
+ onReturnToBody={onCloseStory ?? (() => undefined)}
192
+ onSetIndentation={onSetParagraphIndentation}
193
+ onSetTabStops={onSetParagraphTabStops}
194
+ />
195
+ </div>
196
+ );
197
+ });
198
+
199
+ interface RibbonButtonProps {
200
+ ariaLabel: string;
201
+ children: React.ReactNode;
202
+ disabled?: boolean;
203
+ onClick: () => void;
204
+ "data-testid"?: string;
205
+ }
206
+
207
+ function RibbonButton({
208
+ ariaLabel,
209
+ children,
210
+ disabled,
211
+ onClick,
212
+ "data-testid": testId,
213
+ }: RibbonButtonProps): React.ReactElement {
214
+ return (
215
+ <button
216
+ type="button"
217
+ aria-label={ariaLabel}
218
+ disabled={disabled}
219
+ data-testid={testId}
220
+ onMouseDown={preserveEditorSelectionMouseDown}
221
+ onClick={onClick}
222
+ className="inline-flex h-6 items-center rounded px-2 text-[11px] font-medium text-primary transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-40"
223
+ >
224
+ {children}
225
+ </button>
226
+ );
227
+ }
228
+
229
+ export default TwActiveBandRibbon;
@@ -27,6 +27,7 @@ import { TwPageHeaderBand } from "./tw-page-header-band.tsx";
27
27
  import { TwPageFooterBand } from "./tw-page-footer-band.tsx";
28
28
  import { TwFootnoteArea } from "./tw-footnote-area.tsx";
29
29
  import type { MediaPreviewDescriptor } from "../editor-surface/pm-state-from-snapshot.ts";
30
+ import type { TwActiveBandRibbonProps } from "./tw-active-band-ribbon.tsx";
30
31
 
31
32
  export interface TwPageChromeEntryProps {
32
33
  rect: PageOverlayRect;
@@ -40,6 +41,15 @@ export interface TwPageChromeEntryProps {
40
41
  /** Preview catalog threaded into header/footer/footnote region renderers
41
42
  * so images (CCEP logos on 7-of-8 CCEP docs) render as real <img>s. */
42
43
  mediaPreviews?: Record<string, MediaPreviewDescriptor>;
44
+ /**
45
+ * Slice B (§6.20 reshape) — section-properties ribbon bundle forwarded
46
+ * to whichever band is active for this page. The bundle's `pageLayout`
47
+ * + `viewState` + `paragraphLayout` already reflect the active story;
48
+ * `kind` is set by the band itself. Pass `null` (or omit) when no
49
+ * header/footer is active anywhere — keeps the inactive-page memo
50
+ * stable across activate/deactivate cycles on a different page.
51
+ */
52
+ activeBandRibbonProps?: Omit<TwActiveBandRibbonProps, "kind" | "data-testid"> | null;
43
53
  }
44
54
 
45
55
  function TwPageChromeEntryInner({
@@ -52,6 +62,7 @@ function TwPageChromeEntryInner({
52
62
  visiblePageIndexRange,
53
63
  renderFrameRevision,
54
64
  mediaPreviews,
65
+ activeBandRibbonProps,
55
66
  }: TwPageChromeEntryProps): React.ReactElement {
56
67
  const layout = page.layout;
57
68
  const headerStory = page.stories.header;
@@ -192,6 +203,7 @@ function TwPageChromeEntryInner({
192
203
  sectionLabel={headerActive ? headerSectionLabel : undefined}
193
204
  onClick={handleHeaderClick}
194
205
  mediaPreviews={mediaPreviews}
206
+ ribbonProps={headerActive ? activeBandRibbonProps ?? null : null}
195
207
  />
196
208
  ) : null}
197
209
  {footerRegion && footerStory ? (
@@ -206,6 +218,7 @@ function TwPageChromeEntryInner({
206
218
  sectionLabel={footerActive ? footerSectionLabel : undefined}
207
219
  onClick={handleFooterClick}
208
220
  mediaPreviews={mediaPreviews}
221
+ ribbonProps={footerActive ? activeBandRibbonProps ?? null : null}
209
222
  />
210
223
  ) : null}
211
224
  {footnoteRegion ? (
@@ -249,7 +262,8 @@ function propsAreEqual(
249
262
  prev.rect.topPx === next.rect.topPx &&
250
263
  prev.rect.bottomPx === next.rect.bottomPx &&
251
264
  prev.rect.pageId === next.rect.pageId &&
252
- prev.mediaPreviews === next.mediaPreviews
265
+ prev.mediaPreviews === next.mediaPreviews &&
266
+ prev.activeBandRibbonProps === next.activeBandRibbonProps
253
267
  );
254
268
  }
255
269
 
@@ -3,6 +3,10 @@ import React from "react";
3
3
  import type { SurfaceBlockSnapshot } from "../../api/public-types.ts";
4
4
  import type { MediaPreviewDescriptor } from "../editor-surface/pm-state-from-snapshot.ts";
5
5
  import { TwRegionBlockRenderer } from "./tw-region-block-renderer.tsx";
6
+ import {
7
+ TwActiveBandRibbon,
8
+ type TwActiveBandRibbonProps,
9
+ } from "./tw-active-band-ribbon.tsx";
6
10
 
7
11
  // ---------------------------------------------------------------------------
8
12
  // TwPageFooterBand (P8.5)
@@ -36,6 +40,12 @@ export interface TwPageFooterBandProps {
36
40
  onClick: () => void;
37
41
  "data-testid"?: string;
38
42
  mediaPreviews?: Record<string, MediaPreviewDescriptor>;
43
+ /**
44
+ * Slice B (§6.20 reshape) — section-properties ribbon that floats
45
+ * below the footer band when it is the active story slot. See
46
+ * `TwPageHeaderBandProps.ribbonProps` for shape rationale.
47
+ */
48
+ ribbonProps?: Omit<TwActiveBandRibbonProps, "kind" | "data-testid"> | null;
39
49
  }
40
50
 
41
51
  export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = React.memo(({
@@ -50,6 +60,7 @@ export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = React.memo(({
50
60
  onClick,
51
61
  "data-testid": testId,
52
62
  mediaPreviews,
63
+ ribbonProps,
53
64
  }) => {
54
65
  return (
55
66
  <div
@@ -73,6 +84,13 @@ export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = React.memo(({
73
84
  {sectionLabel}
74
85
  </span>
75
86
  ) : null}
87
+ {isActiveSlot && ribbonProps ? (
88
+ <TwActiveBandRibbon
89
+ kind="footer"
90
+ {...ribbonProps}
91
+ data-testid={testId ? `${testId}-ribbon` : undefined}
92
+ />
93
+ ) : null}
76
94
  {isActiveSlot ? (
77
95
  <div
78
96
  data-pm-portal-slot
@@ -3,6 +3,10 @@ import React from "react";
3
3
  import type { SurfaceBlockSnapshot } from "../../api/public-types.ts";
4
4
  import type { MediaPreviewDescriptor } from "../editor-surface/pm-state-from-snapshot.ts";
5
5
  import { TwRegionBlockRenderer } from "./tw-region-block-renderer.tsx";
6
+ import {
7
+ TwActiveBandRibbon,
8
+ type TwActiveBandRibbonProps,
9
+ } from "./tw-active-band-ribbon.tsx";
6
10
 
7
11
  // ---------------------------------------------------------------------------
8
12
  // TwPageHeaderBand (P8.5)
@@ -40,6 +44,14 @@ export interface TwPageHeaderBandProps {
40
44
  * (CCEP logos on 7-of-8 CCEP docs) render as real <img>s instead of
41
45
  * the 48×32 placeholder chip. */
42
46
  mediaPreviews?: Record<string, MediaPreviewDescriptor>;
47
+ /**
48
+ * Slice B (§6.20 reshape) — section-properties ribbon that floats
49
+ * above the band when it is the active story slot. Omit (or pass
50
+ * `null`) to suppress the ribbon — `isActiveSlot` alone is not
51
+ * sufficient because some hosts mount the band in active mode without
52
+ * surfacing layout controls (e.g. headless / read-only previews).
53
+ */
54
+ ribbonProps?: Omit<TwActiveBandRibbonProps, "kind" | "data-testid"> | null;
43
55
  }
44
56
 
45
57
  export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = React.memo(({
@@ -54,6 +66,7 @@ export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = React.memo(({
54
66
  onClick,
55
67
  "data-testid": testId,
56
68
  mediaPreviews,
69
+ ribbonProps,
57
70
  }) => {
58
71
  return (
59
72
  <div
@@ -77,6 +90,13 @@ export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = React.memo(({
77
90
  {sectionLabel}
78
91
  </span>
79
92
  ) : null}
93
+ {isActiveSlot && ribbonProps ? (
94
+ <TwActiveBandRibbon
95
+ kind="header"
96
+ {...ribbonProps}
97
+ data-testid={testId ? `${testId}-ribbon` : undefined}
98
+ />
99
+ ) : null}
80
100
  {isActiveSlot ? (
81
101
  <div
82
102
  data-pm-portal-slot
@@ -80,6 +80,7 @@ import {
80
80
  } from "../chrome-overlay/tw-page-stack-overlay-layer.tsx";
81
81
  import { TwEndnoteArea } from "./tw-endnote-area.tsx";
82
82
  import { TwPageChromeEntry } from "./tw-page-chrome-entry.tsx";
83
+ import type { TwActiveBandRibbonProps } from "./tw-active-band-ribbon.tsx";
83
84
 
84
85
  /**
85
86
  * Minimal structural type for the PM `EditorView` handle consumed by
@@ -149,6 +150,13 @@ export interface TwPageStackChromeLayerProps {
149
150
  * in headers/footers/footnote bodies render as real <img>s. Without
150
151
  * this, image segments fall back to the 48×32 gray placeholder chip. */
151
152
  mediaPreviews?: Record<string, import("../editor-surface/pm-state-from-snapshot.ts").MediaPreviewDescriptor>;
153
+ /**
154
+ * Slice B (§6.20 reshape) — section-properties ribbon bundle forwarded
155
+ * to whichever page's active band renders it. Pass `null` (or omit)
156
+ * when no header/footer is active to keep memo equality stable for
157
+ * inactive sessions.
158
+ */
159
+ activeBandRibbonProps?: Omit<TwActiveBandRibbonProps, "kind" | "data-testid"> | null;
152
160
  }
153
161
 
154
162
  const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
@@ -162,6 +170,7 @@ const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
162
170
  visiblePageIndexRange,
163
171
  "data-testid": testId,
164
172
  mediaPreviews,
173
+ activeBandRibbonProps,
165
174
  }) => {
166
175
  const [rects, setRects] = React.useState<readonly PageOverlayRect[]>([]);
167
176
  const overlayRootRef = React.useRef<HTMLDivElement | null>(null);
@@ -407,6 +416,7 @@ const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
407
416
  visiblePageIndexRange={visiblePageIndexRange}
408
417
  renderFrameRevision={renderFrameRevision}
409
418
  mediaPreviews={mediaPreviews}
419
+ activeBandRibbonProps={activeBandRibbonProps}
410
420
  />
411
421
  );
412
422
  })}
@@ -220,12 +220,14 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
220
220
  viewState.selection.activeRange.kind === "node"
221
221
  ? viewState.selection.activeRange.at
222
222
  : viewState.selection.head;
223
- // Slice A (designsystem §6.20 reshape): the legacy "Layout tools"
224
- // disclosure that gated this resolver is gone. Slice B (active-band
225
- // ribbon) re-enables resolution when a header/footer band is the
226
- // active story; until then, no consumer reads activeParagraphLayout
227
- // so the resolver stays inert.
228
- const shouldResolveActiveParagraphLayout = false;
223
+ // Slice B (designsystem §6.20 reshape): the active-band ribbon's
224
+ // `TwPageRuler` reads `activeParagraphLayout` when a header or footer
225
+ // story is the active slot, so the resolver runs only while that ribbon
226
+ // is mounted. Body-mode editing skips the resolver — no consumer reads
227
+ // it on the body path.
228
+ const shouldResolveActiveParagraphLayout =
229
+ snapshot.activeStory.kind === "header" ||
230
+ snapshot.activeStory.kind === "footer";
229
231
  const effectiveSelectionMode = props.interactionGuardSnapshot?.effectiveMode ?? "edit";
230
232
  const allowLocalChromeMutations = Boolean(caps?.canEdit) && effectiveSelectionMode === "edit";
231
233
  const {
@@ -333,6 +335,53 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
333
335
  onDismissSelectionToolbar: props.onDismissSelectionToolbar,
334
336
  });
335
337
 
338
+ // Slice B (designsystem §6.20 reshape) — section-properties ribbon
339
+ // bundle. Built only while a header or footer band is the active
340
+ // story; the chrome layer forwards it to whichever band rendered the
341
+ // ribbon. Document-level read-only governs the controls (the
342
+ // pageChromeReadOnly flag also disables on header/footer-active, which
343
+ // is the wrong polarity here — the ribbon's whole point is editing
344
+ // section properties from inside the header/footer).
345
+ const ribbonReadOnly =
346
+ snapshot.readOnly || effectiveSelectionMode !== "edit";
347
+ const activeBandRibbonProps = useMemo(() => {
348
+ if (
349
+ snapshot.activeStory.kind !== "header" &&
350
+ snapshot.activeStory.kind !== "footer"
351
+ ) {
352
+ return null;
353
+ }
354
+ if (!snapshot.pageLayout) {
355
+ return null;
356
+ }
357
+ return {
358
+ pageLayout: snapshot.pageLayout,
359
+ viewState,
360
+ paragraphLayout: activeParagraphLayout,
361
+ readOnly: ribbonReadOnly,
362
+ onCloseStory: props.onCloseStory,
363
+ onInsertSectionBreak: props.onInsertSectionBreak,
364
+ onUpdateSectionLayout: props.onUpdateSectionLayout,
365
+ onSetSectionPageNumbering: props.onSetSectionPageNumbering,
366
+ onSetHeaderFooterLink: props.onSetHeaderFooterLink,
367
+ onSetParagraphIndentation: props.onSetParagraphIndentation,
368
+ onSetParagraphTabStops: props.onSetParagraphTabStops,
369
+ };
370
+ }, [
371
+ snapshot.activeStory.kind,
372
+ snapshot.pageLayout,
373
+ viewState,
374
+ activeParagraphLayout,
375
+ ribbonReadOnly,
376
+ props.onCloseStory,
377
+ props.onInsertSectionBreak,
378
+ props.onUpdateSectionLayout,
379
+ props.onSetSectionPageNumbering,
380
+ props.onSetHeaderFooterLink,
381
+ props.onSetParagraphIndentation,
382
+ props.onSetParagraphTabStops,
383
+ ]);
384
+
336
385
  // Audit §2.4 — the shell header is ALWAYS present in default composition.
337
386
  // When the host does not supply a pre-assembled shell node, fall back to
338
387
  // a default TwShellHeader wired to the workspace's editor-role state so
@@ -1010,6 +1059,7 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1010
1059
  pmSurfaceElement={pmSurfaceElement}
1011
1060
  visiblePageIndexRange={visiblePageIndexRange}
1012
1061
  mediaPreviews={props.mediaPreviews}
1062
+ activeBandRibbonProps={activeBandRibbonProps}
1013
1063
  />
1014
1064
  ) : null}
1015
1065
  </div>