@beyondwork/docx-react-component 1.0.53 → 1.0.54

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 (86) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +35 -7
  3. package/src/io/docx-session.ts +30 -6
  4. package/src/runtime/collab/checkpoint-store.ts +1 -1
  5. package/src/runtime/collab/event-types.ts +4 -0
  6. package/src/runtime/collab/runtime-collab-sync.ts +1 -2
  7. package/src/runtime/document-runtime.ts +23 -9
  8. package/src/runtime/layout/inert-layout-facet.ts +1 -0
  9. package/src/runtime/layout/layout-engine-version.ts +58 -1
  10. package/src/runtime/layout/layout-invalidation.ts +150 -30
  11. package/src/runtime/layout/page-graph.ts +19 -0
  12. package/src/runtime/layout/paginated-layout-engine.ts +128 -19
  13. package/src/runtime/layout/project-block-fragments.ts +27 -0
  14. package/src/runtime/layout/public-facet.ts +27 -0
  15. package/src/runtime/render/render-frame-diff.ts +38 -2
  16. package/src/ui/WordReviewEditor.tsx +6 -3
  17. package/src/ui/headless/comment-decoration-model.ts +60 -5
  18. package/src/ui/headless/revision-decoration-model.ts +94 -6
  19. package/src/ui/shared/revision-filters.ts +16 -6
  20. package/src/ui-tailwind/chart/ChartSurface.tsx +236 -0
  21. package/src/ui-tailwind/chart/layout/axis-layout.ts +17 -9
  22. package/src/ui-tailwind/chart/layout/legend-layout.ts +231 -0
  23. package/src/ui-tailwind/chart/layout/plot-area.ts +152 -59
  24. package/src/ui-tailwind/chart/layout/title-layout.ts +184 -0
  25. package/src/ui-tailwind/chart/render/area.tsx +277 -0
  26. package/src/ui-tailwind/chart/render/bar-column.tsx +356 -0
  27. package/src/ui-tailwind/chart/render/bubble.tsx +134 -0
  28. package/src/ui-tailwind/chart/render/combo.tsx +85 -0
  29. package/src/ui-tailwind/chart/render/data-labels.tsx +513 -0
  30. package/src/ui-tailwind/chart/render/font-metrics.ts +298 -0
  31. package/src/ui-tailwind/chart/render/gridlines.ts +228 -0
  32. package/src/ui-tailwind/chart/render/line.tsx +363 -0
  33. package/src/ui-tailwind/chart/render/number-format.ts +120 -16
  34. package/src/ui-tailwind/chart/render/pie.tsx +275 -0
  35. package/src/ui-tailwind/chart/render/progressive-render.ts +103 -0
  36. package/src/ui-tailwind/chart/render/scatter.tsx +228 -0
  37. package/src/ui-tailwind/chart/render/smooth-curve.ts +101 -0
  38. package/src/ui-tailwind/chart/render/svg-primitives.ts +378 -0
  39. package/src/ui-tailwind/chart/render/unsupported.tsx +126 -0
  40. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +11 -0
  41. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +44 -18
  42. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +68 -7
  43. package/src/ui-tailwind/chrome/collab-role-chip.tsx +21 -2
  44. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +20 -3
  45. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +102 -37
  46. package/src/ui-tailwind/chrome/tw-command-palette.tsx +358 -0
  47. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +108 -0
  48. package/src/ui-tailwind/chrome/tw-context-menu.tsx +227 -0
  49. package/src/ui-tailwind/chrome/tw-display-mode-selector.tsx +136 -0
  50. package/src/ui-tailwind/chrome/tw-empty-state.tsx +76 -0
  51. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +30 -16
  52. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +23 -4
  53. package/src/ui-tailwind/chrome/tw-paste-drop-toast.tsx +113 -0
  54. package/src/ui-tailwind/chrome/tw-revision-hover-preview.tsx +150 -0
  55. package/src/ui-tailwind/chrome/tw-selection-tool-formatting.tsx +2 -0
  56. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +38 -2
  57. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +15 -3
  58. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +32 -20
  59. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +68 -0
  60. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +10 -10
  61. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +26 -5
  62. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +29 -22
  63. package/src/ui-tailwind/chrome/tw-unsaved-modal.tsx +72 -10
  64. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +33 -18
  65. package/src/ui-tailwind/chrome-overlay/tw-table-continuation-header.tsx +94 -0
  66. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +20 -7
  67. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +93 -0
  68. package/src/ui-tailwind/index.ts +11 -0
  69. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +52 -2
  70. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +13 -0
  71. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +13 -0
  72. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +8 -0
  73. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +83 -32
  74. package/src/ui-tailwind/review/tw-health-panel.tsx +174 -109
  75. package/src/ui-tailwind/review/tw-rail-card.tsx +9 -1
  76. package/src/ui-tailwind/review/tw-review-rail.tsx +36 -42
  77. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +189 -101
  78. package/src/ui-tailwind/review/tw-workflow-tab.tsx +11 -1
  79. package/src/ui-tailwind/status/tw-status-bar.tsx +114 -46
  80. package/src/ui-tailwind/theme/editor-theme.css +249 -22
  81. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +14 -1
  82. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +73 -32
  83. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +49 -9
  84. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +178 -14
  85. package/src/ui-tailwind/tw-review-workspace.tsx +39 -6
  86. package/src/ui-tailwind/chrome/review-queue-bar.tsx +0 -85
