@elytracms/studio 0.0.37 → 0.0.39

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 (198) hide show
  1. package/convex/cms.ts +8 -2
  2. package/dist/components/app-views/default-modules.d.ts +60 -5
  3. package/dist/components/app-views/default-modules.js +53 -15
  4. package/dist/components/app-views/default-modules.js.map +1 -1
  5. package/dist/components/app-views/module.d.ts +2 -0
  6. package/dist/components/app-views/module.js +8 -0
  7. package/dist/components/app-views/module.js.map +1 -1
  8. package/dist/components/assets/asset-detail.d.ts +24 -0
  9. package/dist/components/assets/asset-detail.js +163 -0
  10. package/dist/components/assets/asset-detail.js.map +1 -0
  11. package/dist/components/assets/asset-grid.d.ts +28 -0
  12. package/dist/components/assets/asset-grid.js +68 -0
  13. package/dist/components/assets/asset-grid.js.map +1 -0
  14. package/dist/components/assets/asset-metadata.d.ts +19 -0
  15. package/dist/components/assets/asset-metadata.js +44 -0
  16. package/dist/components/assets/asset-metadata.js.map +1 -1
  17. package/dist/components/assets/assets-collection-view-source.d.ts +54 -0
  18. package/dist/components/assets/assets-collection-view-source.js +186 -0
  19. package/dist/components/assets/assets-collection-view-source.js.map +1 -0
  20. package/dist/components/assets/assets-module.d.ts +18 -0
  21. package/dist/components/assets/assets-module.js +94 -0
  22. package/dist/components/assets/assets-module.js.map +1 -0
  23. package/dist/components/assets/assets-store.js +2 -7
  24. package/dist/components/assets/assets-store.js.map +1 -1
  25. package/dist/components/assets/assets-view-preference.d.ts +15 -0
  26. package/dist/components/assets/assets-view-preference.js +22 -0
  27. package/dist/components/assets/assets-view-preference.js.map +1 -0
  28. package/dist/components/builder/builder-store.d.ts +12 -0
  29. package/dist/components/builder/builder-store.js +18 -16
  30. package/dist/components/builder/builder-store.js.map +1 -1
  31. package/dist/components/builder/clipboard-actions.js +5 -14
  32. package/dist/components/builder/clipboard-actions.js.map +1 -1
  33. package/dist/components/builder/composition-field-editor-lazy.d.ts +19 -0
  34. package/dist/components/builder/composition-field-editor-lazy.js +29 -0
  35. package/dist/components/builder/composition-field-editor-lazy.js.map +1 -0
  36. package/dist/components/builder/composition-field-editor.js +1 -4
  37. package/dist/components/builder/composition-field-editor.js.map +1 -1
  38. package/dist/components/builder/insert-context.d.ts +8 -0
  39. package/dist/components/builder/insert-context.js +1 -3
  40. package/dist/components/builder/insert-context.js.map +1 -1
  41. package/dist/components/builder/page-tree.js +1 -3
  42. package/dist/components/builder/page-tree.js.map +1 -1
  43. package/dist/components/builder/property-inspector.js +7 -134
  44. package/dist/components/builder/property-inspector.js.map +1 -1
  45. package/dist/components/cms/cms-store.d.ts +15 -4
  46. package/dist/components/cms/cms-store.js +42 -24
  47. package/dist/components/cms/cms-store.js.map +1 -1
  48. package/dist/components/cms/collection-table.d.ts +8 -1
  49. package/dist/components/cms/collection-table.js +62 -10
  50. package/dist/components/cms/collection-table.js.map +1 -1
  51. package/dist/components/cms/column-width.d.ts +25 -0
  52. package/dist/components/cms/column-width.js +47 -0
  53. package/dist/components/cms/column-width.js.map +1 -0
  54. package/dist/components/cms/detail-editor.js +101 -68
  55. package/dist/components/cms/detail-editor.js.map +1 -1
  56. package/dist/components/cms/discard-draft.d.ts +32 -0
  57. package/dist/components/cms/discard-draft.js +13 -0
  58. package/dist/components/cms/discard-draft.js.map +1 -0
  59. package/dist/components/cms/document-staging.d.ts +7 -0
  60. package/dist/components/cms/document-staging.js +44 -0
  61. package/dist/components/cms/document-staging.js.map +1 -0
  62. package/dist/components/cms/field-connection.d.ts +22 -0
  63. package/dist/components/cms/field-connection.js +37 -0
  64. package/dist/components/cms/field-connection.js.map +1 -0
  65. package/dist/components/cms/field-control.js +158 -34
  66. package/dist/components/cms/field-control.js.map +1 -1
  67. package/dist/components/cms/prop-fields.d.ts +43 -0
  68. package/dist/components/cms/prop-fields.js +130 -0
  69. package/dist/components/cms/prop-fields.js.map +1 -0
  70. package/dist/components/cms/rich-text/component-embed.d.ts +17 -3
  71. package/dist/components/cms/rich-text/component-embed.js +29 -7
  72. package/dist/components/cms/rich-text/component-embed.js.map +1 -1
  73. package/dist/components/cms/rich-text/editor.d.ts +3 -0
  74. package/dist/components/cms/rich-text/editor.js +21 -3
  75. package/dist/components/cms/rich-text/editor.js.map +1 -1
  76. package/dist/components/cms/rich-text/embed-picker.d.ts +17 -0
  77. package/dist/components/cms/rich-text/embed-picker.js +34 -0
  78. package/dist/components/cms/rich-text/embed-picker.js.map +1 -0
  79. package/dist/components/cms/rich-text/embed-props.d.ts +49 -0
  80. package/dist/components/cms/rich-text/embed-props.js +106 -0
  81. package/dist/components/cms/rich-text/embed-props.js.map +1 -0
  82. package/dist/components/cms/rich-text/extensions.d.ts +7 -1
  83. package/dist/components/cms/rich-text/extensions.js +6 -1
  84. package/dist/components/cms/rich-text/extensions.js.map +1 -1
  85. package/dist/components/cms/rich-text/index.d.ts +6 -1
  86. package/dist/components/cms/rich-text/index.js +6 -1
  87. package/dist/components/cms/rich-text/index.js.map +1 -1
  88. package/dist/components/cms/rich-text/insert.d.ts +21 -0
  89. package/dist/components/cms/rich-text/insert.js +55 -0
  90. package/dist/components/cms/rich-text/insert.js.map +1 -0
  91. package/dist/components/cms/rich-text/lazy.d.ts +19 -0
  92. package/dist/components/cms/rich-text/lazy.js +26 -0
  93. package/dist/components/cms/rich-text/lazy.js.map +1 -0
  94. package/dist/components/cms/rich-text/slash-command.d.ts +27 -0
  95. package/dist/components/cms/rich-text/slash-command.js +158 -0
  96. package/dist/components/cms/rich-text/slash-command.js.map +1 -0
  97. package/dist/components/cms/rich-text/toolbar.d.ts +5 -6
  98. package/dist/components/cms/rich-text/toolbar.js +3 -5
  99. package/dist/components/cms/rich-text/toolbar.js.map +1 -1
  100. package/dist/components/cms/row-actions.d.ts +28 -0
  101. package/dist/components/cms/row-actions.js +20 -0
  102. package/dist/components/cms/row-actions.js.map +1 -0
  103. package/dist/components/cms/use-document-versions.d.ts +8 -1
  104. package/dist/components/cms/use-document-versions.js +31 -4
  105. package/dist/components/cms/use-document-versions.js.map +1 -1
  106. package/dist/components/cms/version-compare-lazy.d.ts +1 -0
  107. package/dist/components/cms/version-compare-lazy.js +23 -0
  108. package/dist/components/cms/version-compare-lazy.js.map +1 -0
  109. package/dist/components/cms/version-compare-text.d.ts +46 -0
  110. package/dist/components/cms/version-compare-text.js +78 -0
  111. package/dist/components/cms/version-compare-text.js.map +1 -1
  112. package/dist/components/cms/version-compare.d.ts +9 -5
  113. package/dist/components/cms/version-compare.js +184 -59
  114. package/dist/components/cms/version-compare.js.map +1 -1
  115. package/dist/components/cms/versions-pane.js +10 -4
  116. package/dist/components/cms/versions-pane.js.map +1 -1
  117. package/dist/components/components/component-detail.d.ts +5 -3
  118. package/dist/components/components/component-detail.js +41 -8
  119. package/dist/components/components/component-detail.js.map +1 -1
  120. package/dist/components/components/component-preview.d.ts +61 -0
  121. package/dist/components/components/component-preview.js +103 -0
  122. package/dist/components/components/component-preview.js.map +1 -0
  123. package/dist/components/components/components-index.d.ts +5 -3
  124. package/dist/components/components/components-index.js +19 -24
  125. package/dist/components/components/components-index.js.map +1 -1
  126. package/dist/components/components/registry-browser.d.ts +7 -0
  127. package/dist/components/components/registry-browser.js +20 -36
  128. package/dist/components/components/registry-browser.js.map +1 -1
  129. package/dist/components/components/taxonomy.d.ts +51 -0
  130. package/dist/components/components/taxonomy.js +73 -0
  131. package/dist/components/components/taxonomy.js.map +1 -0
  132. package/dist/components/components/transfer-dialog.d.ts +20 -0
  133. package/dist/components/components/transfer-dialog.js +64 -0
  134. package/dist/components/components/transfer-dialog.js.map +1 -0
  135. package/dist/components/components/transfer.d.ts +43 -0
  136. package/dist/components/components/transfer.js +31 -0
  137. package/dist/components/components/transfer.js.map +1 -0
  138. package/dist/components/project-list.js +3 -4
  139. package/dist/components/project-list.js.map +1 -1
  140. package/dist/components/project-switcher.js +3 -2
  141. package/dist/components/project-switcher.js.map +1 -1
  142. package/dist/components/rail-store-status.d.ts +21 -0
  143. package/dist/components/rail-store-status.js +21 -0
  144. package/dist/components/rail-store-status.js.map +1 -0
  145. package/dist/components/redirects/redirects-module.js +4 -8
  146. package/dist/components/redirects/redirects-module.js.map +1 -1
  147. package/dist/components/redirects/redirects-store.js +2 -7
  148. package/dist/components/redirects/redirects-store.js.map +1 -1
  149. package/dist/components/sign-in-needed-badge.d.ts +6 -0
  150. package/dist/components/sign-in-needed-badge.js +13 -0
  151. package/dist/components/sign-in-needed-badge.js.map +1 -0
  152. package/dist/components/studio-project-screen.js +1 -4
  153. package/dist/components/studio-project-screen.js.map +1 -1
  154. package/dist/components/studio-shell.js +1 -4
  155. package/dist/components/studio-shell.js.map +1 -1
  156. package/dist/components/users/users-module.js +5 -7
  157. package/dist/components/users/users-module.js.map +1 -1
  158. package/dist/components/users/users-store.js +2 -7
  159. package/dist/components/users/users-store.js.map +1 -1
  160. package/dist/lib/auth/index.js +1 -1
  161. package/dist/lib/auth/index.js.map +1 -1
  162. package/dist/lib/clipboard/paste-target.js +1 -3
  163. package/dist/lib/clipboard/paste-target.js.map +1 -1
  164. package/dist/lib/cms-views/view-model.d.ts +0 -2
  165. package/dist/lib/cms-views/view-model.js +0 -7
  166. package/dist/lib/cms-views/view-model.js.map +1 -1
  167. package/dist/lib/diff-view-preference.d.ts +7 -0
  168. package/dist/lib/diff-view-preference.js +21 -0
  169. package/dist/lib/diff-view-preference.js.map +1 -0
  170. package/dist/lib/i18n/messages.en.js +81 -30
  171. package/dist/lib/i18n/messages.en.js.map +1 -1
  172. package/dist/lib/local-preference.d.ts +11 -0
  173. package/dist/lib/local-preference.js +26 -0
  174. package/dist/lib/local-preference.js.map +1 -0
  175. package/dist/lib/locale/fallback.d.ts +45 -0
  176. package/dist/lib/locale/fallback.js +85 -0
  177. package/dist/lib/locale/fallback.js.map +1 -1
  178. package/dist/lib/persona/labels.d.ts +6 -0
  179. package/dist/lib/persona/labels.js +8 -0
  180. package/dist/lib/persona/labels.js.map +1 -1
  181. package/dist/lib/query/index.d.ts +1 -1
  182. package/dist/lib/query/index.js +1 -1
  183. package/dist/lib/query/index.js.map +1 -1
  184. package/dist/lib/query/operations-query.d.ts +14 -2
  185. package/dist/lib/query/operations-query.js +11 -6
  186. package/dist/lib/query/operations-query.js.map +1 -1
  187. package/dist/lib/rail.d.ts +0 -6
  188. package/dist/lib/rail.js +6 -25
  189. package/dist/lib/rail.js.map +1 -1
  190. package/dist/lib/theme.d.ts +0 -6
  191. package/dist/lib/theme.js +10 -13
  192. package/dist/lib/theme.js.map +1 -1
  193. package/dist/router/match.d.ts +1 -0
  194. package/dist/router/match.js +4 -0
  195. package/dist/router/match.js.map +1 -1
  196. package/dist/ui/button.d.ts +1 -1
  197. package/package.json +22 -24
  198. package/src/styles/studio.css +17 -0
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useMemo, useState } from 'react';
2
+ import { createContext, useCallback, useContext, useMemo, useState } from 'react';
3
3
  import { ArrowRight, ChevronDown, ChevronLeft, ChevronRight, Image as ImageIcon, RotateCcw } from 'lucide-react';
