@beyondwork/docx-react-component 1.0.95 → 1.0.97

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 (43) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +33 -19
  3. package/src/api/v3/ui/_types.ts +11 -21
  4. package/src/api/v3/ui/chrome.ts +8 -9
  5. package/src/api/v3/ui/debug.ts +15 -77
  6. package/src/api/v3/ui/overlays-visibility.ts +9 -10
  7. package/src/api/v3/ui/overlays.ts +8 -75
  8. package/src/io/ooxml/parse-main-document.ts +30 -0
  9. package/src/io/ooxml/parse-picture.ts +14 -0
  10. package/src/io/ooxml/parse-shapes.ts +41 -1
  11. package/src/model/canonical-document.ts +17 -0
  12. package/src/runtime/document-runtime.ts +46 -1
  13. package/src/runtime/layout/layout-engine-version.ts +8 -1
  14. package/src/runtime/layout/page-story-resolver.ts +1 -0
  15. package/src/runtime/layout/paginated-layout-engine.ts +26 -10
  16. package/src/runtime/surface-projection.ts +114 -12
  17. package/src/runtime/workflow/rail/compose.ts +5 -0
  18. package/src/ui/WordReviewEditor.tsx +6 -10
  19. package/src/ui/editor-command-bag.ts +2 -0
  20. package/src/ui/ui-controller-factory.ts +2 -2
  21. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +12 -41
  22. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +3 -7
  23. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +1 -1
  24. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +22 -228
  25. package/src/ui-tailwind/debug/README.md +12 -50
  26. package/src/ui-tailwind/debug/tw-debug-overlay.tsx +6 -6
  27. package/src/ui-tailwind/debug/tw-debug-presentation.tsx +9 -20
  28. package/src/ui-tailwind/debug/tw-debug-top-bar.tsx +5 -6
  29. package/src/ui-tailwind/editor-surface/chart-node-view.tsx +1 -4
  30. package/src/ui-tailwind/editor-surface/picture-effects.ts +96 -0
  31. package/src/ui-tailwind/editor-surface/pm-schema.ts +89 -62
  32. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +205 -0
  33. package/src/ui-tailwind/editor-surface/runtime-decoration-plugin.ts +190 -0
  34. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +53 -53
  35. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +83 -20
  36. package/src/ui-tailwind/page-stack/tw-floating-image-layer.tsx +114 -4
  37. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +5 -0
  38. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +3 -0
  39. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +3 -0
  40. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +8 -0
  41. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +26 -0
  42. package/src/ui-tailwind/theme/editor-theme.css +82 -84
  43. package/src/ui-tailwind/tw-review-workspace.tsx +15 -0
@@ -18,8 +18,15 @@ import {
18
18
  } from "../chrome-overlay/tw-page-stack-overlay-layer.tsx";
19
19
  import {
20
20
  collectFloatingImageOverlayItems,
21
+ type FloatingImageOverlayItem,
21
22
  type FloatingImagePreviewDescriptor,
22
23
  } from "./floating-image-overlay-model.ts";
24
+ import { buildSegmentStyle } from "../editor-surface/tw-page-block-view.helpers.ts";
25
+ import { buildPictureFilterCss } from "../editor-surface/picture-effects.ts";
26
+
27
+ const EMU_PER_PX = 9525;
28
+ type FloatingShapeBody = NonNullable<FloatingImageOverlayItem["shape"]>["body"];
29
+ type FloatingShapeBodyAnchor = NonNullable<FloatingShapeBody>["anchor"];
23
30
 
