@beyondwork/docx-react-component 1.0.72 → 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.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +37 -0
  3. package/src/api/v3/ai/policy.ts +31 -0
  4. package/src/api/v3/ui/chrome-preset-model.ts +6 -0
  5. package/src/api/v3/ui/viewport.ts +1 -1
  6. package/src/core/state/editor-state.ts +49 -6
  7. package/src/io/export/serialize-footnotes.ts +6 -0
  8. package/src/io/export/serialize-headers-footers.ts +6 -0
  9. package/src/io/export/serialize-main-document.ts +7 -0
  10. package/src/io/export/serialize-paragraph-formatting.ts +1 -1
  11. package/src/io/normalize/normalize-text.ts +38 -2
  12. package/src/io/ooxml/parse-headers-footers.ts +31 -0
  13. package/src/io/ooxml/parse-main-document.ts +127 -2
  14. package/src/io/ooxml/parse-paragraph-formatting.ts +1 -1
  15. package/src/runtime/layout/layout-engine-version.ts +22 -1
  16. package/src/runtime/layout/paginated-layout-engine.ts +47 -0
  17. package/src/runtime/scopes/action-validation.ts +30 -4
  18. package/src/runtime/scopes/replacement/apply.ts +1 -0
  19. package/src/runtime/scopes/scope-kinds/paragraph.ts +170 -7
  20. package/src/runtime/scopes/semantic-scope-types.ts +19 -0
  21. package/src/runtime/surface-projection.ts +55 -0
  22. package/src/session/import/loader-types.ts +18 -0
  23. package/src/session/import/loader.ts +2 -0
  24. package/src/ui-tailwind/editor-surface/pm-schema.ts +32 -0
  25. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +6 -0
  26. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +77 -0
  27. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +12 -4
  28. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +49 -32
  29. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +5 -1
  30. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +5 -1
  31. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +71 -7
  32. package/src/ui-tailwind/theme/editor-theme.css +15 -16
  33. package/src/ui-tailwind/tw-review-workspace.tsx +21 -14
@@ -85,6 +85,54 @@ export function resolveMarkerAlignCss(raw: string | undefined): React.CSSPropert
85
85
  }
86
86
  }
87
87
 
88
+ /**
89
+ * Precompute per-tab-segment widths from the paragraph's tabStops.
90
+ *
91
+ * Returns a map `segmentId -> widthPt`. The N-th tab segment gets the
92
+ * width `(tabStops[N].pos - (tabStops[N-1]?.pos ?? 0)) / 20` points —
93
+ * the horizontal span from the previous stop (or the paragraph's
94
+ * content-left edge) to the current stop.
95
+ *
96
+ * This is a conservative approximation of Word's real tab algorithm
97
+ * (which advances to the next stop >= cumulative X considering prior
98
+ * content width). Without run-width measurement, the approximation
99
+ * holds best when the text in each zone is short relative to the zone
100
+ * width — which is exactly the three-zone-header / TOC case L11 needs
101
+ * to render correctly.
102
+ *
103
+ * Direct `block.tabStops` wins over `block.resolvedParagraphFormatting.tabStops`
104
+ * (consistent with other resolved fields).
105
+ */
106
+ export function computeTabWidthsInPoints(
107
+ block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
108
+ ): Map<string, number> {
109
+ const widths = new Map<string, number>();
110
+ // Direct `block.tabStops` uses the public `{pos, val?, leader?}` shape;
111
+ // `block.resolvedParagraphFormatting.tabStops` uses the canonical
112
+ // `{position, align, leader?}` shape. Normalize to a single accessor.
113
+ const readPos = (stop: { pos?: number; position?: number }): number =>
114
+ stop.pos ?? stop.position ?? 0;
115
+
116
+ const rawStops: Array<{ pos?: number; position?: number }> | undefined =
117
+ block.tabStops ?? block.resolvedParagraphFormatting?.tabStops;
118
+ if (!rawStops || rawStops.length === 0) return widths;
119
+
120
+ let tabIndex = 0;
121
+ for (const seg of block.segments) {
122
+ if (seg.kind !== "tab") continue;
123
+ const stop = rawStops[tabIndex];
124
+ if (stop) {
125
+ const prevPos = tabIndex > 0 ? readPos(rawStops[tabIndex - 1]) : 0;
126
+ const widthTwips = readPos(stop) - prevPos;
127
+ if (widthTwips > 0) {
128
+ widths.set(seg.segmentId, widthTwips / 20);
129
+ }
130
+ }
131
+ tabIndex += 1;
132
+ }
133
+ return widths;
134
+ }
135
+
88
136
  /** Build CSSProperties for a paragraph block from spacing/indent/alignment. */