4
4
  import { diffDocuments } from '@elytracms/core/content';
5
5
  import { isAssetNode, readAssetId, readDocumentRef } from '@elytracms/core/cms-core';
@@ -8,9 +8,11 @@ import { useOperationCache } from '@elytracms/studio/lib/query';
8
8
  import { translate } from '@elytracms/studio/lib/i18n';
9
9
  import { localeChipLabel } from '@elytracms/studio/lib/locale';
10
10
  import { cn } from '@elytracms/studio/lib/utils';
11
+ import { readDiffViewMode, storeDiffViewMode } from '@elytracms/studio/lib/diff-view-preference';
11
12
  import { useCollectionView } from '../collection-view/collection-view-context';
13
+ import { fieldLabel } from './cms-store';
12
14
  import { toRefArray } from './cell';
13
- import { richTextToText, wordDiff } from './version-compare-text';
15
+ import { DIFF_TRUNCATE_THRESHOLD, mergeWordDiff, richTextToText, truncateText, truncateTokens, wordDiff, } from './version-compare-text';
14
16
  import { useDocumentEditor } from './document-editor-context';
15
17
  import { useDocumentVersionsQuery } from './use-document-versions';
16
18
  import { formatVersionTimestamp } from './versions-model';
@@ -19,11 +21,15 @@ import { formatVersionTimestamp } from './versions-model';
19
21
  * (the Versions sidebar pane stays on as the navigator).