@@ -16,128 +16,216 @@ export interface TwRevisionSidebarProps {
16
16
  onRejectAllChanges?: () => void;
17
17
  }
18
18
 
19
+ type TypeFilter = "all" | "insertion" | "deletion" | "formatting";
20
+
21
+ function labelFor(type: TypeFilter): string {
22
+ switch (type) {
23
+ case "all": return "All";
24
+ case "insertion": return "Insertions";
25
+ case "deletion": return "Deletions";
26
+ case "formatting": return "Formatting";
27
+ }
28
+ }
29
+
19
30
  const focusRingClass =
20
31
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas";
21
32
 
22
33
  export function TwRevisionSidebar(props: TwRevisionSidebarProps) {
23
34
  const { trackedChanges, markupDisplay, activeRevisionId } = props;
24
35
  const visibleRevisions = selectVisibleRevisions(trackedChanges.revisions, markupDisplay);
25
- const actionablePendingCount = trackedChanges.revisions.filter(
26
- (r) => r.status === "active" && r.actionability === "actionable",
27
- ).length;
36
+
37
+ const [typeFilter, setTypeFilter] = React.useState<TypeFilter>("all");
38
+ const [authorFilter, setAuthorFilter] = React.useState<string | null>(null);
39
+
40
+ // Derive distinct authors from all visible revisions
41
+ const authors = React.useMemo(() => {
42
+ const seen = new Set<string>();
43
+ for (const r of visibleRevisions) {
44
+ if (r.authorId) seen.add(r.authorId);
45
+ }
46
+ return Array.from(seen).sort();
47
+ }, [visibleRevisions]);
48
+
49
+ // Filtered list based on type + author chips
50
+ const filteredRevisions = React.useMemo(() => {
51
+ return visibleRevisions.filter((r) => {
52
+ if (typeFilter !== "all") {
53
+ // Map filter chip value to revision kinds
54
+ if (typeFilter === "insertion" && r.kind !== "insertion") return false;
55
+ if (typeFilter === "deletion" && r.kind !== "deletion") return false;
56
+ if (typeFilter === "formatting" && r.kind !== "formatting") return false;
57
+ }
58
+ if (authorFilter !== null && r.authorId !== authorFilter) return false;
59
+ return true;
60
+ });
61
+ }, [visibleRevisions, typeFilter, authorFilter]);
62
+
63
+ const handleAcceptAll = React.useCallback(() => {
64
+ if (props.onAcceptAllChanges && typeFilter === "all" && authorFilter === null) {
65
+ // Use dedicated bulk handler when no filter is active
66
+ props.onAcceptAllChanges();
67
+ } else {
68
+ filteredRevisions.forEach((r) => props.onAcceptRevision?.(r.revisionId));
69
+ }
70
+ }, [filteredRevisions, typeFilter, authorFilter, props.onAcceptAllChanges, props.onAcceptRevision]);
71
+
72
+ const handleRejectAll = React.useCallback(() => {
73
+ if (props.onRejectAllChanges && typeFilter === "all" && authorFilter === null) {
74
+ // Use dedicated bulk handler when no filter is active
75
+ props.onRejectAllChanges();
76
+ } else {
77
+ filteredRevisions.forEach((r) => props.onRejectRevision?.(r.revisionId));
78
+ }
79
+ }, [filteredRevisions, typeFilter, authorFilter, props.onRejectAllChanges, props.onRejectRevision]);
28
80
 
29
81
  return (
30
- <div className="outline-none">
31
- <p className="mb-3 text-[11px] text-tertiary">
82
+ <div className="flex flex-col outline-none">
83
+ {/* Stats header */}
84
+ <p className="px-3 pt-3 pb-1 text-[11px] text-[var(--color-text-tertiary)]">
32
85
  {trackedChanges.pendingChangeIds.length} active · {trackedChanges.acceptedChangeIds.length} accepted · {trackedChanges.preserveOnlyChangeIds.length} preserve-only
33
86
  </p>
34
87
 
35
- {/* Bulk actions */}
36
- <div className="mb-3 flex gap-1.5">
88
+ {/* Filter chip row */}
89
+ <div className="flex flex-wrap items-center gap-1 px-3 py-2 border-b border-[var(--color-border-subtle)]/60">
90
+ {(["all", "insertion", "deletion", "formatting"] as const).map((type) => (
91
+ <button
92
+ key={type}
93
+ type="button"
94
+ aria-pressed={typeFilter === type}
95
+ onClick={() => setTypeFilter(type)}
96
+ className={`inline-flex h-6 items-center rounded-[var(--radius-sm)] px-2 text-[11px] font-medium transition-colors
97
+ aria-pressed:bg-[var(--color-accent-soft)] aria-pressed:text-[var(--color-accent-primary)] aria-pressed:ring-1 aria-pressed:ring-[var(--color-accent-primary)]/20
98
+ text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]`}
99
+ >
100
+ {labelFor(type)}
101
+ </button>
102
+ ))}
103
+ {authors.length > 1 && (
104
+ <select
105
+ value={authorFilter ?? ""}
106
+ onChange={(e) => setAuthorFilter(e.target.value || null)}
107
+ className="h-6 rounded-[var(--radius-sm)] border border-[var(--color-border-subtle)] bg-[var(--color-bg-canvas)] px-2 text-[11px] text-[var(--color-text-primary)]"
108
+ >
109
+ <option value="">All authors</option>
110
+ {authors.map((a) => (<option key={a} value={a}>{a}</option>))}
111
+ </select>
112
+ )}
113
+ </div>
114
+
115
+ {/* Revision list — scrollable */}
116
+ <div className="flex-1 overflow-y-auto px-3 py-2">
117
+ {filteredRevisions.length > 0 ? (
118
+ <div className="space-y-2">
119
+ {filteredRevisions.map((rev) => {
120
+ const isActive = activeRevisionId === rev.revisionId;
121
+
122
+ return (
123
+ <div
124
+ key={rev.revisionId}
125
+ role="button"
126
+ tabIndex={0}
127
+ className={`flex cursor-pointer rounded-md bg-surface/90 transition-colors ring-1 ring-border/40 ${focusRingClass} ${isActive ? "bg-accent-soft/40 ring-accent/25" : "hover:bg-surface"}`}
128
+ onClick={() => props.onOpenRevision?.(rev)}
129
+ onKeyDown={(event) => {
130
+ if (event.key === "Enter" || event.key === " ") {
131
+ event.preventDefault();
132
+ props.onOpenRevision?.(rev);
133
+ }
134
+ }}
135
+ >
136
+ <div className={`w-0.5 shrink-0 rounded-l-md ${
137
+ rev.kind === "insertion" ? "bg-insert"
138
+ : rev.kind === "deletion" ? "bg-danger"
139
+ : "bg-tertiary"
140
+ }`} />
141
+ <div className="p-2 flex-1 min-w-0">
142
+ <div className="mb-0.5 flex items-start justify-between gap-2">
143
+ <span className="text-[11px] font-medium text-primary">{rev.anchorLabel}</span>
144
+ <RevisionBadge status={rev.status} actionability={rev.actionability} />
145
+ </div>
146
+ <p className="mb-1 text-[10px] text-tertiary">{rev.authorId} · {rev.createdAt}</p>
147
+ {rev.excerpt ? (
148
+ <p className={`text-[11px] ${
149
+ rev.kind === "insertion" ? "text-insert"
150
+ : rev.kind === "deletion" ? "text-danger line-through"
151
+ : "text-secondary"
152
+ }`}>
153
+ {rev.excerpt}
154
+ </p>
155
+ ) : (
156
+ <p className="text-[11px] text-secondary">{rev.label}</p>
157
+ )}
158
+ {rev.detail ? (
159
+ <p className="mt-1 text-[10px] text-secondary">{rev.detail}</p>
160
+ ) : null}
161
+ <div className="mt-2 flex gap-1.5">
162
+ {rev.actionability === "actionable" ? (
163
+ <>
164
+ <button
165
+ type="button"
166
+ disabled={!rev.canAccept || rev.status === "accepted"}
167
+ className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] text-accent hover:bg-accent-soft transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
168
+ onClick={(e) => {
169
+ e.stopPropagation();
170
+ props.onAcceptRevision?.(rev.revisionId);
171
+ }}
172
+ >
173
+ <Check className="h-3 w-3" /> Accept
174
+ </button>
175
+ <button
176
+ type="button"
177
+ disabled={!rev.canReject || rev.status === "rejected"}
178
+ className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] text-danger hover:bg-danger-soft transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
179
+ onClick={(e) => {
180
+ e.stopPropagation();
181
+ props.onRejectRevision?.(rev.revisionId);
182
+ }}
183
+ >
184
+ <X className="h-3 w-3" /> Reject
185
+ </button>
186
+ </>
187
+ ) : (
188
+ <span className="px-1.5 py-0.5 text-[10px] text-tertiary">Preserve-only</span>
189
+ )}
190
+ </div>
191
+ </div>
192
+ </div>
193
+ );
194
+ })}
195
+ </div>
196
+ ) : (
197
+ <p className="rounded-lg bg-surface px-3 py-4 text-xs text-tertiary ring-1 ring-border/40">
198
+ {trackedChanges.totalCount > 0
199
+ ? (visibleRevisions.length > 0
200
+ ? "No revisions match the current filter."
201
+ : "Switch to Full markup to see all tracked changes.")
202
+ : "Tracked change cards will appear here when present."}
203
+ </p>
204
+ )}
205
+ </div>
206
+
207
+ {/* Sticky footer — bulk actions */}
208
+ <div className="sticky bottom-0 border-t border-[var(--color-border-subtle)]/60 bg-[var(--color-bg-sidebar)] px-3 py-2 flex items-center gap-2">
37
209
  <button
