@beyondwork/docx-react-component 1.0.53 → 1.0.55

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 (99) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +125 -7
  3. package/src/index.ts +5 -0
  4. package/src/io/docx-session.ts +27 -3
  5. package/src/io/normalize/normalize-text.ts +1 -0
  6. package/src/io/ooxml/parse-field-switches.ts +134 -0
  7. package/src/io/ooxml/parse-fields.ts +28 -2
  8. package/src/model/canonical-document.ts +13 -2
  9. package/src/runtime/chart/chart-model-store.ts +88 -0
  10. package/src/runtime/chart/chart-snapshot.ts +239 -0
  11. package/src/runtime/collab/checkpoint-store.ts +1 -1
  12. package/src/runtime/collab/event-types.ts +4 -0
  13. package/src/runtime/collab/runtime-collab-sync.ts +1 -2
  14. package/src/runtime/document-runtime.ts +115 -13
  15. package/src/runtime/layout/inert-layout-facet.ts +1 -0
  16. package/src/runtime/layout/layout-engine-version.ts +58 -1
  17. package/src/runtime/layout/layout-invalidation.ts +150 -30
  18. package/src/runtime/layout/page-graph.ts +19 -0
  19. package/src/runtime/layout/paginated-layout-engine.ts +128 -19
  20. package/src/runtime/layout/project-block-fragments.ts +27 -0
  21. package/src/runtime/layout/public-facet.ts +27 -0
  22. package/src/runtime/page-number-format.ts +207 -0
  23. package/src/runtime/render/render-frame-diff.ts +38 -2
  24. package/src/runtime/surface-projection.ts +32 -3
  25. package/src/ui/WordReviewEditor.tsx +57 -3
  26. package/src/ui/headless/comment-decoration-model.ts +60 -5
  27. package/src/ui/headless/revision-decoration-model.ts +94 -6
  28. package/src/ui/shared/revision-filters.ts +16 -6
  29. package/src/ui-tailwind/chart/ChartSurface.tsx +236 -0
  30. package/src/ui-tailwind/chart/layout/axis-layout.ts +17 -9
  31. package/src/ui-tailwind/chart/layout/legend-layout.ts +231 -0
  32. package/src/ui-tailwind/chart/layout/plot-area.ts +152 -59
  33. package/src/ui-tailwind/chart/layout/title-layout.ts +184 -0
  34. package/src/ui-tailwind/chart/render/area.tsx +277 -0
  35. package/src/ui-tailwind/chart/render/bar-column.tsx +356 -0
  36. package/src/ui-tailwind/chart/render/bubble.tsx +134 -0
  37. package/src/ui-tailwind/chart/render/combo.tsx +85 -0
  38. package/src/ui-tailwind/chart/render/data-labels.tsx +513 -0
  39. package/src/ui-tailwind/chart/render/font-metrics.ts +298 -0
  40. package/src/ui-tailwind/chart/render/gridlines.ts +228 -0
  41. package/src/ui-tailwind/chart/render/line.tsx +363 -0
  42. package/src/ui-tailwind/chart/render/number-format.ts +120 -16
  43. package/src/ui-tailwind/chart/render/pie.tsx +275 -0
  44. package/src/ui-tailwind/chart/render/progressive-render.ts +103 -0
  45. package/src/ui-tailwind/chart/render/scatter.tsx +228 -0
  46. package/src/ui-tailwind/chart/render/smooth-curve.ts +101 -0
  47. package/src/ui-tailwind/chart/render/svg-primitives.ts +378 -0
  48. package/src/ui-tailwind/chart/render/unsupported.tsx +126 -0
  49. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +11 -0
  50. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +44 -18
  51. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +68 -7
  52. package/src/ui-tailwind/chrome/collab-role-chip.tsx +21 -2
  53. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +20 -3
  54. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +102 -37
  55. package/src/ui-tailwind/chrome/tw-command-palette.tsx +358 -0
  56. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +108 -0
  57. package/src/ui-tailwind/chrome/tw-context-menu.tsx +227 -0
  58. package/src/ui-tailwind/chrome/tw-display-mode-selector.tsx +136 -0
  59. package/src/ui-tailwind/chrome/tw-empty-state.tsx +76 -0
  60. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +30 -16
  61. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +23 -4
  62. package/src/ui-tailwind/chrome/tw-paste-drop-toast.tsx +113 -0
  63. package/src/ui-tailwind/chrome/tw-revision-hover-preview.tsx +150 -0
  64. package/src/ui-tailwind/chrome/tw-selection-tool-formatting.tsx +2 -0
  65. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +38 -2
  66. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +15 -3
  67. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +32 -20
  68. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +68 -0
  69. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +10 -10
  70. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +26 -5
  71. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +29 -22
  72. package/src/ui-tailwind/chrome/tw-unsaved-modal.tsx +72 -10
  73. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +33 -18
  74. package/src/ui-tailwind/chrome-overlay/tw-table-continuation-header.tsx +94 -0
  75. package/src/ui-tailwind/editor-surface/chart-node-view.tsx +90 -0
  76. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +20 -7
  77. package/src/ui-tailwind/editor-surface/pm-schema.ts +4 -0
  78. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +14 -0
  79. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +93 -0
  80. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +2 -1
  81. package/src/ui-tailwind/index.ts +11 -0
  82. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +52 -2
  83. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +13 -0
  84. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +13 -0
  85. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +8 -0
  86. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +83 -32
  87. package/src/ui-tailwind/review/tw-health-panel.tsx +174 -109
  88. package/src/ui-tailwind/review/tw-rail-card.tsx +9 -1
  89. package/src/ui-tailwind/review/tw-review-rail.tsx +36 -42
  90. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +189 -101
  91. package/src/ui-tailwind/review/tw-workflow-tab.tsx +11 -1
  92. package/src/ui-tailwind/status/tw-status-bar.tsx +114 -46
  93. package/src/ui-tailwind/theme/editor-theme.css +249 -22
  94. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +14 -1
  95. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +73 -32
  96. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +49 -9
  97. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +178 -14
  98. package/src/ui-tailwind/tw-review-workspace.tsx +39 -6
  99. package/src/ui-tailwind/chrome/review-queue-bar.tsx +0 -85