20
22
  *
21
23
  * A calm change-list grouped BY FIELD (a localized field's per-locale variants sit
22
- * together): before | after across ONE continuous central divider. Text reads as a
23
- * prose diff (changed words lit in place — rich text via its markdown projection so
24
- * the content, not just "changed", is visible); assets as thumbnails; relations as
25
- * titles. Each field/locale (and each block, at property level) restores back to the
26
- * base version with a single ↩.
24
+ * together): a left **descriptor lane** (EC-317 — field label + locale) identifies
25
+ * each row, next to its before | after value (or one merged **unified** lane —
26
+ * a persisted transient view preference, EC-317). Text reads as a prose diff
27
+ * (changed words lit in place — rich text via its markdown projection); assets as
28
+ * thumbnails; relations as titles. A value past a length threshold collapses
29
+ * behind a per-row "Show more" (+ a global "Expand/Collapse all") — presentation
30
+ * only, the semantic/identity diff underneath (EC-298) is never touched. Rows
31
+ * zebra-stripe for scanability. Each field/locale (and each block, at property
32
+ * level) restores back to the base version with a single ↩.
27
33
  */
28
34
  export function VersionCompare() {
29
35
  const source = useCollectionView();
@@ -31,18 +37,63 @@ export function VersionCompare() {
31
37
  const { versionsQuery, versionKey } = useDocumentVersionsQuery(docRef);
32
38
  const cache = useOperationCache();
33
39
  const [busy, setBusy] = useState(false);
40
+ // EC-317 — the unified/side-by-side toggle is a transient, personal-local view
41
+ // preference (same class as the theme/rail prefs): read once at mount, written
42
+ // back on every change, never round-tripped through the project graph.
43
+ const [mode, setModeState] = useState(() => readDiffViewMode());
44
+ const setMode = useCallback((next) => {
45
+ setModeState(next);
46
+ storeDiffViewMode(next);
47
+ }, []);
48
+ // EC-317 — long-value truncation state: an `expandAll` flag plus per-row
49
+ // overrides. Presentation-only (never recomputes the diff), reset is implicit
50
+ // (in-session state, like the rest of the compare selection).
51
+ const [expandAll, setExpandAll] = useState(false);
52
+ const [expandedRows, setExpandedRows] = useState(() => new Set());
53
+ const toggleExpand = useCallback((id) => {
54
+ setExpandedRows((prev) => {
55
+ const next = new Set(prev);
56
+ if (next.has(id))
57
+ next.delete(id);
58
+ else
59
+ next.add(id);
60
+ return next;
61
+ });
62
+ }, []);
63
+ const isExpanded = useCallback((id) => expandAll || expandedRows.has(id), [expandAll, expandedRows]);
64
+ const toggleExpandAll = useCallback(() => {
65
+ setExpandAll((prev) => {
66
+ const next = !prev;
67
+ // Collapsing all clears per-row overrides too, so "Collapse all" always
68
+ // means "everything is collapsed" rather than "back to before I expanded".
69
+ if (!next)
70
+ setExpandedRows(new Set());
71
+ return next;
72
+ });
73
+ }, []);
74
+ const diffDisplay = useMemo(() => ({ mode, expandAll, isExpanded, toggleExpand }), [mode, expandAll, isExpanded, toggleExpand]);
34
75
  const view = versionsQuery.data ?? null;
35
76
  const versions = useMemo(() => view?.versions ?? [], [view]);
36
77
  const baseRecord = compare ? versions.find((v) => v.version === compare.base) : undefined;
37
- const targetSnapshot = compare?.target === 'draft'
38
- ? draft
39
- : versions.find((v) => v.version === compare?.target)?.snapshot;
78
+ const targetSnapshot = compare?.target === 'draft' ? draft : versions.find((v) => v.version === compare?.target)?.snapshot;
40
79
  const diff = useMemo(() => baseRecord && targetSnapshot
41
80
  ? diffDocuments(collection, baseRecord.snapshot, targetSnapshot)
42
81
  : { changes: [], unchangedCount: 0 }, [collection, baseRecord, targetSnapshot]);
43
82
  const fieldOrder = useMemo(() => new Map(collection.fields.map((f, i) => [f.name, i])), [collection]);
83
+ const fieldByName = useMemo(() => new Map(collection.fields.map((f) => [f.name, f])), [collection]);
44
84
  const localeRank = useMemo(() => new Map(source.localeConfig.locales.map((l, i) => [l, i])), [source.localeConfig]);
45
85
  const groups = useMemo(() => groupByField(diff.changes, fieldOrder, localeRank), [diff.changes, fieldOrder, localeRank]);
86
+ // EC-317 zebra striping: a running index across every rendered row (not reset
87
+ // per field group), so consecutive groups keep alternating rather than each
88
+ // restarting on the same shade.
89
+ const groupRows = useMemo(() => {
90
+ let rowIndex = 0;
91
+ return groups.map((group) => {
92
+ const zebraStart = rowIndex;
93
+ rowIndex += group.entries.length;
94
+ return { group, field: fieldByName.get(group.field), zebraStart };
95
+ });
96
+ }, [groups, fieldByName]);
46
97
  const restore = useCallback(async (scope) => {
47
98
  if (!compare)
48
99
  return;
@@ -90,79 +141,129 @@ export function VersionCompare() {
90
141
  .filter((v) => v.version !== compare.base)
91
142
  .map((v) => ({ value: v.version, label: `v${v.version}`, meta: formatVersionTimestamp(v.createdAt) })),
92
143
  ];
93
- return (_jsxs(Stack, { gap: "3", className: "mx-auto w-full max-w-5xl p-4 sm:p-6", children: [_jsxs(Row, { gap: "2", align: "center", wrap: true, className: "rounded-md border border-border/70 bg-muted/30 px-3 py-2", children: [_jsx(VersionMenu, { caption: translate('cms.version.baseCaption'), current: `v${compare.base}`, meta: formatVersionTimestamp(baseRecord.createdAt), options: baseOptions, onSelect: (v) => setCompareBase(v) }), _jsx(ArrowRight, { className: "size-4 shrink-0 text-muted-foreground", "aria-hidden": true }), _jsx(VersionMenu, { caption: translate('cms.version.compareWithCaption'), current: targetLabel, options: targetOptions, onSelect: (v) => setCompareTarget(v) }), _jsx(Spacer, {}), _jsxs(Row, { gap: "1", children: [_jsx(Button, { variant: "outline", size: "icon-sm", onClick: () => newerBase !== undefined && setCompareBase(newerBase), disabled: newerBase === undefined, title: translate('cms.version.compareNewer'), "aria-label": translate('cms.version.compareNewer'), children: _jsx(ChevronLeft, {}) }), _jsx(Button, { variant: "outline", size: "icon-sm", onClick: () => olderBase !== undefined && setCompareBase(olderBase), disabled: olderBase === undefined, title: translate('cms.version.compareOlder'), "aria-label": translate('cms.version.compareOlder'), children: _jsx(ChevronRight, {}) })] }), canRestore ? (_jsxs(Button, { size: "sm", variant: "outline", onClick: () => void restore({ kind: 'all' }), disabled: busy || diff.changes.length === 0, title: translate('cms.version.restoreWholeTitle', { version: compare.base }), children: [_jsx(RotateCcw, {}), " ", translate('cms.version.restoreVersion')] })) : null] }), _jsxs(Text, { size: "xs", tone: "muted", className: "px-0.5", children: [diff.changes.length === 0
144
+ return (_jsxs(Stack, { gap: "3", className: "mx-auto w-full max-w-5xl p-4 sm:p-6", children: [_jsxs(Row, { gap: "2", align: "center", wrap: true, className: "rounded-md border border-border/70 bg-muted/30 px-3 py-2", children: [_jsx(VersionMenu, { caption: translate('cms.version.baseCaption'), current: `v${compare.base}`, meta: formatVersionTimestamp(baseRecord.createdAt), options: baseOptions, onSelect: (v) => setCompareBase(v) }), _jsx(ArrowRight, { className: "size-4 shrink-0 text-muted-foreground", "aria-hidden": true }), _jsx(VersionMenu, { caption: translate('cms.version.compareWithCaption'), current: targetLabel, options: targetOptions, onSelect: (v) => setCompareTarget(v) }), _jsx(Spacer, {}), _jsxs(Row, { gap: "1", children: [_jsx(Button, { variant: "outline", size: "icon-sm", onClick: () => newerBase !== undefined && setCompareBase(newerBase), disabled: newerBase === undefined, title: translate('cms.version.compareNewer'), "aria-label": translate('cms.version.compareNewer'), children: _jsx(ChevronLeft, {}) }), _jsx(Button, { variant: "outline", size: "icon-sm", onClick: () => olderBase !== undefined && setCompareBase(olderBase), disabled: olderBase === undefined, title: translate('cms.version.compareOlder'), "aria-label": translate('cms.version.compareOlder'), children: _jsx(ChevronRight, {}) })] }), canRestore ? (_jsxs(Button, { size: "sm", variant: "outline", onClick: () => void restore({ kind: 'all' }), disabled: busy || diff.changes.length === 0, title: translate('cms.version.restoreWholeTitle', { version: compare.base }), children: [_jsx(RotateCcw, {}), " ", translate('cms.version.restoreVersion')] })) : null] }), diff.changes.length > 0 ? (_jsxs(Row, { gap: "2", align: "center", justify: "between", wrap: true, className: "px-0.5", children: [_jsx(DiffViewToggle, { mode: mode, onChange: setMode }), _jsx(Button, { variant: "ghost", size: "sm", onClick: toggleExpandAll, children: translate(expandAll ? 'cms.field.collapseAll' : 'cms.field.expandAll') })] })) : null, _jsxs(Text, { size: "xs", tone: "muted", className: "px-0.5", children: [diff.changes.length === 0
94
145
  ? translate('cms.version.noDifferences')
95
- : translate(diff.changes.length === 1
96
- ? 'cms.version.changeSummary.one'
97
- : 'cms.version.changeSummary.other', { count: diff.changes.length, unchanged: diff.unchangedCount }), !canRestore && diff.changes.length > 0
98
- ? translate('cms.version.comparingPastVersionsHint')
99
- : ''] }), diff.changes.length === 0 ? (_jsx(CompareNotice, { children: translate('cms.version.versionsIdentical') })) : (_jsxs("div", { className: "relative", children: [_jsx("div", { className: "pointer-events-none absolute inset-y-0 left-1/2 w-0.5 -translate-x-1/2 bg-border", "aria-hidden": true }), _jsx(TwoCol, { className: "px-0.5 pb-1", before: _jsx(Text, { size: "xs", tone: "subtle", className: "uppercase tracking-wide", children: translate('cms.version.beforeVersion', { version: compare.base }) }), after: _jsx(Text, { size: "xs", tone: "subtle", className: "uppercase tracking-wide", children: translate('cms.version.afterTarget', { target: targetLabel }) }) }), groups.map((group) => (_jsx(FieldGroup, { group: group, canRestore: canRestore, busy: busy, onRestore: restore }, group.field)))] }))] }));
146
+ : translate(diff.changes.length === 1 ? 'cms.version.changeSummary.one' : 'cms.version.changeSummary.other', {
147
+ count: diff.changes.length,
148
+ unchanged: diff.unchangedCount,
149
+ }), !canRestore && diff.changes.length > 0 ? translate('cms.version.comparingPastVersionsHint') : ''] }), diff.changes.length === 0 ? (_jsx(CompareNotice, { children: translate('cms.version.versionsIdentical') })) : (_jsx(DiffDisplayContext.Provider, { value: diffDisplay, children: _jsxs("div", { className: "pb-1", children: [_jsxs("div", { className: cn(DIFF_ROW_GRID, 'px-2 pb-1.5'), children: [_jsx(Text, { size: "xs", tone: "subtle", className: "uppercase tracking-wide", children: translate('cms.version.fieldColumnHeader') }), mode === 'unified' ? (_jsx(Text, { size: "xs", tone: "subtle", className: "uppercase tracking-wide", children: translate('cms.version.unifiedCaption', { base: compare.base, target: targetLabel }) })) : (_jsx(TwoCol, { before: _jsx(Text, { size: "xs", tone: "subtle", className: "uppercase tracking-wide", children: translate('cms.version.beforeVersion', { version: compare.base }) }), after: _jsx(Text, { size: "xs", tone: "subtle", className: "uppercase tracking-wide", children: translate('cms.version.afterTarget', { target: targetLabel }) }) }))] }), groupRows.map(({ group, field, zebraStart }) => (_jsx(FieldGroup, { group: group, field: field, zebraStart: zebraStart, canRestore: canRestore, busy: busy, onRestore: restore }, group.field)))] }) }))] }));
100
150
  }
101
- /** A before | after grid; the continuous centre divider lives behind it (in the
102
- * parent), so the gap is where the two columns meet. */
151
+ const DiffDisplayContext = createContext(null);
152
+ function useDiffDisplay() {
153
+ const ctx = useContext(DiffDisplayContext);
154
+ if (!ctx)
155
+ throw new Error('useDiffDisplay must be rendered under the VersionCompare diff area');
156
+ return ctx;
157
+ }
158
+ /** Shared grid template so the descriptor lane + header captions line up. */
159
+ const DIFF_ROW_GRID = 'grid grid-cols-[7.5rem_1fr] items-start gap-x-4';
160
+ /** A before | after grid; a divider sits at the midline (its own, scoped to this
161
+ * instance — every row uses the same proportions, so they all line up). */
103
162
  function TwoCol({ before, after, className }) {
104
- return (_jsxs("div", { className: cn('grid grid-cols-2 gap-x-6', className), children: [_jsx("div", { className: "min-w-0", children: before }), _jsx("div", { className: "min-w-0", children: after })] }));
163
+ return (_jsxs("div", { className: cn('relative grid grid-cols-2 gap-x-6', className), children: [_jsx("div", { className: "pointer-events-none absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border", "aria-hidden": true }), _jsx("div", { className: "min-w-0", children: before }), _jsx("div", { className: "min-w-0", children: after })] }));
164
+ }
165
+ /**
166
+ * Mode-aware value display (EC-317): side-by-side keeps the before | after
167
+ * columns; unified stacks a single "− before" / "+ after" pair in one lane,
168
+ * omitting whichever side doesn't exist (`added`/`removed` kind).
169
+ */
170
+ function DiffColumns({ before, after, kind }) {
171
+ const { mode } = useDiffDisplay();
172
+ if (mode === 'unified') {
173
+ return (_jsxs(Stack, { gap: "1", children: [kind !== 'added' ? _jsx(UnifiedLine, { tone: "removed", children: before }) : null, kind !== 'removed' ? _jsx(UnifiedLine, { tone: "added", children: after }) : null] }));
174
+ }
175
+ return _jsx(TwoCol, { before: before, after: after });
176
+ }
177
+ function UnifiedLine({ tone, children }) {
178
+ return (_jsxs(Row, { gap: "2", align: "start", className: cn('rounded-sm px-1.5 py-1', tone === 'removed' ? 'bg-destructive/10' : 'bg-success/10'), children: [_jsx(Text, { size: "xs", mono: true, "aria-hidden": true, className: cn('mt-0.5 shrink-0 select-none', tone === 'removed' ? 'text-destructive' : 'text-success'), children: tone === 'removed' ? '−' : '+' }), _jsx("div", { className: "min-w-0 flex-1", children: children })] }));
179
+ }
180
+ /** Wraps truncatable content with its per-row "Show more"/"Show less" control —
181
+ * hidden while the global "expand all" is active (nothing left to toggle). */
182
+ function ExpandToggle({ id, isLong, expanded, children, }) {
183
+ const { expandAll, toggleExpand } = useDiffDisplay();
184
+ return (_jsxs("div", { children: [children, isLong && !expandAll ? (_jsx(Button, { variant: "link", size: "sm", className: "h-auto p-0 text-xs", onClick: () => toggleExpand(id), children: expanded ? translate('cms.version.showLess') : translate('cms.version.showMore') })) : null] }));
105
185
  }
106
186
  function VersionMenu({ caption, current, meta, options, onSelect, }) {
107
187
  return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(Button, { variant: "outline", size: "sm", className: "gap-1.5", children: [_jsx(Text, { size: "xs", tone: "subtle", className: "uppercase tracking-wide", children: caption }), _jsx(Text, { size: "sm", weight: "medium", mono: true, children: current }), meta ? (_jsx(Text, { size: "xs", tone: "muted", className: "hidden sm:inline", children: meta })) : null, _jsx(ChevronDown, { className: "size-3.5 text-muted-foreground" })] }) }), _jsx(DropdownMenuContent, { align: "start", className: "max-h-72 overflow-auto", children: options.map((o) => (_jsxs(DropdownMenuItem, { onClick: () => onSelect(o.value), className: "gap-2", children: [_jsx(Text, { size: "sm", weight: "medium", mono: o.value !== 'draft', children: o.label }), o.meta ? (_jsx(Text, { size: "xs", tone: "muted", children: o.meta })) : null] }, String(o.value)))) })] }));