89
137
  export function buildParagraphStyle(
90
138
  block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
@@ -203,6 +251,35 @@ export function buildParagraphStyle(
203
251
  style.marginTop = "16px";
204
252
  }
205
253
 
254
+ // `<w:framePr>` out-of-flow frame (ECMA-376 §17.3.1.11). L04 returns 0
255
+ // from measureBlockHeight for these paragraphs (a298391e) so the inline
256
+ // flow doesn't double-count; L11 renders them absolutely positioned.
257
+ // Drop-cap (dropCap="drop"|"margin") is in-flow — only the initial
258
+ // letter is framed — so skip the absolute switch there.
259
+ const framePr = block.frameProperties;
260
+ if (framePr && framePr.dropCap !== "drop" && framePr.dropCap !== "margin") {
261
+ style.position = "absolute";
262
+ if (typeof framePr.xTwips === "number") {
263
+ style.left = `${framePr.xTwips / 20}pt`;
264
+ }
265
+ if (typeof framePr.yTwips === "number") {
266
+ style.top = `${framePr.yTwips / 20}pt`;
267
+ }
268
+ if (typeof framePr.widthTwips === "number") {
269
+ style.width = `${framePr.widthTwips / 20}pt`;
270
+ }
271
+ if (typeof framePr.heightTwips === "number") {
272
+ if (framePr.hRule === "exact") {
273
+ style.height = `${framePr.heightTwips / 20}pt`;
274
+ } else if (framePr.hRule === "atLeast") {
275
+ style.minHeight = `${framePr.heightTwips / 20}pt`;
276
+ }
277
+ // hRule === "auto" (or missing) leaves the frame's vertical size
278
+ // content-driven. The height field is ignored — OOXML treats the
279
+ // value as a hint that layout engines may override.
280
+ }
281
+ }
282
+
206
283
  return style;
207
284
  }
208
285
 