38
210
  type="button"
39
- disabled={actionablePendingCount === 0}
40
- className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-semibold text-accent hover:bg-accent-soft transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
41
- onClick={props.onAcceptAllChanges}
211
+ onClick={handleAcceptAll}
212
+ disabled={filteredRevisions.length === 0}
213
+ className="inline-flex h-7 items-center rounded-[var(--radius-sm)] bg-[var(--color-accent-primary)] px-3 text-[11px] font-medium text-[var(--color-text-on-accent)] hover:bg-[var(--color-accent-primary-hover)] disabled:opacity-40 disabled:cursor-not-allowed"
42
214
  >
43
- Accept all ({actionablePendingCount})
215
+ Accept all
44
216
  </button>
45
217
  <button
46
218
  type="button"
47
- disabled={actionablePendingCount === 0}
48
- className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-[10px] text-secondary hover:bg-surface-hover transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
49
- onClick={props.onRejectAllChanges}
219
+ onClick={handleRejectAll}
220
+ disabled={filteredRevisions.length === 0}
221
+ className="inline-flex h-7 items-center rounded-[var(--radius-sm)] border border-[var(--color-semantic-error)]/35 px-3 text-[11px] font-medium text-[var(--color-semantic-error)] hover:bg-[var(--color-semantic-error-soft)] disabled:opacity-40 disabled:cursor-not-allowed"
50
222
  >