108
188
  }
109
- function FieldGroup({ group, canRestore, busy, onRestore, }) {
110
- // A plain (non-localized) field is a single entry — label + value inline. A
111
- // localized field groups its locale variants under one label.
112
- const single = group.entries.length === 1 && group.entries[0].locale === undefined;
113
- if (single) {
114
- const entry = group.entries[0];
115
- return (_jsxs("div", { className: "border-t border-border/30 py-3", children: [_jsxs(Row, { gap: "2", align: "center", className: "mb-1.5", children: [_jsx(Text, { size: "sm", weight: "medium", className: "min-w-0 truncate", children: group.label }), _jsx(KindTag, { kind: entry.kind }), _jsx(Spacer, {}), canRestore ? (_jsx(RestoreIcon, { busy: busy, title: translate('cms.version.restoreField', { field: group.label }), onClick: () => onRestore(fieldScope(entry)) })) : null] }), _jsx(EntryBody, { change: entry, canRestore: canRestore, busy: busy, onRestore: onRestore })] }));
116
- }
117
- return (_jsxs("div", { className: "border-t border-border/30 py-3", children: [_jsx(Text, { size: "sm", weight: "medium", className: "mb-2 block", children: group.label }), _jsx(Stack, { gap: "3", children: group.entries.map((entry) => (_jsxs("div", { children: [_jsxs(Row, { gap: "2", align: "center", className: "mb-1", children: [entry.locale ? (_jsx(Badge, { variant: "outline", className: "px-1 py-0 text-[0.6rem] uppercase tracking-wide text-muted-foreground", children: localeChipLabel(entry.locale) })) : null, _jsx(KindTag, { kind: entry.kind }), _jsx(Spacer, {}), canRestore ? (_jsx(RestoreIcon, { busy: busy, title: entry.locale
118
- ? translate('cms.version.restoreFieldLocale', {
119
- field: group.label,
120
- locale: localeChipLabel(entry.locale),
121
- })
122
- : translate('cms.version.restoreField', { field: group.label }), onClick: () => onRestore(fieldScope(entry)) })) : null] }), _jsx(EntryBody, { change: entry, canRestore: canRestore, busy: busy, onRestore: onRestore })] }, entry.locale ?? '__base__'))) })] }));
189
+ /** The unified/side-by-side segmented toggle (EC-317) — same pill language as
190
+ * the header's `ViewTabs`/locale switcher, so it reads as one lens control. */
191
+ function DiffViewToggle({ mode, onChange }) {
192
+ return (_jsxs(Row, { gap: "px", className: "rounded-md bg-muted/50 p-0.5", role: "group", "aria-label": translate('cms.version.viewModeAriaLabel'), "data-testid": "diff-view-toggle", children: [_jsx(DiffViewOption, { active: mode === 'sideBySide', label: translate('cms.version.viewSideBySide'), onClick: () => onChange('sideBySide') }), _jsx(DiffViewOption, { active: mode === 'unified', label: translate('cms.version.viewUnified'), onClick: () => onChange('unified') })] }));
123
193
  }
124
- function EntryBody({ change, canRestore, busy, onRestore, }) {
194
+ function DiffViewOption({ active, label, onClick }) {
195
+ return (_jsx("button", { type: "button", "aria-pressed": active, "data-testid": `diff-view-option-${active ? 'active' : 'inactive'}`, onClick: onClick, className: cn('rounded-[5px] px-2.5 py-1 text-xs font-medium transition-colors', active ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'), children: label }));
196
+ }
197
+ /**
198
+ * One field's diff rows (EC-317: each locale variant is its own zebra-striped
199
+ * row with a descriptor lane — label reused from the studio's EC-241
200
+ * `fieldLabel` active-locale resolver where a schema field exists, falling
201
+ * back to the diff engine's default-locale label for a schema-drift key).
202
+ */
203
+ function FieldGroup({ group, field, zebraStart, canRestore, busy, onRestore, }) {
204
+ const label = field ? fieldLabel(field) : group.label;
205
+ return (_jsx("div", { className: "border-t border-border/30", children: group.entries.map((entry, i) => {
206
+ const rowId = `${group.field}:${entry.locale ?? '_'}`;
207
+ return (_jsx(FieldRow, { zebra: (zebraStart + i) % 2 === 1, descriptor: _jsx(FieldDescriptor, { label: label, locale: entry.locale, kind: entry.kind }), restore: canRestore ? (_jsx(RestoreIcon, { busy: busy, title: entry.locale
208
+ ? translate('cms.version.restoreFieldLocale', {
209
+ field: label,
210
+ locale: localeChipLabel(entry.locale),
211
+ })
212
+ : translate('cms.version.restoreField', { field: label }), onClick: () => onRestore(fieldScope(entry)) })) : null, children: _jsx(EntryBody, { change: entry, rowId: rowId, canRestore: canRestore, busy: busy, onRestore: onRestore }) }, entry.locale ?? '__base__'));
213
+ }) }));
214
+ }
215
+ /** The descriptor lane + value area, one grid row (EC-317). Zebra alternates
216
+ * by the row's position across the WHOLE change-list, not per field group. */
217
+ function FieldRow({ descriptor, restore, zebra, children, }) {
218
+ return (_jsxs("div", { className: cn(DIFF_ROW_GRID, 'rounded-sm px-2 py-2.5', zebra && 'bg-muted/40'), children: [_jsxs("div", { className: "flex flex-col gap-1.5", children: [descriptor, restore] }), _jsx("div", { className: "min-w-0", children: children })] }));
219
+ }
220
+ /** The descriptor lane's content (EC-317): field label always, locale chip only
221
+ * when this row is a localized variant, plus the quiet added/removed kind tag. */
222
+ function FieldDescriptor({ label, locale, kind }) {
223
+ return (_jsxs(Stack, { gap: "1", children: [_jsx(Text, { size: "sm", weight: "medium", className: "min-w-0 truncate", children: label }), _jsxs(Row, { gap: "1", wrap: true, children: [locale ? (_jsx(Badge, { variant: "outline", className: "px-1 py-0 text-[0.6rem] uppercase tracking-wide text-muted-foreground", children: localeChipLabel(locale) })) : null, _jsx(KindTag, { kind: kind })] })] }));
224
+ }
225
+ function EntryBody({ change, rowId, canRestore, busy, onRestore, }) {
125
226
  const localeScope = change.locale !== undefined ? { locale: change.locale } : {};
126
227
  if (change.blocks) {
127
- return (_jsx(Stack, { gap: "3", className: "mt-1", children: change.blocks.map((block) => (_jsx(BlockSection, { block: block, canRestore: canRestore, busy: busy, onRestore: () => onRestore({ kind: 'block', field: change.field, blockId: block.blockId, ...localeScope }) }, block.blockId))) }));
228
+ return (_jsx(Stack, { gap: "3", className: "mt-1", children: change.blocks.map((block) => (_jsx(BlockSection, { block: block, rowId: `${rowId}|block:${block.blockId}`, canRestore: canRestore, busy: busy, onRestore: () => onRestore({ kind: 'block', field: change.field, blockId: block.blockId, ...localeScope }) }, block.blockId))) }));
128
229
  }
129
230
  if (change.type === 'richText') {
130
231
  const before = richTextToText(change.before);
131
232
  const after = richTextToText(change.after);
132
233
  if (before === after)
133
- return (_jsx(MutedNote, { children: before
134
- ? translate('cms.version.textUnchanged')
135
- : translate('cms.version.contentChanged') }));
136
- return _jsx(WordDiffCols, { before: before, after: after });
234
+ return (_jsx(MutedNote, { children: before ? translate('cms.version.textUnchanged') : translate('cms.version.contentChanged') }));
235
+ return _jsx(WordDiffCols, { id: rowId, before: before, after: after });
137
236
  }
138
237
  if (change.kind === 'changed' && typeof change.before === 'string' && typeof change.after === 'string') {
139
- return _jsx(WordDiffCols, { before: change.before, after: change.after });
238
+ return _jsx(WordDiffCols, { id: rowId, before: change.before, after: change.after });
140
239
  }
141
- return (_jsx(TwoCol, { before: _jsx(SideValue, { value: change.before, other: change.after, typeHint: change.type }), after: _jsx(SideValue, { value: change.after, other: change.before, typeHint: change.type }) }));
240
+ return (_jsx(DiffColumns, { kind: change.kind, before: _jsx(SideValue, { value: change.before, other: change.after, typeHint: change.type, id: `${rowId}|before` }), after: _jsx(SideValue, { value: change.after, other: change.before, typeHint: change.type, id: `${rowId}|after` }) }));
142
241
  }
143
- function BlockSection({ block, canRestore, busy, onRestore, }) {
242
+ function BlockSection({ block, rowId, canRestore, busy, onRestore, }) {
144
243
  const name = block.componentId.split('.').pop() ?? block.componentId;
145
- return (_jsxs("div", { children: [_jsxs(Row, { gap: "2", align: "center", className: "mb-1", children: [_jsx(Text, { size: "xs", weight: "medium", className: "min-w-0 truncate", children: name }), _jsx(Code, { tone: "muted", children: block.componentId }), _jsx(KindTag, { kind: block.kind }), _jsx(Spacer, {}), canRestore ? (_jsx(RestoreIcon, { busy: busy, title: translate('cms.version.restoreBlock'), onClick: onRestore })) : null] }), block.props && block.props.length > 0 ? (_jsx(Stack, { gap: "2", children: block.props.map((prop) => (_jsx(PropRow, { prop: prop }, prop.name))) })) : null, block.slotsChanged ? _jsx(MutedNote, { children: translate('cms.version.nestedContentChanged') }) : null, block.kind !== 'changed' && !block.props?.length ? (_jsx(MutedNote, { children: block.kind === 'added'
146
- ? translate('cms.version.blockAdded')
147
- : translate('cms.version.blockRemoved') })) : null] }));
244
+ return (_jsxs("div", { children: [_jsxs(Row, { gap: "2", align: "center", className: "mb-1", children: [_jsx(Text, { size: "xs", weight: "medium", className: "min-w-0 truncate", children: name }), _jsx(Code, { tone: "muted", children: block.componentId }), _jsx(KindTag, { kind: block.kind }), _jsx(Spacer, {}), canRestore ? (_jsx(RestoreIcon, { busy: busy, title: translate('cms.version.restoreBlock'), onClick: onRestore })) : null] }), block.props && block.props.length > 0 ? (_jsx(Stack, { gap: "2", children: block.props.map((prop) => (_jsx(PropRow, { prop: prop, rowId: `${rowId}|prop:${prop.name}` }, prop.name))) })) : null, block.slotsChanged ? _jsx(MutedNote, { children: translate('cms.version.nestedContentChanged') }) : null, block.kind !== 'changed' && !block.props?.length ? (_jsx(MutedNote, { children: block.kind === 'added' ? translate('cms.version.blockAdded') : translate('cms.version.blockRemoved') })) : null] }));
148
245
  }
149
- function PropRow({ prop }) {
246
+ function PropRow({ prop, rowId }) {
150
247
  const useWordDiff = prop.kind === 'changed' && typeof prop.before === 'string' && typeof prop.after === 'string';
151
- return (_jsxs("div", { children: [_jsx(Text, { size: "xs", tone: "subtle", className: "mb-0.5 block uppercase tracking-wide", children: prettyName(prop.name) }), useWordDiff ? (_jsx(WordDiffCols, { before: String(prop.before), after: String(prop.after) })) : (_jsx(TwoCol, { before: _jsx(SideValue, { value: prop.before, other: prop.after }), after: _jsx(SideValue, { value: prop.after, other: prop.before }) }))] }));
248
+ return (_jsxs("div", { children: [_jsx(Text, { size: "xs", tone: "subtle", className: "mb-0.5 block uppercase tracking-wide", children: prettyName(prop.name) }), useWordDiff ? (_jsx(WordDiffCols, { id: rowId, before: String(prop.before), after: String(prop.after) })) : (_jsx(DiffColumns, { kind: prop.kind, before: _jsx(SideValue, { value: prop.before, other: prop.after, id: `${rowId}|before` }), after: _jsx(SideValue, { value: prop.after, other: prop.before, id: `${rowId}|after` }) }))] }));
152
249
  }
153
250
  /** One side of a non-text change — the value, or an "empty" placeholder. */
154
- function SideValue({ value, other, typeHint }) {
251
+ function SideValue({ value, other, typeHint, id, }) {
155
252
  if (value === undefined || value === null || value === '') {
156
253
  return (_jsx(Text, { size: "sm", tone: "subtle", className: "italic", children: translate('cms.version.emptyValue') }));
157
254
  }
158
- return _jsx(ValueView, { value: value, other: other, typeHint: typeHint });
255
+ return _jsx(ValueView, { value: value, other: other, typeHint: typeHint, id: id });
159
256
  }
160
257
  /**
161
258
  * Read-only value display, dispatching on the field type where known and on the
162
259
  * self-describing value shape otherwise (block props carry no schema) — assets as
163
- * thumbnails, relations as titles, everything else as calm text.
260
+ * thumbnails, relations as titles, everything else as calm text (EC-317: the
261
+ * plain-text fallback truncates past the shared length threshold).
164
262
  */
165
- function ValueView({ value, other, typeHint }) {
263
+ function ValueView({ value, other, typeHint, id, }) {
264
+ // Called unconditionally up front (rules-of-hooks) even though only the plain-
265
+ // text fallback branch below needs it — the early returns above it don't hook.
266
+ const { isExpanded } = useDiffDisplay();
166
267
  if (typeHint === 'asset' || isAssetNode(value))
167
268
  return _jsx(AssetValue, { value: value });
168
269
  if (typeHint === 'relation' || readDocumentRef(value))
@@ -175,9 +276,13 @@ function ValueView({ value, other, typeHint }) {
175
276
  return (_jsx(Text, { size: "sm", mono: true, children: String(value).slice(0, 10) }));
176
277
  if (typeof value === 'object')
177
278
  return (_jsx(Text, { size: "sm", tone: "muted", children: Array.isArray(value)
178
- ? translate(value.length === 1 ? 'cms.version.itemCount.one' : 'cms.version.itemCount.other', { count: value.length })
279
+ ? translate(value.length === 1 ? 'cms.version.itemCount.one' : 'cms.version.itemCount.other', {
280
+ count: value.length,
281
+ })
179
282
  : translate('cms.version.objectValue') }));
180
- return (_jsx(Text, { size: "sm", className: "whitespace-pre-wrap break-words", children: String(value) }));
283
+ const expanded = isExpanded(id);
284
+ const { shown, isLong } = truncateText(String(value), expanded);
285
+ return (_jsx(ExpandToggle, { id: id, isLong: isLong, expanded: expanded, children: _jsx(Text, { size: "sm", className: "whitespace-pre-wrap break-words", children: shown }) }));
181
286
  }
182
287
  function RelationValue({ value, other }) {
183
288
  const source = useCollectionView();
@@ -197,9 +302,7 @@ function RelationValue({ value, other }) {
197
302
  return _jsx(Text, { size: "sm", children: label });
198
303
  }
199
304
  function AssetValue({ value }) {
200
- const ids = (Array.isArray(value) ? value : [value])
201
- .map(readAssetId)
202
- .filter((id) => id !== undefined);
305
+ const ids = (Array.isArray(value) ? value : [value]).map(readAssetId).filter((id) => id !== undefined);
203
306
  if (ids.length === 0)
204
307
  return (_jsx(Text, { size: "sm", className: "whitespace-pre-wrap break-words", children: String(value) }));
205
308
  return (_jsx("div", { className: "flex flex-wrap gap-2", children: ids.map((id) => (_jsx(AssetThumb, { id: id }, id))) }));
@@ -219,16 +322,38 @@ function AssetThumb({ id }) {
219
322
  return (_jsxs(Badge, { variant: "outline", children: [_jsx(ImageIcon, { className: "size-3" }), " ", label] }));
220
323
  }
221
324
  // --- Prose word diff --------------------------------------------------------------
222
- /** Prose-diff across the central divider, both sides from one LCS. */
223
- function WordDiffCols({ before, after }) {
325
+ /**
326
+ * Prose diff across a row (EC-317): side-by-side reads as two word-diffed
327
+ * columns; unified merges into ONE reading lane (`mergeWordDiff`). Either way,
328
+ * a value past the length threshold collapses behind the row's expand control
329
+ * — the full `wordDiff`/`mergeWordDiff` computation underneath is untouched.
330
+ */
331
+ function WordDiffCols({ id, before, after }) {
332
+ const { mode, isExpanded } = useDiffDisplay();
333
+ const expanded = isExpanded(id);
224
334
  const wd = useMemo(() => wordDiff(before, after), [before, after]);
225
- return (_jsx(TwoCol, { before: _jsx(WordTokens, { tokens: wd.before, side: "before" }), after: _jsx(WordTokens, { tokens: wd.after, side: "after" }) }));
335
+ const merged = useMemo(() => mergeWordDiff(wd.before, wd.after), [wd]);
336
+ if (mode === 'unified') {
337
+ const { shown, isLong } = truncateTokens(merged, expanded, DIFF_TRUNCATE_THRESHOLD);
338
+ return (_jsx(ExpandToggle, { id: id, isLong: isLong, expanded: expanded, children: _jsx(UnifiedWordTokens, { tokens: shown }) }));
339
+ }
340
+ const beforeSlice = truncateTokens(wd.before, expanded, DIFF_TRUNCATE_THRESHOLD);
341
+ const afterSlice = truncateTokens(wd.after, expanded, DIFF_TRUNCATE_THRESHOLD);
342
+ const isLong = beforeSlice.isLong || afterSlice.isLong;
343
+ return (_jsx(ExpandToggle, { id: id, isLong: isLong, expanded: expanded, children: _jsx(TwoCol, { before: _jsx(WordTokens, { tokens: beforeSlice.shown, side: "before" }), after: _jsx(WordTokens, { tokens: afterSlice.shown, side: "after" }) }) }));
226
344
  }
227
345
  function WordTokens({ tokens, side }) {
228
346
  return (_jsx(Text, { size: "sm", className: "whitespace-pre-wrap break-words leading-relaxed", children: tokens.map((token, i) => token.changed ? (_jsx("mark", { className: cn('rounded-sm px-0.5', side === 'before'
229
347
  ? 'bg-destructive/15 text-destructive line-through decoration-destructive/40'
230
348
  : 'bg-success/20 text-foreground'), children: token.text }, i)) : (_jsx("span", { className: "text-muted-foreground", children: token.text }, i))) }));
231
349
  }
350
+ /** The unified lane's merged token stream — same marks as `WordTokens`, one
351
+ * continuous reading order instead of two columns. */
352
+ function UnifiedWordTokens({ tokens }) {
353
+ return (_jsx(Text, { size: "sm", className: "whitespace-pre-wrap break-words leading-relaxed", children: tokens.map((token, i) => token.kind === 'same' ? (_jsx("span", { className: "text-muted-foreground", children: token.text }, i)) : (_jsx("mark", { className: cn('rounded-sm px-0.5', token.kind === 'removed'
354
+ ? 'bg-destructive/15 text-destructive line-through decoration-destructive/40'
355
+ : 'bg-success/20 text-foreground'), children: token.text }, i))) }));
356
+ }
232
357
  // --- Small shared bits ------------------------------------------------------------
233
358
  /** A quiet "· new" / "· removed" tag; nothing for a plain change (implicit). */
234
359
  function KindTag({ kind }) {