24
31
  export interface TwFloatingImageLayerProps {
25
32
  facet: WordReviewEditorLayoutFacet;
@@ -221,9 +228,9 @@ export const TwFloatingImageLayer: React.FC<TwFloatingImageLayerProps> = ({
221
228
  data-floating-image-count={items.length}
222
229
  >
223
230
  {items.map((item) => {
224
- const interactive = plane === "front" && typeof onActivateFloatingImage === "function";
231
+ const interactive =
232
+ !item.shape && plane === "front" && typeof onActivateFloatingImage === "function";
225
233
  const commonProps = {
226
- key: item.key,
227
234
  className: interactive
228
235
  ? "pointer-events-auto absolute m-0 border-0 bg-transparent p-0"
229
236
  : "pointer-events-none absolute m-0 border-0 bg-transparent p-0",
@@ -234,9 +241,21 @@ export const TwFloatingImageLayer: React.FC<TwFloatingImageLayerProps> = ({
234
241
  left: `${item.leftPx}px`,
235
242
  width: `${item.widthPx}px`,
236
243
  height: `${item.heightPx}px`,
244
+ ...(item.zIndex !== undefined ? { zIndex: item.zIndex } : {}),
237
245
  },
238
246
  } as const;
239
- const content = item.src ? (
247
+ const imageFilter = buildPictureFilterCss(item.pictureEffects);
248
+ const content = item.shape ? (
249
+ <div
250
+ className="flex h-full w-full overflow-hidden"
251
+ title={item.detail ?? item.shape.label}
252
+ style={buildFloatingShapeBoxStyle(item)}
253
+ >
254
+ <span style={buildFloatingShapeTextStyle(item)}>
255
+ {item.shape.text}
256
+ </span>
257
+ </div>
258
+ ) : item.src ? (
240
259
  <img
241
260
  src={item.src}
242
261
  alt={interactive ? item.altText ?? "" : ""}
@@ -247,6 +266,8 @@ export const TwFloatingImageLayer: React.FC<TwFloatingImageLayerProps> = ({
247
266
  width: "100%",
248
267
  height: "100%",
249
268
  objectFit: "fill",
269
+ ...(imageFilter ? { filter: imageFilter } : {}),
270
+ ...(item.pictureEffects?.srcRect ? { clipPath: srcRectToInset(item.pictureEffects.srcRect) } : {}),
250
271
  }}
251
272
  />
252
273
  ) : (
@@ -259,13 +280,14 @@ export const TwFloatingImageLayer: React.FC<TwFloatingImageLayerProps> = ({
259
280
  );
260
281
  if (!interactive) {
261
282
  return (
262
- <div {...commonProps} aria-hidden="true">
283
+ <div key={item.key} {...commonProps} aria-hidden="true">
263
284
  {content}
264
285
  </div>
265
286
  );
266
287
  }
267
288
  return (
268
289
  <button
290
+ key={item.key}
269
291
  {...commonProps}
270
292
  type="button"
271
293
  tabIndex={-1}
@@ -287,4 +309,92 @@ export const TwFloatingImageLayer: React.FC<TwFloatingImageLayerProps> = ({
287
309
  );
288
310
  };
289
311
 
312
+ function buildFloatingShapeBoxStyle(
313
+ item: FloatingImageOverlayItem,
314
+ ): React.CSSProperties {
315
+ const fill = item.shape?.fill;
316
+ const line = item.shape?.line;
317
+ const style: React.CSSProperties = {
318
+ boxSizing: "border-box",
319
+ alignItems: bodyAnchorToAlignItems(item.shape?.body?.anchor),
320
+ justifyContent: inferTextBoxJustification(item.shape?.text),
321
+ padding: shapeBodyPadding(item.shape?.body),
322
+ textAlign: inferTextBoxTextAlign(item.shape?.text),
323
+ backgroundColor: "transparent",
324
+ border: "none",
325
+ };
326
+ if (fill?.kind === "solid" && fill.colorType === "srgbClr" && isSafeHexColor(fill.color)) {
327
+ style.backgroundColor = `#${fill.color}`;
328
+ }
329
+ if (line && !line.noLine && line.color && isSafeHexColor(line.color)) {
330
+ style.border = `${Math.max(1, Math.round((line.widthEmu ?? 9525) / EMU_PER_PX))}px solid #${line.color}`;
331
+ }
332
+ return style;
333
+ }
334
+
335
+ function bodyAnchorToAlignItems(
336
+ anchor: FloatingShapeBodyAnchor,
337
+ ): React.CSSProperties["alignItems"] {
338
+ switch (anchor) {
339
+ case "t":
340
+ return "flex-start";
341
+ case "b":
342
+ return "flex-end";
343
+ case "ctr":
344
+ default:
345
+ return "center";
346
+ }
347
+ }
348
+
349
+ function shapeBodyPadding(
350
+ body: FloatingShapeBody,
351
+ ): string {
352
+ const top = emuInsetToPx(body?.insetTopEmu, 0);
353
+ const right = emuInsetToPx(body?.insetRightEmu, 4);
354
+ const bottom = emuInsetToPx(body?.insetBottomEmu, 0);
355
+ const left = emuInsetToPx(body?.insetLeftEmu, 4);
356
+ return `${top}px ${right}px ${bottom}px ${left}px`;
357
+ }
358
+
359
+ function emuInsetToPx(value: number | undefined, fallbackPx: number): number {
360
+ return value === undefined ? fallbackPx : Math.max(0, Math.round(value / EMU_PER_PX));
361
+ }
362
+
363
+ function buildFloatingShapeTextStyle(
364
+ item: FloatingImageOverlayItem,
365
+ ): React.CSSProperties {
366
+ const style = buildSegmentStyle(item.shape?.marks, item.shape?.markAttrs);
367
+ if (item.shape?.markAttrs?.fontSize) {
368
+ style.fontSize = `${item.shape.markAttrs.fontSize / 2}pt`;
369
+ }
370
+ const rawColor = item.shape?.markAttrs?.textColor;
371
+ if (rawColor && isSafeHexColor(rawColor)) {
372
+ style.color = `#${rawColor}`;
373
+ }
374
+ if (!style.fontSize) {
375
+ style.fontSize = `${Math.max(12, Math.min(56, Math.round(item.heightPx * 0.42)))}px`;
376
+ }
377
+ if (!style.lineHeight) {
378
+ style.lineHeight = 1.05;
379
+ }
380
+ style.whiteSpace = "pre-wrap";
381
+ return style;
382
+ }
383
+
384
+ function srcRectToInset(srcRect: { top: number; right: number; bottom: number; left: number }): string {
385
+ return `inset(${(srcRect.top / 1000).toFixed(3)}% ${(srcRect.right / 1000).toFixed(3)}% ${(srcRect.bottom / 1000).toFixed(3)}% ${(srcRect.left / 1000).toFixed(3)}%)`;
386
+ }
387
+
388
+ function inferTextBoxJustification(text: string | undefined): React.CSSProperties["justifyContent"] {
389
+ return text?.startsWith("[") ? "flex-end" : "center";
390
+ }
391
+
392
+ function inferTextBoxTextAlign(text: string | undefined): React.CSSProperties["textAlign"] {
393
+ return text?.startsWith("[") ? "right" : "center";
394
+ }
395
+
396
+ function isSafeHexColor(value: string): boolean {
397
+ return /^[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/.test(value);
398
+ }
399
+
290
400
  export default TwFloatingImageLayer;
@@ -38,6 +38,7 @@ export interface TwPageChromeEntryProps {
38
38
  activeStoryPageIndex?: number | null;
39
39
  onOpenStory?: (target: EditorStoryTarget, pageIndex: number) => void;
40
40
  visiblePageIndexRange?: { start: number; end: number } | null;
41
+ plainPageBands?: boolean;
41
42
  renderFrameRevision: number;
42
43
  /** Preview catalog threaded into header/footer/footnote region renderers
43
44
  * so images (CCEP logos on 7-of-8 CCEP docs) render as real <img>s. */
@@ -62,6 +63,7 @@ function TwPageChromeEntryInner({
62
63
  activeStoryPageIndex,
63
64
  onOpenStory,
64
65
  visiblePageIndexRange,
66
+ plainPageBands = false,
65
67
  renderFrameRevision,
66
68
  mediaPreviews,
67
69
  activeBandRibbonProps,
@@ -208,6 +210,7 @@ function TwPageChromeEntryInner({
208
210
  widthPx={bandWidthPx}
209
211
  bandHeightPx={px(headerRegion.heightTwips)}
210
212
  isActiveSlot={Boolean(headerActive)}
213
+ chromeMode={plainPageBands ? "plain" : "normal"}
211
214
  sectionLabel={headerActive ? headerSectionLabel : undefined}
212
215
  onDoubleClick={handleHeaderDoubleClick}
213
216
  mediaPreviews={mediaPreviews}
@@ -223,6 +226,7 @@ function TwPageChromeEntryInner({
223
226
  widthPx={bandWidthPx}
224
227
  bandHeightPx={px(footerRegion.heightTwips)}
225
228
  isActiveSlot={Boolean(footerActive)}
229
+ chromeMode={plainPageBands ? "plain" : "normal"}
226
230
  sectionLabel={footerActive ? footerSectionLabel : undefined}
227
231
  onDoubleClick={handleFooterDoubleClick}
228
232
  mediaPreviews={mediaPreviews}
@@ -267,6 +271,7 @@ function propsAreEqual(
267
271
  prev.activeStoryPageIndex === next.activeStoryPageIndex &&
268
272
  prev.onOpenStory === next.onOpenStory &&
269
273
  prev.visiblePageIndexRange === next.visiblePageIndexRange &&
274
+ prev.plainPageBands === next.plainPageBands &&
270
275
  prev.renderFrameRevision === next.renderFrameRevision &&
271
276
  prev.rect.topPx === next.rect.topPx &&
272
277
  prev.rect.bottomPx === next.rect.bottomPx &&
@@ -32,6 +32,7 @@ export interface TwPageFooterBandProps {
32
32
  widthPx: number;
33
33
  /** True when this band is the active-story slot. Renders portal target instead of read-only DOM. */
34
34
  isActiveSlot: boolean;
35
+ chromeMode?: "normal" | "plain";
35
36
  /**
36
37
  * Lane 6d.U1 — section label for the active-band ribbon (e.g. "Footer — Section 1").
37
38
  * Only rendered when `isActiveSlot` is true.
@@ -56,6 +57,7 @@ export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = React.memo(({
56
57
  leftPx,
57
58
  widthPx,
58
59
  isActiveSlot,
60
+ chromeMode = "normal",
59
61
  sectionLabel,
60
62
  onDoubleClick,
61
63
  "data-testid": testId,
@@ -66,6 +68,7 @@ export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = React.memo(({
66
68
  <div
67
69
  className="wre-page-band"
68
70
  data-page-band="footer"
71
+ data-page-band-chrome={chromeMode === "plain" ? "plain" : undefined}
69
72
  data-page-index={pageIndex}
70
73
  data-active={isActiveSlot ? "true" : undefined}
71
74
  data-testid={testId}
@@ -33,6 +33,7 @@ export interface TwPageHeaderBandProps {
33
33
  widthPx: number;
34
34
  /** True when this band is the active-story slot. Renders portal target instead of read-only DOM. */
35
35
  isActiveSlot: boolean;
36
+ chromeMode?: "normal" | "plain";
36
37
  /**
37
38
  * Lane 6d.U1 — section label for the active-band ribbon (e.g. "Header — Section 1").
38
39
  * Only rendered when `isActiveSlot` is true.
@@ -62,6 +63,7 @@ export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = React.memo(({
62
63
  leftPx,
63
64
  widthPx,
64
65
  isActiveSlot,
66
+ chromeMode = "normal",
65
67
  sectionLabel,
66
68
  onDoubleClick,
67
69
  "data-testid": testId,
@@ -72,6 +74,7 @@ export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = React.memo(({
72
74
  <div
73
75
  className="wre-page-band"
74
76
  data-page-band="header"
77
+ data-page-band-chrome={chromeMode === "plain" ? "plain" : undefined}
75
78
  data-page-index={pageIndex}
76
79
  data-active={isActiveSlot ? "true" : undefined}
77
80
  data-testid={testId}
@@ -148,6 +148,11 @@ export interface TwPageStackChromeLayerProps {
148
148
  * reported.
149
149
  */
150
150
  visiblePageIndexRange?: { start: number; end: number } | null;
151
+ /**
152
+ * Chrome-less visual fidelity mode: keep per-page story rendering active
153
+ * while suppressing editor-only inactive band tint/border.
154
+ */
155
+ plainPageBands?: boolean;
151
156
  /** Optional test id applied to the layer root. */
152
157
  "data-testid"?: string;
153
158
  /** Preview catalog threaded through to per-page chrome bands so images
@@ -173,6 +178,7 @@ const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
173
178
  pmSurfaceElement,
174
179
  pmView,
175
180
  visiblePageIndexRange,
181
+ plainPageBands = false,
176
182
  "data-testid": testId,
177
183
  mediaPreviews,
178
184
  activeBandRibbonProps,
@@ -461,6 +467,7 @@ const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
461
467
  activeStoryPageIndex={activeStoryPageIndex}
462
468
  onOpenStory={handleOpenStoryForPage}
463
469
  visiblePageIndexRange={visiblePageIndexRange}
470
+ plainPageBands={plainPageBands}
464
471
  renderFrameRevision={renderFrameRevision}
465
472
  mediaPreviews={mediaPreviews}
466
473
  activeBandRibbonProps={activeBandRibbonProps}
@@ -516,6 +523,7 @@ function propsAreEqual(
516
523
  prev.pmSurfaceElement === next.pmSurfaceElement &&
517
524
  prev.pmView === next.pmView &&
518
525
  rangeEqual(prev.visiblePageIndexRange, next.visiblePageIndexRange) &&
526
+ prev.plainPageBands === next.plainPageBands &&
519
527
  prev["data-testid"] === next["data-testid"]
520
528
  );
521
529
  }
@@ -167,6 +167,28 @@ function renderSegment(
167
167
  />
168
168
  );
169
169
  }
170
+ case "shape": {
171
+ if (shouldRenderAbsoluteFloatingImageInPageOverlay(seg.anchor)) {
172
+ return null;
173
+ }
174
+ if (!seg.isTextBox || !seg.txbxText) {
175
+ return null;
176
+ }
177
+ const style = buildSegmentStyle(seg.txbxMarks, seg.txbxMarkAttrs);
178
+ if (seg.txbxMarkAttrs?.textColor && isSafeHexColor(seg.txbxMarkAttrs.textColor)) {
179
+ style.color = `#${seg.txbxMarkAttrs.textColor}`;
180
+ }
181
+ return (
182
+ <span
183
+ key={seg.segmentId}
184
+ data-node-type="shape"
185
+ style={style}
186
+ title={seg.detail}
187
+ >
188
+ {seg.txbxText ?? seg.label}
189
+ </span>
190
+ );
191
+ }
170
192
  case "field_ref":
171
193
  return (
172
194
  <span
@@ -202,6 +224,10 @@ function renderSegment(
202
224
  }
203
225
  }
204
226
 
227
+ function isSafeHexColor(value: string): boolean {
228
+ return /^[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/.test(value);
229
+ }
230
+
205
231
  // ---------------------------------------------------------------------------
206
232
  // Block renderers
207
233
  // ---------------------------------------------------------------------------
@@ -379,17 +379,31 @@
379
379
  /*
380
380
  * ─── Workflow inline text emphasis ───
381
381
  *
382
- * Since R3a the workflow scope rail + flat block tint are painted on the
383
- * ChromeOverlay plane (see src/ui-tailwind/chrome-overlay/). PM
384
- * decorations retain ONLY inline text emphasis for postures that carry
385
- * unique per-text signals (candidate = dashed underline, blocked-import =
386
- * wavy underline, active = thin outline). The rounded in-text background
387
- * boxes that previously wrapped every run are gone — the overlay's flat
388
- * tint handles that signal.
382
+ * Scopes should read as text ownership, not block selection. PM inline
383
+ * decorations carry the visible border on the scoped text, while the
384
+ * ChromeOverlay plane supplies the gutter/action rail. Keep this
385
+ * border-only: no filled boxes over document content.
389
386
  */
390
387
  .prosemirror-surface .ProseMirror .wre-workflow-inline {
391
388
  -webkit-box-decoration-break: clone;
392
389
  box-decoration-break: clone;
390
+ border-radius: 2px;
391
+ }
392
+
393
+ .prosemirror-surface .ProseMirror .wre-workflow-inline-edit {
394
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-accent) 52%, transparent);
395
+ }
396
+
397
+ .prosemirror-surface .ProseMirror .wre-workflow-inline-suggest {
398
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-warning) 56%, transparent);
399
+ }
400
+
401
+ .prosemirror-surface .ProseMirror .wre-workflow-inline-comment {
402
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-insert) 48%, transparent);
403
+ }
404
+
405
+ .prosemirror-surface .ProseMirror .wre-workflow-inline-view {
406
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-secondary) 46%, transparent);
393
407
  }
394
408
 
395
409
  .prosemirror-surface .ProseMirror .wre-workflow-inline-candidate {
@@ -408,43 +422,43 @@
408
422
 
409
423
  /*
410
424
  * Locked zone marker for inline runs: a subtle dotted right edge so the
411
- * reader can tell where the locked range ends when the gutter label scrolls
412
- * out of view. The overlay's flat tint carries the primary signal.
425
+ * reader can tell where the locked range ends when the gutter handle scrolls
426
+ * out of view. The rail carries the action affordance.
413
427
  */
414
428
  .prosemirror-surface .ProseMirror .wre-workflow-inline-locked-zone {
415
429
  box-shadow: inset -1px 0 0 color-mix(in srgb, var(--color-danger) 35%, transparent);
416
430
  }
417
431
 
418
432
  /*
419
- * `wre-workflow-inline-active` no longer emits a visual outline. The
420
- * per-run inset box-shadow produced a halo around every text fragment
421
- * (one box per run, due to box-decoration-break: clone above), which
422
- * fought with the overlay's flat tint. The class name is kept on the
423
- * inline decoration as a data hook (no visual), and emphasis for the
424
- * active scope now lives on the ChromeOverlay rail stripe + scope card.
433
+ * Active scope emphasis is a stronger text border plus the gutter handle.
434
+ * This keeps focus local to scoped text without reintroducing filled green
435
+ * rectangles.
425
436
  */
426
437
  .prosemirror-surface .ProseMirror .wre-workflow-inline-active {
427
- /* intentionally empty — visual emphasis handled by ChromeOverlay */
438
+ box-shadow:
439
+ 0 0 0 1px color-mix(in srgb, var(--color-accent) 72%, transparent),
440
+ 0 0 0 3px color-mix(in srgb, var(--color-accent) 12%, transparent);
428
441
  }
429
442
 
430
443
  /*
431
444
  * ─── ChromeOverlay: scope rail layer ───
432
445
  *
433
- * The overlay sits above PM and paints the flat block-tint + gutter labels
434
- * that used to be inline PM decorations. Positions come from the render
435
- * kernel's anchor index, not DOM rects.
446
+ * Product scope affordance now lives directly on the scoped inline text.
447
+ * Keep the legacy rail layer hidden if an old host mounts it directly:
448
+ * no gutter markers, no rail handles, no overlay tints.
436
449
  */
437
450
  .wre-scope-rail-layer {
451
+ display: none;
438
452
  pointer-events: none;
439
453
  }
440
454
 
441
455
  /*
442
456
  * ─── Gutter lane tokens ───
443
457
  *
444
- * The scope rail and scope card chrome live in a reserved lane to the
445
- * left of the page frame so they visibly read as chrome (not document
446
- * content). Page surfaces use 64px, canvas surfaces 48px. Host apps
447
- * can override via CSS custom property.
458
+ * Kept for legacy gutter consumers and page-card composition. Product
459
+ * scope rails are hidden; scoped text is the visible scope affordance.
460
+ * Page surfaces use 64px, canvas surfaces 48px. Host apps can override
461
+ * via CSS custom property.
448
462
  */
449
463
  :root {
450
464
  --wre-gutter-lane-width: 64px;
@@ -471,58 +485,36 @@
471
485
  border-radius: 0.2rem;
472
486
  pointer-events: none;
473
487
  z-index: 0;
474
- transition: background 140ms ease-out;
475
- }
476
-
477
- .wre-scope-rail-tint-accent {
478
- background: color-mix(in srgb, var(--color-accent) 12%, transparent);
479
- }
480
- .wre-scope-rail-tint-warning {
481
- background: color-mix(in srgb, var(--color-warning) 14%, transparent);
482
- }
483
- .wre-scope-rail-tint-insert {
484
- background: color-mix(in srgb, var(--color-insert) 12%, transparent);
485
- }
486
- .wre-scope-rail-tint-secondary {
487
- background: color-mix(in srgb, var(--color-secondary) 9%, transparent);
488
- }
489
- .wre-scope-rail-tint-danger {
490
- background: color-mix(in srgb, var(--color-danger) 14%, transparent);
491
- }
492
-
493
- /* §3.7 canonical scope families */
494
- .wre-scope-rail-tint-blocked {
495
- background: var(--color-scope-tint-blocked);
496
- }
497
- .wre-scope-rail-tint-in-scope {
498
- background: var(--color-scope-tint-in-scope);
499
- }
500
- .wre-scope-rail-tint-suggest {
501
- background: var(--color-scope-tint-suggest);
502
- }
503
- .wre-scope-rail-tint-comment {
504
- background: var(--color-scope-tint-comment);
505
- }
506
- .wre-scope-rail-tint-scheduled {
507
- background: var(--color-scope-tint-scheduled);
508
- }
488
+ background: transparent;
489
+ transition: box-shadow 140ms ease-out;
490
+ }
491
+
492
+ .wre-scope-rail-tint-accent,
493
+ .wre-scope-rail-tint-warning,
494
+ .wre-scope-rail-tint-insert,
495
+ .wre-scope-rail-tint-secondary,
496
+ .wre-scope-rail-tint-danger,
497
+ .wre-scope-rail-tint-blocked,
498
+ .wre-scope-rail-tint-in-scope,
499
+ .wre-scope-rail-tint-suggest,
500
+ .wre-scope-rail-tint-comment,
501
+ .wre-scope-rail-tint-scheduled,
509
502
  .wre-scope-rail-tint-proposed {
510
- background: var(--color-scope-tint-proposed);
503
+ background: transparent;
511
504
  }
512
505
 
513
506
  .wre-scope-rail-tint-active {
514
- outline: 1px solid color-mix(in srgb, var(--color-accent) 40%, transparent);
507
+ outline: 1px solid color-mix(in srgb, var(--color-accent) 52%, transparent);
515
508
  outline-offset: -1px;
516
509
  }
517
510
 
518
511
  /*
519
512
  * ─── Agent-pending shimmer (K2 / scope-card-overlay P2) ───
520
513
  *
521
- * Painted on every scope tint that overlaps a WorkflowCandidateRange
522
- * with `source: "ai"`. A soft 1.8s pulse signals the agent is
523
- * thinking without competing with the active outline. Reduced-
524
- * motion disables the animation and holds a static 60% opacity
525
- * border so the posture is still readable.
514
+ * Painted on every scope outline that overlaps a WorkflowCandidateRange
515
+ * with `source: "ai"`. A soft 1.8s pulse signals the agent is thinking
516
+ * without competing with the active outline. Reduced-motion disables the
517
+ * animation and holds a static 60% opacity outline.
526
518
  */
527
519
  @keyframes wre-agent-pulse {
528
520
  0%, 100% { opacity: 0.4; }
@@ -541,12 +533,11 @@
541
533
  }
542
534
 
543
535
  /*
544
- * ─── Scope rail stripe ───
536
+ * ─── Legacy scope rail stripe ───
545
537
  *
546
- * The rail stripe is the rest-state representation of a scope: a 4px
547
- * color stripe in the gutter lane. Posture color comes from the
548
- * accent/warning/insert/secondary/danger tokens. Hover widens the
549
- * stripe via transform (zero layout cost) and reveals the label pill.
538
+ * Hidden by .wre-scope-rail-layer. These rules are retained only so old
539
+ * direct mounts degrade consistently if they have not yet removed the
540
+ * rail DOM.
550
541
  */
551
542
  .wre-scope-rail-stripe {
552
543
  position: absolute;
@@ -595,34 +586,31 @@
595
586
  .wre-scope-rail-stripe.wre-scope-rail-tint-proposed { background: var(--color-scope-tint-proposed); }
596
587
 
597
588
  /*
598
- * ─── Scope rail label pill ───
589
+ * ─── Scope rail edit handle ───
599
590
  *
600
- * Shown only on stripe hover (CSS-driven). The pill overlays the
601
- * stripe with icon + short posture label, anchored to the first line
602
- * of the scope.
591
+ * Shown only on stripe hover (CSS-driven). The handle overlays the
592
+ * stripe with a compact icon anchored to the first line of the scope.
603
593
  */
604
594
  .wre-scope-rail-label {
605
595
  position: absolute;
606
596
  display: flex;
607
597
  align-items: center;
608
598
  justify-content: center;
609
- gap: 0.2rem;
610
- padding: 0.15rem 0.3rem;
611
- border-radius: var(--radius-sm);
599
+ width: 24px;
600
+ height: 24px;
601
+ padding: 0;
602
+ border-radius: 999px;
612
603
  border: 1px solid transparent;
613
604
  background: var(--color-canvas, #fff);
614
605
  box-shadow: var(--shadow-sm);
615
- font-size: 9.5px;
616
- line-height: 1;
617
- text-transform: uppercase;
618
- letter-spacing: 0.06em;
619
- font-weight: 600;
606
+ font: inherit;
620
607
  cursor: pointer;
621
608
  z-index: 2;
622
609
  opacity: 0;
623
610
  pointer-events: none;
624
611
  transition: opacity 140ms ease-out, transform 140ms ease-out;
625
612
  transform: translateX(-4px);
613
+ margin: 0;
626
614
  }
627
615
 
628
616
  .wre-scope-rail-stripe:hover + .wre-scope-rail-label,
@@ -697,8 +685,8 @@
697
685
 
698
686
  .wre-scope-rail-icon {
699
687
  display: inline-block;
700
- width: 14px;
701
- height: 14px;
688
+ width: 13px;
689
+ height: 13px;
702
690
  background-color: currentColor;
703
691
  mask-repeat: no-repeat;
704
692
  mask-position: center;
@@ -708,6 +696,10 @@
708
696
  -webkit-mask-size: contain;
709
697
  }
710
698
 
699
+ .wre-scope-rail-label-text {
700
+ display: none;
701
+ }
702
+
711
703
  /* Simple inline-SVG-as-mask icons so consumers don't need an icon font. */
712
704
  .wre-scope-rail-icon-lock {
713
705
  mask-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 018 0v4"/></svg>');
@@ -1085,6 +1077,12 @@
1085
1077
  background-color: var(--color-bg-hover);
1086
1078
  }
1087
1079
 
1080
+ .wre-page-band[data-page-band-chrome="plain"],
1081
+ .wre-page-band[data-page-band-chrome="plain"]:hover {
1082
+ background-color: transparent;
1083
+ border-color: transparent;
1084
+ }
1085
+
1088
1086
  .wre-page-band[data-active="true"] {
1089
1087
  background-color: var(--color-accent-soft);
1090
1088
  border: 1px solid var(--color-border-accent);
@@ -243,6 +243,17 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
243
243
  chromeOptions,
244
244
  chromeVisibility: props.chromeVisibility,
245
245
  });
246
+ const plainPageBands =
247
+ isPageWorkspace &&
248
+ chromeVisibility.pageChrome &&
249
+ !chromeVisibility.toolbar &&
250
+ !chromeVisibility.alerts &&
251
+ !chromeVisibility.selectionOverlay &&
252
+ !chromeVisibility.contextToolbars &&
253
+ !chromeVisibility.contextAnalytics &&
254
+ !chromeVisibility.statusBar &&
255
+ !chromeVisibility.reviewRail &&
256
+ !chromeVisibility.shellHeader;
246
257
  const reviewRailAvailable = chromeVisibility.reviewRail && (caps?.reviewRailVisible ?? true);
247
258
  const { viewportWidth, viewportHeight } = useViewportDimensions();
248
259
  const { reviewRailOpen, setReviewRailOpen } = useReviewRailState({
@@ -1059,6 +1070,9 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1059
1070
  ...(pageShellMetrics.frameHeightPx
1060
1071
  ? { minHeight: `${pageShellMetrics.frameHeightPx}px` }
1061
1072
  : {}),
1073
+ ...(pageShellMetrics.frameHeightPx
1074
+ ? { "--wre-page-frame-height-px": `${pageShellMetrics.frameHeightPx}px` }
1075
+ : {}),
1062
1076
  ...(zoomScale !== 1 ? { zoom: zoomScale } : {}),
1063
1077
  }
1064
1078
  : zoomScale !== 1
@@ -1209,6 +1223,7 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1209
1223
  onOpenStory={handleOpenPageModeStory}
1210
1224
  pmSurfaceElement={pmSurfaceElement}
1211
1225
  visiblePageIndexRange={visiblePageIndexRange}
1226
+ plainPageBands={plainPageBands}
1212
1227
  mediaPreviews={props.mediaPreviews}
1213
1228
  activeBandRibbonProps={activeBandRibbonProps}
1214
1229
  />