51
223
  Reject all
52
224
  </button>
225
+ <span className="ml-auto text-[11px] text-[var(--color-text-tertiary)]">
226
+ {filteredRevisions.length} of {visibleRevisions.length}
227
+ </span>
53
228
  </div>
54
-
55
- {visibleRevisions.length > 0 ? (
56
- <div className="space-y-2">
57
- {visibleRevisions.map((rev) => {
58
- const isActive = activeRevisionId === rev.revisionId;
59
-
60
- return (
61
- <div
62
- key={rev.revisionId}
63
- role="button"
64
- tabIndex={0}
65
- className={`flex cursor-pointer rounded-md bg-surface/90 transition-colors ring-1 ring-border/40 ${focusRingClass} ${isActive ? "bg-accent-soft/40 ring-accent/25" : "hover:bg-surface"}`}
66
- onClick={() => props.onOpenRevision?.(rev)}
67
- onKeyDown={(event) => {
68
- if (event.key === "Enter" || event.key === " ") {
69
- event.preventDefault();
70
- props.onOpenRevision?.(rev);
71
- }
72
- }}
73
- >
74
- <div className={`w-0.5 shrink-0 rounded-l-md ${
75
- rev.kind === "insertion" ? "bg-insert"
76
- : rev.kind === "deletion" ? "bg-danger"
77
- : "bg-tertiary"
78
- }`} />
79
- <div className="p-2 flex-1 min-w-0">
80
- <div className="mb-0.5 flex items-start justify-between gap-2">
81
- <span className="text-[11px] font-medium text-primary">{rev.anchorLabel}</span>
82
- <RevisionBadge status={rev.status} actionability={rev.actionability} />
83
- </div>
84
- <p className="mb-1 text-[10px] text-tertiary">{rev.authorId} · {rev.createdAt}</p>
85
- {rev.excerpt ? (
86
- <p className={`text-[11px] ${
87
- rev.kind === "insertion" ? "text-insert"
88
- : rev.kind === "deletion" ? "text-danger line-through"
89
- : "text-secondary"
90
- }`}>
91
- {rev.excerpt}
92
- </p>
93
- ) : (
94
- <p className="text-[11px] text-secondary">{rev.label}</p>
95
- )}
96
- {rev.detail ? (
97
- <p className="mt-1 text-[10px] text-secondary">{rev.detail}</p>
98
- ) : null}
99
- <div className="mt-2 flex gap-1.5">
100
- {rev.actionability === "actionable" ? (
101
- <>
102
- <button
103
- type="button"
104
- disabled={!rev.canAccept || rev.status === "accepted"}
105
- className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] text-accent hover:bg-accent-soft transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
106
- onClick={(e) => {
107
- e.stopPropagation();
108
- props.onAcceptRevision?.(rev.revisionId);
109
- }}
110
- >
111
- <Check className="h-3 w-3" /> Accept
112
- </button>
113
- <button
114
- type="button"
115
- disabled={!rev.canReject || rev.status === "rejected"}
116
- className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] text-danger hover:bg-danger-soft transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
117
- onClick={(e) => {
118
- e.stopPropagation();
119
- props.onRejectRevision?.(rev.revisionId);
120
- }}
121
- >
122
- <X className="h-3 w-3" /> Reject
123
- </button>
124
- </>
125
- ) : (
126
- <span className="px-1.5 py-0.5 text-[10px] text-tertiary">Preserve-only</span>
127
- )}
128
- </div>
129
- </div>
130
- </div>
131
- );
132
- })}
133
- </div>
134
- ) : (
135
- <p className="rounded-lg bg-surface px-3 py-4 text-xs text-tertiary ring-1 ring-border/40">
136
- {trackedChanges.totalCount > 0
137
- ? "Switch to Full markup to see all tracked changes."
138
- : "Tracked change cards will appear here when present."}
139
- </p>
140
- )}
141
229
  </div>