@@ -27,6 +27,11 @@ export interface TwPageFooterBandProps {
27
27
  widthPx: number;
28
28
  /** True when this band is the active-story slot. Renders portal target instead of read-only DOM. */
29
29
  isActiveSlot: boolean;
30
+ /**
31
+ * Lane 6d.U1 — section label for the active-band ribbon (e.g. "Footer — Section 1").
32
+ * Only rendered when `isActiveSlot` is true.
33
+ */
34
+ sectionLabel?: string;
30
35
  onClick: () => void;
31
36
  "data-testid"?: string;
32
37
  }
@@ -39,13 +44,16 @@ export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = React.memo(({
39
44
  leftPx,
40
45
  widthPx,
41
46
  isActiveSlot,
47
+ sectionLabel,
42
48
  onClick,
43
49
  "data-testid": testId,
44
50
  }) => {
45
51
  return (
46
52
  <div
53
+ className="wre-page-band"
47
54
  data-page-band="footer"
48
55
  data-page-index={pageIndex}
56
+ data-active={isActiveSlot ? "true" : undefined}
49
57
  data-testid={testId}
50
58
  onClick={onClick}
51
59
  style={{
@@ -57,6 +65,11 @@ export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = React.memo(({
57
65
  cursor: "pointer",
58
66
  }}
59
67
  >
68
+ {isActiveSlot && sectionLabel ? (
69
+ <span className="wre-page-band__label" data-kind="page-band-label">
70
+ {sectionLabel}
71
+ </span>
72
+ ) : null}
60
73
  {isActiveSlot ? (
61
74
  <div
62
75
  data-pm-portal-slot
@@ -28,6 +28,11 @@ export interface TwPageHeaderBandProps {
28
28
  widthPx: number;
29
29
  /** True when this band is the active-story slot. Renders portal target instead of read-only DOM. */
30
30
  isActiveSlot: boolean;
31
+ /**
32
+ * Lane 6d.U1 — section label for the active-band ribbon (e.g. "Header — Section 1").
33
+ * Only rendered when `isActiveSlot` is true.
34
+ */
35
+ sectionLabel?: string;
31
36
  onClick: () => void;
32
37
  "data-testid"?: string;
33
38
  }
@@ -40,13 +45,16 @@ export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = React.memo(({
40
45
  leftPx,
41
46
  widthPx,
42
47
  isActiveSlot,
48
+ sectionLabel,
43
49
  onClick,
44
50
  "data-testid": testId,
45
51
  }) => {
46
52
  return (
47
53
  <div
54
+ className="wre-page-band"
48
55
  data-page-band="header"
49
56
  data-page-index={pageIndex}
57
+ data-active={isActiveSlot ? "true" : undefined}
50
58
  data-testid={testId}
51
59
  onClick={onClick}
52
60
  style={{
@@ -58,6 +66,11 @@ export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = React.memo(({
58
66
  cursor: "pointer",
59
67
  }}
60
68
  >
69
+ {isActiveSlot && sectionLabel ? (
70
+ <span className="wre-page-band__label" data-kind="page-band-label">
71
+ {sectionLabel}
72
+ </span>
73
+ ) : null}
61
74
  {isActiveSlot ? (
62
75
  <div
63
76
  data-pm-portal-slot
@@ -361,6 +361,14 @@ export const TwPageStackChromeLayer: React.FC<TwPageStackChromeLayerProps> = ({
361
361
  ref={overlayRootRef}
362
362
  data-page-stack-chrome-layer=""
363
363
  data-testid={testId ?? "page-stack-chrome-layer"}
364
+ // L6d.U1 — expose the active-story kind so the CSS can dim the
365
+ // body when an H/F story is being edited and un-dim again when
366
+ // the active story is the main body.
367
+ data-story-active={
368
+ activeStory.kind === "header" || activeStory.kind === "footer"
369
+ ? activeStory.kind
370
+ : undefined
371
+ }
364
372
  style={{ position: "absolute", inset: 0, pointerEvents: "none" }}
365
373
  >
366
374
  {rects.map((rect, pageIndex) => {
@@ -1,5 +1,7 @@
1
1
  import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
- import { Check, CornerDownRight, RotateCcw } from "lucide-react";
2
+ import { Check, ChevronRight, CornerDownRight, RotateCcw } from "lucide-react";
3
+
4
+ import { TwEmptyState } from "../chrome/tw-empty-state";
3
5
 
4
6
  import type { CommentSidebarSnapshot, CommentSidebarThreadSnapshot } from "../../api/public-types";
5
7
  import type {
@@ -49,41 +51,81 @@ export function TwCommentSidebar(props: TwCommentSidebarProps) {
49
51
  return map;
50
52
  }, [commentPresentations]);
51
53
 
54
+ // Partition threads into open / detached / resolved for ordered rendering
55
+ const openThreads = useMemo(
56
+ () => comments.threads.filter((t) => t.status === "open"),
57
+ [comments.threads],
58
+ );
59
+ const detachedThreads = useMemo(
60
+ () => comments.threads.filter((t) => t.status === "detached"),
61
+ [comments.threads],
62
+ );
63
+ const resolvedThreads = useMemo(
64
+ () => comments.threads.filter((t) => t.status === "resolved"),
65
+ [comments.threads],
66
+ );
67
+
68
+ const openCount = openThreads.length;
69
+ const resolvedCount = resolvedThreads.length;
70
+ const detachedCount = detachedThreads.length;
71
+
72
+ function renderThreadCard(thread: CommentSidebarThreadSnapshot) {
73
+ return (
74
+ <CommentThreadCard
75
+ key={thread.commentId}
76
+ thread={thread}
77
+ isActive={activeCommentId === thread.commentId}
78
+ currentUserId={currentUserId}
79
+ presentation={presentationByCommentId.get(thread.commentId)}
80
+ resolveAttachmentHref={resolveAttachmentHref}
81
+ onOpenComment={props.onOpenComment}
82
+ onResolveComment={props.onResolveComment}
83
+ onReopenComment={props.onReopenComment}
84
+ onAddReply={props.onAddReply}
85
+ onEditBody={props.onEditBody}
86
+ />
87
+ );
88
+ }
89
+
52
90
  return (
53
91
  <div className="outline-none">
54
- <div className="mb-3 flex items-center gap-2 text-[11px] text-tertiary">
55
- <span>{comments.openCommentIds.length} open</span>
56
- <span className="text-border">·</span>
57
- <span>{comments.resolvedCommentIds.length} resolved</span>
58
- {comments.detachedCommentIds.length > 0 && (
59
- <>
60
- <span className="text-border">·</span>
61
- <span>{comments.detachedCommentIds.length} detached</span>
62
- </>
63
- )}
64
- </div>
92
+ {/* Counts header row */}
93
+ {(openCount + resolvedCount + detachedCount > 0) && (
94
+ <div className="mb-3 flex items-center gap-2 px-3 py-2 text-[11px] text-[var(--color-text-tertiary)]">
95
+ <span>{openCount} open</span>
96
+ <span aria-hidden="true">·</span>
97
+ <span>{resolvedCount} resolved</span>
98
+ {detachedCount > 0 && (
99
+ <>
100
+ <span aria-hidden="true">·</span>
101
+ <span className="text-[var(--color-semantic-warning)]">{detachedCount} detached</span>
102
+ </>
103
+ )}
104
+ </div>
105
+ )}
65
106
  {comments.threads.length > 0 ? (
66
107
  <div className="space-y-2">
67
- {comments.threads.map((thread) => (
68
- <CommentThreadCard
69
- key={thread.commentId}
70
- thread={thread}
71
- isActive={activeCommentId === thread.commentId}
72
- currentUserId={currentUserId}
73
- presentation={presentationByCommentId.get(thread.commentId)}
74
- resolveAttachmentHref={resolveAttachmentHref}
75
- onOpenComment={props.onOpenComment}
76
- onResolveComment={props.onResolveComment}
77
- onReopenComment={props.onReopenComment}
78
- onAddReply={props.onAddReply}
79
- onEditBody={props.onEditBody}
80
- />
81
- ))}
108
+ {/* 1. Open threads first */}
109
+ {openThreads.map(renderThreadCard)}
110
+
111
+ {/* 2. Detached threads — visually distinct with warning left-rule */}
112
+ {detachedThreads.map(renderThreadCard)}
113
+
114
+ {/* 3. Resolved threads under a collapsed disclosure */}
115
+ {resolvedThreads.length > 0 && (
116
+ <details className="group mt-2">
117
+ <summary className="cursor-pointer list-none px-3 py-1.5 text-[11px] font-medium text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] flex items-center gap-1.5">
118
+ <ChevronRight className="h-3 w-3 transition-transform group-open:rotate-90" />
119
+ Resolved ({resolvedThreads.length})
120
+ </summary>
121
+ <div className="space-y-2 mt-1">
122
+ {resolvedThreads.map(renderThreadCard)}
123
+ </div>
124
+ </details>
125
+ )}
82
126
  </div>
83
127
  ) : (
84
- <div className="rounded-lg bg-surface px-3 py-3 text-[11px] leading-5 text-tertiary ring-1 ring-border/40">
85
- No comment threads yet. Select text and add one from the toolbar.
86
- </div>
128
+ <TwEmptyState body="No comment threads yet. Select text and add one from the toolbar." />
87
129
  )}
88
130
  </div>
89
131
  );
@@ -138,7 +180,9 @@ function CommentThreadCard(props: {
138
180
  isActive
139
181
  ? "bg-accent-soft/40 ring-accent/25 shadow-[var(--shadow-soft)]"
140
182
  : "hover:bg-surface",
141
- thread.status === "detached" ? "opacity-70" : "",
183
+ thread.status === "detached"
184
+ ? "border-l-[3px] border-[var(--color-semantic-warning)] opacity-70 pl-2.5"
185
+ : "",
142
186
  ].join(" ")}
143
187
  onClick={() => props.onOpenComment?.(thread)}
144
188
  onKeyDown={(event) => {
@@ -154,13 +198,20 @@ function CommentThreadCard(props: {
154
198
  {thread.createdBy.charAt(0).toUpperCase()}
155
199
  </span>
156
200
  <span className="truncate text-[10px] font-medium text-primary">{thread.createdBy}</span>
201
+ {thread.status === "detached" && (
202
+ <span
203
+ data-comment-thread-detached-chip="true"
204
+ className="inline-flex items-center rounded-full bg-[var(--color-semantic-warning-soft)] text-[var(--color-semantic-warning)] text-[9px] font-semibold uppercase tracking-[0.08em] px-1.5 py-0.5 ml-1.5"
205
+ >
206
+ Detached
207
+ </span>
208
+ )}
157
209
  <span data-comment-thread-created-at="true" className="text-[9px] text-tertiary">
158
210
  {formatCommentDate(thread.createdAt)}
159
211
  </span>
160
212
  <span className="flex-1" />
161
213
  {isDraftThread ? <StatusBadge label="draft" tone="draft" /> : null}
162
214
  {thread.status === "resolved" ? <StatusBadge label="resolved" tone="resolved" /> : null}
163
- {thread.status === "detached" ? <StatusBadge label="detached" tone="detached" /> : null}
164
215
  </div>
165
216
 
166
217
  {/* Excerpt — anchored text from document */}
@@ -7,6 +7,7 @@ import type {
7
7
  EditorWarning,
8
8
  WorkflowBlockedCommandReason,
9
9
  } from "../../api/public-types";
10
+ import { TwEmptyState } from "../chrome/tw-empty-state";
10
11
 
11
12
  export interface TwHealthPanelProps {
12
13
  compatibility: CompatibilityPanelSnapshot;
@@ -14,127 +15,186 @@ export interface TwHealthPanelProps {
14
15
  blockedReasons?: WorkflowBlockedCommandReason[];
15
16
  }
16
17
 
17
- export function TwHealthPanel(props: TwHealthPanelProps) {
18
- const { compatibility, warnings, blockedReasons = [] } = props;
19
- const supportedCount = compatibility.featureEntries.filter(
20
- (e) => e.featureClass === "supported-roundtrip",
21
- ).length;
22
- const preserveOnlyCount = compatibility.featureEntries.filter(
23
- (e) => e.featureClass === "preserve-only",
24
- ).length;
25
- const blockedCount = compatibility.featureEntries.filter(
26
- (e) => e.featureClass === "unsupported-fatal",
27
- ).length;
18
+ type SeverityKey = "error" | "warning" | "info";
19
+
20
+ interface IssueRow {
21
+ id: string;
22
+ message: string;
23
+ detail?: string;
24
+ badge?: string;
25
+ icon: React.ReactNode;
26
+ }
28
27
 
28
+ function renderIssueRow(item: IssueRow, severityKey: SeverityKey) {
29
29
  return (
30
- <div className="outline-none">
31
- <p className="text-xs text-tertiary mb-3">
32
- {supportedCount} supported · {preserveOnlyCount} preserve-only · {blockedCount} blocked
33
- {warnings.length > 0 ? ` · ${warnings.length} warning${warnings.length !== 1 ? "s" : ""}` : ""}
34
- </p>
30
+ <div
31
+ key={item.id}
32
+ className="flex rounded-[var(--radius-sm)] transition-colors hover:bg-[var(--color-bg-hover)]"
33
+ >
34
+ <div
35
+ className="w-[3px] shrink-0 rounded-l-[var(--radius-sm)]"
36
+ style={{ backgroundColor: `var(--color-semantic-${severityKey})` }}
37
+ />
38
+ <div className="flex items-start gap-2 p-2.5 flex-1">
39
+ {item.icon}
40
+ <div className="flex-1 min-w-0">
41
+ <div className="flex items-start justify-between gap-2">
42
+ <span className="text-sm font-medium text-[var(--color-text-primary)]">
43
+ {item.message}
44
+ </span>
45
+ {item.badge != null && (
46
+ <span
47
+ className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium"
48
+ style={{
49
+ color: `var(--color-semantic-${severityKey})`,
50
+ backgroundColor: `var(--color-semantic-${severityKey}-soft)`,
51
+ }}
52
+ >
53
+ {item.badge}
54
+ </span>
55
+ )}
56
+ </div>
57
+ {item.detail != null && (
58
+ <p className="text-xs text-[var(--color-text-tertiary)] mt-0.5">{item.detail}</p>
59
+ )}
60
+ </div>
61
+ </div>
62
+ </div>
63
+ );
64
+ }
35
65
 
66
+ function renderGroup(
67
+ items: IssueRow[],
68
+ label: string,
69
+ severityKey: SeverityKey,
70
+ ) {
71
+ if (items.length === 0) return null;
72
+ return (
73
+ <section key={label}>
74
+ <header className="flex items-center gap-2 mb-1.5">
75
+ <span
76
+ className="inline-flex items-center rounded-[var(--radius-sm)] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em]"
77
+ style={{
78
+ backgroundColor: `var(--color-semantic-${severityKey}-soft)`,
79
+ color: `var(--color-semantic-${severityKey})`,
80
+ }}
81
+ >
82
+ {label}
83
+ </span>
84
+ <span className="text-[10px] text-[var(--color-text-tertiary)]">{items.length}</span>
85
+ </header>
36
86
  <div className="space-y-1">
37
- {compatibility.featureEntries.map((entry) => (
38
- <div key={entry.featureEntryId} className="flex rounded-lg transition-colors hover:bg-surface">
39
- {entry.featureClass !== "supported-roundtrip" ? (
40
- <div className={`w-0.5 shrink-0 rounded-l-lg ${
41
- entry.featureClass === "unsupported-fatal" ? "bg-danger" : "bg-comment"
42
- }`} />
43
- ) : null}
44
- <div className="flex items-start gap-2 p-2.5 flex-1">
45
- <HealthIcon featureClass={entry.featureClass} />
46
- <div className="flex-1 min-w-0">
47
- <div className="flex items-start justify-between gap-2">
48
- <span className="text-sm font-medium text-primary">{entry.message}</span>
49
- <FeatureClassBadge featureClass={entry.featureClass} />
50
- </div>
51
- <p className="text-xs text-tertiary mt-0.5">{entry.featureKey}</p>
52
- </div>
53
- </div>
54
- </div>
55
- ))}
87
+ {items.map((item) => renderIssueRow(item, severityKey))}
88
+ </div>
89
+ </section>
90
+ );
91
+ }
56
92
 
57
- {warnings.map((warning) => (
58
- <div key={warning.warningId} className="flex rounded-lg transition-colors hover:bg-surface">
59
- <div className={`w-0.5 shrink-0 rounded-l-lg ${
60
- warning.severity === "warning" ? "bg-comment" : "bg-accent"
61
- }`} />
62
- <div className="flex items-start gap-2 p-2.5 flex-1">
63
- {warning.severity === "warning" ? (
64
- <AlertTriangle className="h-4 w-4 text-comment shrink-0 mt-0.5" />
65
- ) : (
66
- <Info className="h-4 w-4 text-accent shrink-0 mt-0.5" />
67
- )}
68
- <div className="flex-1 min-w-0">
69
- <div className="flex items-start justify-between gap-2">
70
- <span className="text-sm font-medium text-primary">{warning.message}</span>
71
- <span className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium ${
72
- warning.severity === "warning"
73
- ? "text-comment bg-warning-soft"
74
- : "text-accent bg-accent-soft"
75
- }`}>
76
- {warning.code.replace(/_/g, " ")}
77
- </span>
78
- </div>
79
- <p className="text-xs text-tertiary mt-0.5">{warning.source}</p>
80
- </div>
81
- </div>
82
- </div>
83
- ))}
93
+ export function TwHealthPanel(props: TwHealthPanelProps) {
94
+ const { compatibility, warnings, blockedReasons = [] } = props;
84
95
 
85
- {blockedReasons.length > 0 ? (
86
- <>
87
- <div className="border-t border-border mt-2 pt-2">
88
- <p className="text-xs font-medium text-tertiary mb-1">Workflow blocked reasons</p>
89
- </div>
90
- {blockedReasons.map((reason, index) => (
91
- <div key={`blocked-${index}`} className="flex rounded-lg transition-colors hover:bg-surface">
92
- <div className="w-0.5 shrink-0 rounded-l-lg bg-amber-400" />
93
- <div className="flex items-start gap-2 p-2.5 flex-1">
94
- <ShieldAlert className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
95
- <div className="flex-1 min-w-0">
96
- <div className="flex items-start justify-between gap-2">
97
- <span className="text-sm font-medium text-primary">{reason.message}</span>
98
- <span className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium text-amber-700 bg-amber-100">
99
- {reason.code.replace(/_/g, " ")}
100
- </span>
101
- </div>
102
- {reason.scopeId ? (
103
- <p className="text-xs text-tertiary mt-0.5">scope: {reason.scopeId}</p>
104
- ) : null}
105
- </div>
106
- </div>
107
- </div>
108
- ))}
109
- </>
110
- ) : null}
96
+ // Blocked export group: unsupported-fatal entries
97
+ const blockedExportItems: IssueRow[] = compatibility.featureEntries
98
+ .filter((e) => e.featureClass === "unsupported-fatal")
99
+ .map((e) => ({
100
+ id: e.featureEntryId,
101
+ message: e.message,
102
+ detail: e.featureKey,
103
+ badge: "blocked",
104
+ icon: (
105
+ <ShieldAlert
106
+ className="h-4 w-4 shrink-0 mt-0.5"
107
+ style={{ color: "var(--color-semantic-error)" }}
108
+ />
109
+ ),
110
+ }));
111
111
 
112
- {compatibility.featureEntries.length === 0 && warnings.length === 0 && blockedReasons.length === 0 ? (
113
- <p className="text-xs text-tertiary py-4">
114
- No compatibility entries or warnings to display.
115
- </p>
116
- ) : null}
117
- </div>
112
+ // Warning group: preserve-only entries + workflow blocked reasons + "warning"-severity EditorWarnings
113
+ const warningItems: IssueRow[] = [
114
+ ...compatibility.featureEntries
115
+ .filter((e) => e.featureClass === "preserve-only")
116
+ .map((e) => ({
117
+ id: e.featureEntryId,
118
+ message: e.message,
119
+ detail: e.featureKey,
120
+ badge: "preserve-only",
121
+ icon: (
122
+ <Shield
123
+ className="h-4 w-4 shrink-0 mt-0.5"
124
+ style={{ color: "var(--color-semantic-warning)" }}
125
+ />
126
+ ),
127
+ })),
128
+ ...blockedReasons.map((r, index) => ({
129
+ id: `blocked-reason-${index}`,
130
+ message: r.message,
131
+ detail: r.scopeId != null ? `scope: ${r.scopeId}` : undefined,
132
+ badge: r.code.replace(/_/g, " "),
133
+ icon: (
134
+ <ShieldAlert
135
+ className="h-4 w-4 shrink-0 mt-0.5"
136
+ style={{ color: "var(--color-semantic-warning)" }}
137
+ />
138
+ ),
139
+ })),
140
+ ...warnings
141
+ .filter((w) => w.severity === "warning")
142
+ .map((w) => ({
143
+ id: w.warningId,
144
+ message: w.message,
145
+ detail: w.source,
146
+ badge: w.code.replace(/_/g, " "),
147
+ icon: (
148
+ <AlertTriangle
149
+ className="h-4 w-4 shrink-0 mt-0.5"
150
+ style={{ color: "var(--color-semantic-warning)" }}
151
+ />
152
+ ),
153
+ })),
154
+ ];
155
+
156
+ // Info group: "info"-severity EditorWarnings
157
+ const infoItems: IssueRow[] = warnings
158
+ .filter((w) => w.severity === "info")
159
+ .map((w) => ({
160
+ id: w.warningId,
161
+ message: w.message,
162
+ detail: w.source,
163
+ badge: w.code.replace(/_/g, " "),
164
+ icon: (
165
+ <Info
166
+ className="h-4 w-4 shrink-0 mt-0.5"
167
+ style={{ color: "var(--color-semantic-info)" }}
168
+ />
169
+ ),
170
+ }));
171
+
172
+ const hasAnyIssue =
173
+ blockedExportItems.length > 0 || warningItems.length > 0 || infoItems.length > 0;
174
+
175
+ return (
176
+ <div className="outline-none">
177
+ {!hasAnyIssue ? (
178
+ <TwEmptyState body="All export checks passed." />
179
+ ) : (
180
+ <div className="space-y-3">
181
+ {renderGroup(blockedExportItems, "Blocked export", "error")}
182
+ {renderGroup(warningItems, "Warning", "warning")}
183
+ {renderGroup(infoItems, "Info", "info")}
184
+ </div>
185
+ )}
118
186
  </div>
119
187
  );
120
188
  }
121
189
 
122
- function HealthIcon(props: { featureClass: CompatibilityFeatureEntry["featureClass"] }) {
123
- switch (props.featureClass) {
124
- case "supported-roundtrip":
125
- return <ShieldCheck className="h-4 w-4 text-insert shrink-0 mt-0.5" />;
126
- case "preserve-only":
127
- return <Shield className="h-4 w-4 text-comment shrink-0 mt-0.5" />;
128
- case "unsupported-fatal":
129
- return <ShieldAlert className="h-4 w-4 text-danger shrink-0 mt-0.5" />;
130
- }
131
- }
132
-
133
190
  function FeatureClassBadge(props: { featureClass: CompatibilityFeatureEntry["featureClass"] }) {
134
191
  const styles: Record<string, string> = {
135
- "supported-roundtrip": "text-insert bg-insert-soft",
136
- "preserve-only": "text-comment bg-warning-soft",
137
- "unsupported-fatal": "text-danger bg-delete-soft",
192
+ "supported-roundtrip":
193
+ "text-[var(--color-semantic-success)] bg-[var(--color-semantic-success-soft)]",
194
+ "preserve-only":
195
+ "text-[var(--color-semantic-warning)] bg-[var(--color-semantic-warning-soft)]",
196
+ "unsupported-fatal":
197
+ "text-[var(--color-semantic-error)] bg-[var(--color-semantic-error-soft)]",
138
198
  };
139
199
  const labels: Record<string, string> = {
140
200
  "supported-roundtrip": "supported",
@@ -142,8 +202,13 @@ function FeatureClassBadge(props: { featureClass: CompatibilityFeatureEntry["fea
142
202
  "unsupported-fatal": "blocked",
143
203
  };
144
204
  return (
145
- <span className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium ${styles[props.featureClass]}`}>
205
+ <span
206
+ className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium ${styles[props.featureClass] ?? ""}`}
207
+ >
146
208
  {labels[props.featureClass]}
147
209
  </span>
148
210
  );
149
211
  }
212
+
213
+ // Keep FeatureClassBadge exported for potential external use
214
+ export { FeatureClassBadge };
@@ -51,6 +51,12 @@ export interface TwRailCardProps {
51
51
  onClick?: () => void;
52
52
  onSelect?: () => void;
53
53
  isActive?: boolean;
54
+ /**
55
+ * U6 — bidirectional rail↔scope focus sync. When `true` the card
56
+ * receives a subtle focus ring (accent-primary/30) to signal that its
57
+ * matching scope card is currently open in the overlay.
58
+ */
59
+ isFocused?: boolean;
54
60
  dataTestId?: string;
55
61
  }
56
62
 
@@ -69,6 +75,7 @@ export function TwRailCard(props: TwRailCardProps) {
69
75
  onClick,
70
76
  onSelect,
71
77
  isActive,
78
+ isFocused,
72
79
  dataTestId,
73
80
  } = props;
74
81
 
@@ -80,9 +87,10 @@ export function TwRailCard(props: TwRailCardProps) {
80
87
  : 0;
81
88
 
82
89
  const commonProps: Record<string, unknown> = {
83
- className: "wre-rail-card block w-full text-left",
90
+ className: `wre-rail-card block w-full text-left${isFocused ? " ring-1 ring-[var(--color-accent-primary)]/30" : ""}`,
84
91
  "data-tone": tone,
85
92
  "data-active": isActive ? "true" : "false",
93
+ "data-focused": isFocused ? "true" : undefined,
86
94
  "data-testid": dataTestId,
87
95
  };
88
96