@@ -8,6 +8,7 @@ import {
8
8
  buildMarkerStyle,
9
9
  buildParagraphStyle,
10
10
  buildSegmentStyle,
11
+ computeTabWidthsInPoints,
11
12
  hasStyleEntries,
12
13
  headingClassList,
13
14
  resolveHeadingLevel,
@@ -18,7 +19,7 @@ import {
18
19
  // ---------------------------------------------------------------------------
19
20
 
20
21
  /** Render a single inline segment. */
21
- function renderSegment(seg: SurfaceInlineSegment): React.ReactNode {
22
+ function renderSegment(seg: SurfaceInlineSegment, tabWidthsPt: Map<string, number>): React.ReactNode {
22
23
  switch (seg.kind) {
23
24
  case "text": {
24
25
  const style = buildSegmentStyle(seg.marks, seg.markAttrs);
@@ -32,16 +33,22 @@ function renderSegment(seg: SurfaceInlineSegment): React.ReactNode {
32
33
  </span>
33
34
  );
34
35
  }
35
- case "tab":
36
+ case "tab": {
37
+ const widthPt = tabWidthsPt.get(seg.segmentId);
38
+ const tabStyle: React.CSSProperties =
39
+ typeof widthPt === "number"
40
+ ? { display: "inline-block", width: `${widthPt}pt`, minWidth: "8px" }
41
+ : { display: "inline-block", width: "32px", minWidth: "8px" };
36
42
  return (
37
43
  <span
38
44
  key={seg.segmentId}
39
45
  data-node-type="tab"
40
- style={{ display: "inline-block", width: "32px", minWidth: "8px" }}
46
+ style={tabStyle}
41
47
  >
42
48
  {"\u00A0"}
43
49
  </span>
44
50
  );
51
+ }
45
52
  case "hard_break":
46
53
  return <br key={seg.segmentId} />;
47
54
  case "image":
@@ -114,6 +121,7 @@ function ParagraphBlock({
114
121
  }
115
122
 
116
123
  const pStyle = buildParagraphStyle(block);
124
+ const tabWidthsPt = computeTabWidthsInPoints(block);
117
125
  const attrs: React.HTMLAttributes<HTMLParagraphElement> & {
118
126
  "data-heading-level"?: string;
119
127
  "data-numbered"?: string;
@@ -177,7 +185,7 @@ function ParagraphBlock({
177
185
  <p {...attrs}>
178
186
  {prefixSpan}
179
187
  <span className="pm-paragraph-content">
180
- {block.segments.map((seg) => renderSegment(seg))}
188
+ {block.segments.map((seg) => renderSegment(seg, tabWidthsPt))}
181
189
  </span>
182
190
  </p>
183
191
  );
@@ -101,40 +101,57 @@ export function collectFloatingImageOverlayItems(input: {
101
101
  );
102
102
  const items: FloatingImageOverlayItem[] = [];
103
103
 
104
- walkSurfaceBlocks(surface.blocks, (segment) => {
105
- if (segment.kind !== "image" || !shouldRenderAbsoluteFloatingImageInPageOverlay(segment.anchor)) {
106
- return;
107
- }
108
-
109
- const pages = resolveTargetPages(facet, segment.from, activeStory);
110
- for (const page of pages) {
111
- const pageRect = rectByPageIndex.get(page.pageIndex);
112
- if (!pageRect) {
113
- continue;
104
+ const collectFromStory = (
105
+ blocks: readonly SurfaceBlockSnapshot[],
106
+ storyTarget: EditorStoryTarget,
107
+ ) => {
108
+ walkSurfaceBlocks(blocks, (segment) => {
109
+ if (
110
+ segment.kind !== "image" ||
111
+ !shouldRenderAbsoluteFloatingImageInPageOverlay(segment.anchor)
112
+ ) {
113
+ return;
114
114
  }
115
- const localRect = resolveFloatingImageLocalRect(page, activeStory, segment, pxPerTwip);
116
- if (!localRect) {
117
- continue;
115
+
116
+ const pages = resolveTargetPages(facet, segment.from, storyTarget);
117
+ for (const page of pages) {
118
+ const pageRect = rectByPageIndex.get(page.pageIndex);
119
+ if (!pageRect) {
120
+ continue;
121
+ }
122
+ const localRect = resolveFloatingImageLocalRect(page, storyTarget, segment, pxPerTwip);
123
+ if (!localRect) {
124
+ continue;
125
+ }
126
+ const preview = input.mediaPreviews?.[segment.mediaId];
127
+ items.push({
128
+ key: `${segment.segmentId}:${page.pageId}`,
129
+ mediaId: segment.mediaId,
130
+ from: segment.from,
131
+ to: segment.to,
132
+ pageId: page.pageId,
133
+ pageIndex: page.pageIndex,
134
+ topPx: pageRect.topPx + localRect.topPx,
135
+ leftPx: localRect.leftPx,
136
+ widthPx: localRect.widthPx,
137
+ heightPx: localRect.heightPx,
138
+ behindDoc: Boolean(segment.anchor?.behindDoc),
139
+ src: preview?.src ?? null,
140
+ altText: segment.altText ?? null,
141
+ detail: segment.detail ?? null,
142
+ });
118
143
  }
119
- const preview = input.mediaPreviews?.[segment.mediaId];
120
- items.push({
121
- key: `${segment.segmentId}:${page.pageId}`,
122
- mediaId: segment.mediaId,
123
- from: segment.from,
124
- to: segment.to,
125
- pageId: page.pageId,
126
- pageIndex: page.pageIndex,
127
- topPx: pageRect.topPx + localRect.topPx,
128
- leftPx: localRect.leftPx,
129
- widthPx: localRect.widthPx,
130
- heightPx: localRect.heightPx,
131
- behindDoc: Boolean(segment.anchor?.behindDoc),
132
- src: preview?.src ?? null,
133
- altText: segment.altText ?? null,
134
- detail: segment.detail ?? null,
135
- });
136
- }
137
- });
144
+ });
145
+ };
146
+
147
+ // coord-01 §9 / §5.1 — CCEP logos live in header stories; collect from
148
+ // the main story for the active-story case AND from every secondary
149
+ // story so header/footer images reach the overlay regardless of which
150
+ // story is active in the editor.
151
+ collectFromStory(surface.blocks, activeStory);
152
+ for (const secondary of surface.secondaryStories ?? []) {
153
+ collectFromStory(secondary.blocks, secondary.target);
154
+ }
138
155
 
139
156
  return items;
140
157
  }
@@ -80,7 +80,11 @@ export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = React.memo(({
80
80
  style={{ width: "100%", height: "100%" }}
81
81
  />
82
82
  ) : (
83
- <TwRegionBlockRenderer blocks={blocks} mediaPreviews={mediaPreviews} />
83
+ <TwRegionBlockRenderer
84
+ blocks={blocks}
85
+ mediaPreviews={mediaPreviews}
86
+ fallbackDisplay="hidden"
87
+ />
84
88
  )}
85
89
  </div>
86
90
  );
@@ -84,7 +84,11 @@ export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = React.memo(({
84
84
  style={{ width: "100%", height: "100%" }}
85
85
  />
86
86
  ) : (
87
- <TwRegionBlockRenderer blocks={blocks} mediaPreviews={mediaPreviews} />
87
+ <TwRegionBlockRenderer
88
+ blocks={blocks}
89
+ mediaPreviews={mediaPreviews}
90
+ fallbackDisplay="hidden"
91
+ />
88
92
  )}
89
93
  </div>
90
94
  );
@@ -9,6 +9,7 @@ import {
9
9
  buildMarkerStyle,
10
10
  buildParagraphStyle,
11
11
  buildSegmentStyle,
12
+ computeTabWidthsInPoints,
12
13
  hasStyleEntries,
13
14
  headingClassList,
14
15
  resolveHeadingLevel,
@@ -40,9 +41,26 @@ const EMU_PER_PX = 9525;
40
41
  // Inline segment renderer — mirrors `tw-page-block-view`'s `renderSegment`.
41
42
  // ---------------------------------------------------------------------------
42
43
 
44
+ /**
45
+ * Fallback visual for image segments that cannot resolve real bytes
46
+ * (no preview in `mediaPreviews`, or `seg.state === "missing"`).
47
+ *
48
+ * - `"chip"` (default) — 48×32 gray placeholder. Valuable dev-mode signal;
49
+ * correct for the body renderer.
50
+ * - `"hidden"` — zero-size, `aria-hidden` span. Correct inside header/
51
+ * footer bands, where the chip geometry visibly disrupts layout.
52
+ *
53
+ * The DOM node (with `data-node-type="image"` + `data-state`) is still
54
+ * emitted under `"hidden"` so diagnostics + tests can still see that an
55
+ * image segment was present.
56
+ */
57
+ export type RegionImageFallbackDisplay = "chip" | "hidden";
58
+
43
59
  function renderSegment(
44
60
  seg: SurfaceInlineSegment,
45
61
  mediaPreviews: Record<string, MediaPreviewDescriptor>,
62
+ fallbackDisplay: RegionImageFallbackDisplay,
63
+ tabWidthsPt: Map<string, number>,
46
64
  ): React.ReactNode {
47
65
  switch (seg.kind) {
48
66
  case "text": {
@@ -57,19 +75,32 @@ function renderSegment(
57
75
  </span>
58
76
  );
59
77
  }
60
- case "tab":
78
+ case "tab": {
79
+ const widthPt = tabWidthsPt.get(seg.segmentId);
80
+ const tabStyle: React.CSSProperties =
81
+ typeof widthPt === "number"
82
+ ? { display: "inline-block", width: `${widthPt}pt`, minWidth: "8px" }
83
+ : { display: "inline-block", width: "32px", minWidth: "8px" };
61
84
  return (
62
85
  <span
63
86
  key={seg.segmentId}
64
87
  data-node-type="tab"
65
- style={{ display: "inline-block", width: "32px", minWidth: "8px" }}
88
+ style={tabStyle}
66
89
  >
67
90
  {"\u00A0"}
68
91
  </span>
69
92
  );
93
+ }
70
94
  case "hard_break":
71
95
  return <br key={seg.segmentId} />;
72
96
  case "image": {
97
+ // §5.1 gap 3 — floating-anchor images are owned by the absolute
98
+ // floating-image overlay (`TwFloatingImageLayer`). Emitting them
99
+ // inline here would double-paint the CCEP header logo on every
100
+ // page. Skip entirely so only the overlay renders them.
101
+ if (seg.anchor?.display === "floating") {
102
+ return null;
103
+ }
73
104
  // Mirror body-renderer behavior (`pm-state-from-snapshot.ts` :500+):
74
105
  // look up the preview via `seg.mediaId` and render a real <img> when
75
106
  // available. Without a preview (or `state === "missing"`), fall back
@@ -103,6 +134,18 @@ function renderSegment(
103
134
  />
104
135
  );
105
136
  }
137
+ if (fallbackDisplay === "hidden") {
138
+ return (
139
+ <span
140
+ key={seg.segmentId}
141
+ data-node-type="image"
142
+ data-media-id={seg.mediaId}
143
+ data-state={seg.state}
144
+ aria-hidden="true"
145
+ style={{ display: "inline-block", width: 0, height: 0 }}
146
+ />
147
+ );
148
+ }
106
149
  return (
107
150
  <span
108
151
  key={seg.segmentId}
@@ -164,9 +207,11 @@ function renderSegment(
164
207
  function RegionParagraph({
165
208
  block,
166
209
  mediaPreviews,
210
+ fallbackDisplay,
167
211
  }: {
168
212
  block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>;
169
213
  mediaPreviews: Record<string, MediaPreviewDescriptor>;
214
+ fallbackDisplay: RegionImageFallbackDisplay;
170
215
  }): React.ReactElement {
171
216
  const headingLevel = resolveHeadingLevel(block.styleId, block.outlineLevel);
172
217
  const classes: string[] = ["leading-relaxed"];
@@ -175,6 +220,7 @@ function RegionParagraph({
175
220
  }
176
221
 
177
222
  const pStyle = buildParagraphStyle(block);
223
+ const tabWidthsPt = computeTabWidthsInPoints(block);
178
224
 
179
225
  // Numbering prefix span — matches tw-page-block-view so region content that
180
226
  // happens to carry numbering (e.g. footnote bodies authored as lists) shows
@@ -239,7 +285,7 @@ function RegionParagraph({
239
285
  <div {...attrs}>
240
286
  {prefixSpan}
241
287
  <span className="pm-paragraph-content">
242
- {block.segments.map((seg) => renderSegment(seg, mediaPreviews))}
288
+ {block.segments.map((seg) => renderSegment(seg, mediaPreviews, fallbackDisplay, tabWidthsPt))}
243
289
  </span>
244
290
  </div>
245
291
  );
@@ -248,9 +294,11 @@ function RegionParagraph({
248
294
  function RegionTable({
249
295
  block,
250
296
  mediaPreviews,
297
+ fallbackDisplay,
251
298
  }: {
252
299
  block: Extract<SurfaceBlockSnapshot, { kind: "table" }>;
253
300
  mediaPreviews: Record<string, MediaPreviewDescriptor>;
301
+ fallbackDisplay: RegionImageFallbackDisplay;
254
302
  }): React.ReactElement {
255
303
  const tableStyle: React.CSSProperties = {
256
304
  borderCollapse: "collapse",
@@ -323,6 +371,7 @@ function RegionTable({
323
371
  key={childBlock.blockId}
324
372
  block={childBlock}
325
373
  mediaPreviews={mediaPreviews}
374
+ fallbackDisplay={fallbackDisplay}
326
375
  />
327
376
  ))}
328
377
  </td>
@@ -362,15 +411,17 @@ function RegionOpaque({
362
411
  function RegionBlockItem({
363
412
  block,
364
413
  mediaPreviews,
414
+ fallbackDisplay,
365
415
  }: {
366
416
  block: SurfaceBlockSnapshot;
367
417
  mediaPreviews: Record<string, MediaPreviewDescriptor>;
418
+ fallbackDisplay: RegionImageFallbackDisplay;
368
419
  }): React.ReactElement | null {
369
420
  switch (block.kind) {
370
421
  case "paragraph":
371
- return <RegionParagraph block={block} mediaPreviews={mediaPreviews} />;
422
+ return <RegionParagraph block={block} mediaPreviews={mediaPreviews} fallbackDisplay={fallbackDisplay} />;
372
423
  case "table":
373
- return <RegionTable block={block} mediaPreviews={mediaPreviews} />;
424
+ return <RegionTable block={block} mediaPreviews={mediaPreviews} fallbackDisplay={fallbackDisplay} />;
374
425
  case "sdt_block":
375
426
  return (
376
427
  <section
@@ -380,7 +431,7 @@ function RegionBlockItem({
380
431
  style={{ margin: "8px 0" }}
381
432
  >
382
433
  {block.children.map((child) => (
383
- <RegionBlockItem key={child.blockId} block={child} mediaPreviews={mediaPreviews} />
434
+ <RegionBlockItem key={child.blockId} block={child} mediaPreviews={mediaPreviews} fallbackDisplay={fallbackDisplay} />
384
435
  ))}
385
436
  </section>
386
437
  );
@@ -409,6 +460,13 @@ export interface TwRegionBlockRendererProps {
409
460
  * real image bytes.
410
461
  */
411
462
  mediaPreviews?: Record<string, MediaPreviewDescriptor>;
463
+ /**
464
+ * Behavior for image segments that can't resolve real bytes.
465
+ * Defaults to `"chip"` for back-compat with body-renderer parity;
466
+ * pass `"hidden"` inside header/footer bands where the 48×32 chip
467
+ * disrupts band geometry (§5.5 tuning-phase handover).
468
+ */
469
+ fallbackDisplay?: RegionImageFallbackDisplay;
412
470
  }
413
471
 
414
472
  const EMPTY_MEDIA_PREVIEWS: Record<string, MediaPreviewDescriptor> = {};
@@ -427,6 +485,7 @@ export function TwRegionBlockRenderer({
427
485
  blocks,
428
486
  className,
429
487
  mediaPreviews,
488
+ fallbackDisplay = "chip",
430
489
  }: TwRegionBlockRendererProps): React.ReactElement {
431
490
  const rootClasses = ["ProseMirror"];
432
491
  if (className) rootClasses.push(className);
@@ -438,7 +497,12 @@ export function TwRegionBlockRenderer({
438
497
  data-region-block-renderer=""
439
498
  >
440
499
  {blocks.map((block) => (
441
- <RegionBlockItem key={block.blockId} block={block} mediaPreviews={previews} />
500
+ <RegionBlockItem
501
+ key={block.blockId}
502
+ block={block}
503
+ mediaPreviews={previews}
504
+ fallbackDisplay={fallbackDisplay}
505
+ />
442
506
  ))}
443
507
  </div>
444
508
  );
@@ -1058,30 +1058,29 @@
1058
1058
  * `data-story-active="header|footer"` and the body PM surface dims to
1059
1059
  * 0.65 so the active band reads as the focal surface.
1060
1060
  */
1061
+ /*
1062
+ * Designsystem §6.20 — inactive = low-contrast tint (color.bg.muted),
1063
+ * active = tint color.accent.soft + border color.border.accent.
1064
+ * The 3px top accent stripe was the prior signal; §6.20 rejects it in
1065
+ * favor of a border/tint-led active state that reads as "a focal frame"
1066
+ * rather than "a colored line draped across the top".
1067
+ */
1061
1068
  .wre-page-band {
1062
- opacity: 0.6;
1063
- transition: opacity var(--motion-fast, 120ms) ease-out;
1069
+ background-color: var(--color-bg-muted);
1070
+ border: 1px solid transparent;
1071
+ transition:
1072
+ background-color var(--motion-fast, 120ms) ease-out,
1073
+ border-color var(--motion-fast, 120ms) ease-out;
1064
1074
  pointer-events: auto;
1065
1075
  }
1066
1076
 
1067
1077
  .wre-page-band:hover {
1068
- opacity: 0.85;
1078
+ background-color: color-mix(in srgb, var(--color-bg-muted) 80%, var(--color-surface));
1069
1079
  }
1070
1080
 
1071
1081
  .wre-page-band[data-active="true"] {
1072
- opacity: 1;
1073
- }
1074
-
1075
- .wre-page-band[data-active="true"]::before {
1076
- content: "";
1077
- position: absolute;
1078
- top: 0;
1079
- left: 0;
1080
- right: 0;
1081
- height: 3px;
1082
- background: var(--color-accent);
1083
- border-radius: 0 0 var(--radius-pill) var(--radius-pill);
1084
- pointer-events: none;
1082
+ background-color: var(--color-accent-soft);
1083
+ border: 1px solid var(--color-border-accent);
1085
1084
  }
1086
1085
 
1087
1086
  .wre-page-band__label {
@@ -351,21 +351,28 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
351
351
  const defaultShellActiveMode: ShellHeaderMode = editorRoleToShellMode(
352
352
  viewState.editorRole,
353
353
  );
354
+ // coord-11 §21 — host-supplied shellHeader wins; otherwise the default
355
+ // TwShellHeader mounts only when `chromeVisibility.shellHeader === true`
356
+ // (default for every preset except `selection`). The `selection` preset
357
+ // is intended for minimal embeds (incl. visual-fidelity `chrome=none`)
358
+ // and must not paint a workspace mode-tab header.
354
359
  const renderedShell =
355
- props.shellHeader !== undefined ? (
356
- props.shellHeader
357
- ) : (
358
- <TwShellHeader
359
- modes={defaultShellModes}
360
- activeMode={defaultShellActiveMode}
361
- onModeChange={(mode) => {
362
- const nextRole = shellModeToEditorRole(mode);
363
- if (nextRole !== null && nextRole !== viewState.editorRole) {
364
- props.onEditorRoleChange?.(nextRole);
365
- }
366
- }}
367
- />
368
- );
360
+ props.shellHeader !== undefined
361
+ ? props.shellHeader
362
+ : chromeVisibility.shellHeader
363
+ ? (
364
+ <TwShellHeader
365
+ modes={defaultShellModes}
366
+ activeMode={defaultShellActiveMode}
367
+ onModeChange={(mode) => {
368
+ const nextRole = shellModeToEditorRole(mode);
369
+ if (nextRole !== null && nextRole !== viewState.editorRole) {
370
+ props.onEditorRoleChange?.(nextRole);
371
+ }
372
+ }}
373
+ />
374
+ )
375
+ : null;
369
376
 
370
377
  // Audit §2.5 — context band mounts as a composition-level sibling of
371
378
  // the toolbar so the workspace row becomes