142
230
  );
143
231
  }
@@ -15,6 +15,12 @@ export interface TwWorkflowTabProps {
15
15
  segments: readonly ScopeRailSegment[];
16
16
  activeScopeId?: string | null;
17
17
  onOpenScope?: (segment: ScopeRailSegment) => void;
18
+ /**
19
+ * U6 — bidirectional rail↔scope focus sync. Called when the user
20
+ * clicks a workflow card so the scope card layer can activate the
21
+ * matching overlay card. If omitted, focus sync is not wired.
22
+ */
23
+ onActiveScopeChange?: (scopeId: string) => void;
18
24
  }
19
25
 
20
26
  const POSTURE_META: Record<
@@ -34,6 +40,7 @@ export const TwWorkflowTab: React.FC<TwWorkflowTabProps> = ({
34
40
  segments,
35
41
  activeScopeId,
36
42
  onOpenScope,
43
+ onActiveScopeChange,
37
44
  }) => {
38
45
  // Dedupe by scopeId so a scope spanning multiple pages shows once.
39
46
  const byScopeId = new Map<string, ScopeRailSegment>();
@@ -78,7 +85,10 @@ export const TwWorkflowTab: React.FC<TwWorkflowTabProps> = ({
78
85
  className={`wre-workflow-card flex flex-col gap-1 rounded-md border border-border/50 bg-canvas p-3 text-left transition-shadow hover:shadow-md ${
79
86
  isActive ? "ring-1 ring-accent/60" : ""
80
87
  }`}
81
- onClick={onOpenScope ? () => onOpenScope(segment) : undefined}
88
+ onClick={() => {
89
+ onOpenScope?.(segment);
90
+ onActiveScopeChange?.(segment.scopeId);
91
+ }}
82
92
  data-scope-id={segment.scopeId}
83
93
  data-posture={segment.posture}
84
94
  >
@@ -33,8 +33,34 @@ export interface TwStatusBarProps {
33
33
  * canvas backend is live vs. the empirical fallback. Absent = skip.
34
34
  */
35
35
  measurementFidelity?: PublicMeasurementFidelity;
36
+ /**
37
+ * Lane 6b §6b.S4: opt-in diagnostics flag. When `true`, the status
38
+ * bar reveals the measurement-fidelity badge on the right zone.
39
+ * Hosts must pair this with `debugMode` in production — the fidelity
40
+ * badge is a maintainer affordance, not a user-facing surface.
41
+ */
42
+ debugMode?: boolean;
43
+ /**
44
+ * Lane 6b §6b.S4: explicit opt-in to render the fidelity badge when
45
+ * `debugMode` is on. Defaults to `false` so even a debug harness
46
+ * keeps the badge hidden unless a human asks for it.
47
+ */
48
+ showFidelityBadge?: boolean;
36
49
  }
37
50
 
51
+ /**
52
+ * TwStatusBar — bottom chrome strip, 28-px tall (designsystem §6.16).
53
+ *
54
+ * Three-zone CSS grid (Lane 6b §6b.S4):
55
+ * LEFT — save state dot + export state dot + comment / change counts
56
+ * CENTER — Page N of M indicator
57
+ * RIGHT — measurement fidelity badge (opt-in) + session-active label
58
+ *
59
+ * Status dots bind `--color-status-*` tokens (Lane 6a §3 semantic family)
60
+ * so the review / draft / paused / blocked colour ladder stays consistent
61
+ * with the workflow card grammar. The fidelity badge is diagnostic-only:
62
+ * hidden unless `debugMode && showFidelityBadge` are both `true`.
63
+ */
38
64
  export function TwStatusBar(props: TwStatusBarProps) {
39
65
  const saveState = props.isExportBlocked
40
66
  ? "Read-only"
@@ -46,58 +72,100 @@ export function TwStatusBar(props: TwStatusBarProps) {
46
72
  : props.preserveOnlyCount > 0
47
73
  ? "Warnings"
48
74
  : "Ready";
75
+
76
+ const saveDotColor = props.isExportBlocked
77
+ ? "bg-[var(--color-status-blocked)]"
78
+ : props.isDirty
79
+ ? "bg-[var(--color-status-in-progress)]"
80
+ : "bg-[var(--color-status-ready)]";
81
+ const exportDotColor = props.isExportBlocked
82
+ ? "bg-[var(--color-status-blocked)]"
83
+ : props.preserveOnlyCount > 0
84
+ ? "bg-[var(--color-semantic-warning)]"
85
+ : "bg-[var(--color-status-ready)]";
86
+
87
+ const showFidelity =
88
+ props.measurementFidelity != null &&
89
+ props.debugMode === true &&
90
+ props.showFidelityBadge === true;
91
+
49
92
  return (
50
93
  <footer
51
94
  data-testid="status-bar"
52
- className="flex h-7 shrink-0 items-center gap-3 border-t border-border/60 bg-surface/72 px-3 text-[11px] text-tertiary"
95
+ style={{
96
+ // Lane 6b §6b.U5 — density opt-in. Scales the 28 px base by the
97
+ // root `--space-density-multiplier` (compact 0.85 / standard 1 /
98
+ // comfortable 1.15). Hosts toggle via the `useDensity` hook
99
+ // from Lane 6a.
100
+ height:
101
+ "calc(28px * var(--space-density-multiplier, 1))",
102
+ }}
103
+ className={[
104
+ "grid shrink-0 grid-cols-[1fr_auto_1fr] items-center gap-3",
105
+ "border-t border-[var(--color-border-subtle)]",
106
+ "bg-[var(--color-bg-chrome)]/72",
107
+ "px-3 text-[11px] text-[var(--color-text-tertiary)]",
108
+ ].join(" ")}
53
109
  >
54
- <span className="flex items-center gap-1.5">
55
- <span
56
- className={`inline-block h-1.5 w-1.5 rounded-full ${
57
- props.isExportBlocked
58
- ? "bg-danger"
59
- : props.isDirty
60
- ? "bg-comment"
61
- : "bg-accent"
62
- }`}
63
- />
64
- {saveState}
65
- </span>
66
- <span className="flex items-center gap-1.5">
67
- <span
68
- className={`inline-block h-1.5 w-1.5 rounded-full ${
69
- props.isExportBlocked
70
- ? "bg-danger"
71
- : props.preserveOnlyCount > 0
72
- ? "bg-warning"
73
- : "bg-accent"
74
- }`}
75
- />
76
- Export {exportState.toLowerCase()}
77
- </span>
78
- <span>
79
- {props.commentCount} comment{props.commentCount !== 1 ? "s" : ""} ·{" "}
80
- {props.changeCount} change{props.changeCount !== 1 ? "s" : ""}
81
- </span>
82
- {props.displayPageNumber != null && props.pageCount != null ? (
83
- <span data-testid="status-bar-page-count">
84
- Page {props.displayPageNumber} of {props.pageCount}
110
+ {/* LEFT zone — save + export + counts */}
111
+ <div
112
+ className="flex items-center gap-3"
113
+ data-region="left"
114
+ data-testid="status-bar__region-left"
115
+ >
116
+ <span className="flex items-center gap-1.5">
117
+ <span
118
+ className={`inline-block h-1.5 w-1.5 rounded-[var(--radius-pill)] ${saveDotColor}`}
119
+ data-testid="status-bar__save-dot"
120
+ />
121
+ {saveState}
122
+ </span>
123
+ <span className="flex items-center gap-1.5">
124
+ <span
125
+ className={`inline-block h-1.5 w-1.5 rounded-[var(--radius-pill)] ${exportDotColor}`}
126
+ data-testid="status-bar__export-dot"
127
+ />
128
+ Export {exportState.toLowerCase()}
85
129
  </span>
86
- ) : null}
87
- <span className="flex-1" />
88
- {props.measurementFidelity ? (
89
- <span
90
- data-testid="status-bar-measurement-fidelity"
91
- data-fidelity={props.measurementFidelity}
92
- className="uppercase tracking-[0.12em] text-tertiary/70"
93
- title={`Measurement fidelity: ${props.measurementFidelity}`}
94
- >
95
- {formatFidelityBadge(props.measurementFidelity)}
130
+ <span>
131
+ {props.commentCount} comment{props.commentCount !== 1 ? "s" : ""} ·{" "}
132
+ {props.changeCount} change{props.changeCount !== 1 ? "s" : ""}
133
+ </span>
134
+ </div>
135
+
136
+ {/* CENTER zone — page indicator */}
137
+ <div
138
+ className="flex items-center justify-center"
139
+ data-region="center"
140
+ data-testid="status-bar__region-center"
141
+ >
142
+ {props.displayPageNumber != null && props.pageCount != null ? (
143
+ <span data-testid="status-bar-page-count">
144
+ Page {props.displayPageNumber} of {props.pageCount}
145
+ </span>
146
+ ) : null}
147
+ </div>
148
+
149
+ {/* RIGHT zone — fidelity badge (opt-in) + session */}
150
+ <div
151
+ className="flex items-center justify-end gap-2"
152
+ data-region="right"
153
+ data-testid="status-bar__region-right"
154
+ >
155
+ {showFidelity ? (
156
+ <span
157
+ data-testid="status-bar-measurement-fidelity"
158
+ data-fidelity={props.measurementFidelity}
159
+ className="uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]/70"
160
+ title={`Measurement fidelity: ${props.measurementFidelity}`}
161
+ >
162
+ {formatFidelityBadge(props.measurementFidelity!)}
163
+ </span>
164
+ ) : null}
165
+ <span className="truncate text-[10px] uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]/80">
166
+ Session active
96
167
  </span>
97
- ) : null}
98
- <span className="truncate text-[10px] uppercase tracking-[0.12em] text-tertiary/80">
99
- Session active
100
- </span>
168
+ </div>
101
169
  </footer>
102
170
  );
103
171
  }