@elytracms/studio 0.0.38 → 0.0.40

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 (189) 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 +75 -11
  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 +143 -79
  55. package/dist/components/cms/detail-editor.js.map +1 -1
  56. package/dist/components/cms/details-pane.d.ts +21 -8
  57. package/dist/components/cms/details-pane.js +31 -11
  58. package/dist/components/cms/details-pane.js.map +1 -1
  59. package/dist/components/cms/discard-draft.d.ts +32 -0
  60. package/dist/components/cms/discard-draft.js +13 -0
  61. package/dist/components/cms/discard-draft.js.map +1 -0
  62. package/dist/components/cms/document-staging.d.ts +7 -0
  63. package/dist/components/cms/document-staging.js +44 -0
  64. package/dist/components/cms/document-staging.js.map +1 -0
  65. package/dist/components/cms/field-connection.d.ts +22 -0
  66. package/dist/components/cms/field-connection.js +37 -0
  67. package/dist/components/cms/field-connection.js.map +1 -0
  68. package/dist/components/cms/field-control.js +167 -34
  69. package/dist/components/cms/field-control.js.map +1 -1
  70. package/dist/components/cms/prop-fields.d.ts +43 -0
  71. package/dist/components/cms/prop-fields.js +154 -0
  72. package/dist/components/cms/prop-fields.js.map +1 -0
  73. package/dist/components/cms/rich-text/component-embed.d.ts +17 -3
  74. package/dist/components/cms/rich-text/component-embed.js +29 -7
  75. package/dist/components/cms/rich-text/component-embed.js.map +1 -1
  76. package/dist/components/cms/rich-text/embed-props.d.ts +49 -0
  77. package/dist/components/cms/rich-text/embed-props.js +106 -0
  78. package/dist/components/cms/rich-text/embed-props.js.map +1 -0
  79. package/dist/components/cms/rich-text/index.d.ts +6 -1
  80. package/dist/components/cms/rich-text/index.js +6 -1
  81. package/dist/components/cms/rich-text/index.js.map +1 -1
  82. package/dist/components/cms/rich-text/lazy.d.ts +19 -0
  83. package/dist/components/cms/rich-text/lazy.js +26 -0
  84. package/dist/components/cms/rich-text/lazy.js.map +1 -0
  85. package/dist/components/cms/rich-text/link-control.js +3 -1
  86. package/dist/components/cms/rich-text/link-control.js.map +1 -1
  87. package/dist/components/cms/rich-text/toolbar-state.d.ts +45 -0
  88. package/dist/components/cms/rich-text/toolbar-state.js +66 -0
  89. package/dist/components/cms/rich-text/toolbar-state.js.map +1 -0
  90. package/dist/components/cms/rich-text/toolbar.js +7 -15
  91. package/dist/components/cms/rich-text/toolbar.js.map +1 -1
  92. package/dist/components/cms/row-actions.d.ts +28 -0
  93. package/dist/components/cms/row-actions.js +20 -0
  94. package/dist/components/cms/row-actions.js.map +1 -0
  95. package/dist/components/cms/use-document-versions.d.ts +8 -1
  96. package/dist/components/cms/use-document-versions.js +31 -4
  97. package/dist/components/cms/use-document-versions.js.map +1 -1
  98. package/dist/components/cms/version-compare-lazy.d.ts +1 -0
  99. package/dist/components/cms/version-compare-lazy.js +23 -0
  100. package/dist/components/cms/version-compare-lazy.js.map +1 -0
  101. package/dist/components/cms/version-compare-text.d.ts +46 -0
  102. package/dist/components/cms/version-compare-text.js +78 -0
  103. package/dist/components/cms/version-compare-text.js.map +1 -1
  104. package/dist/components/cms/version-compare.d.ts +9 -5
  105. package/dist/components/cms/version-compare.js +217 -64
  106. package/dist/components/cms/version-compare.js.map +1 -1
  107. package/dist/components/cms/versions-pane.js +10 -4
  108. package/dist/components/cms/versions-pane.js.map +1 -1
  109. package/dist/components/components/component-detail.d.ts +5 -3
  110. package/dist/components/components/component-detail.js +41 -8
  111. package/dist/components/components/component-detail.js.map +1 -1
  112. package/dist/components/components/component-preview.d.ts +97 -0
  113. package/dist/components/components/component-preview.js +202 -0
  114. package/dist/components/components/component-preview.js.map +1 -0
  115. package/dist/components/components/components-index.d.ts +5 -3
  116. package/dist/components/components/components-index.js +19 -24
  117. package/dist/components/components/components-index.js.map +1 -1
  118. package/dist/components/components/registry-browser.d.ts +7 -0
  119. package/dist/components/components/registry-browser.js +20 -36
  120. package/dist/components/components/registry-browser.js.map +1 -1
  121. package/dist/components/components/taxonomy.d.ts +51 -0
  122. package/dist/components/components/taxonomy.js +73 -0
  123. package/dist/components/components/taxonomy.js.map +1 -0
  124. package/dist/components/components/transfer-dialog.d.ts +20 -0
  125. package/dist/components/components/transfer-dialog.js +64 -0
  126. package/dist/components/components/transfer-dialog.js.map +1 -0
  127. package/dist/components/components/transfer.d.ts +43 -0
  128. package/dist/components/components/transfer.js +31 -0
  129. package/dist/components/components/transfer.js.map +1 -0
  130. package/dist/components/project-list.js +3 -4
  131. package/dist/components/project-list.js.map +1 -1
  132. package/dist/components/project-switcher.js +3 -2
  133. package/dist/components/project-switcher.js.map +1 -1
  134. package/dist/components/rail-store-status.d.ts +21 -0
  135. package/dist/components/rail-store-status.js +21 -0
  136. package/dist/components/rail-store-status.js.map +1 -0
  137. package/dist/components/redirects/redirects-module.js +4 -8
  138. package/dist/components/redirects/redirects-module.js.map +1 -1
  139. package/dist/components/redirects/redirects-store.js +2 -7
  140. package/dist/components/redirects/redirects-store.js.map +1 -1
  141. package/dist/components/sign-in-needed-badge.d.ts +6 -0
  142. package/dist/components/sign-in-needed-badge.js +13 -0
  143. package/dist/components/sign-in-needed-badge.js.map +1 -0
  144. package/dist/components/studio-project-screen.js +1 -4
  145. package/dist/components/studio-project-screen.js.map +1 -1
  146. package/dist/components/studio-shell.js +1 -4
  147. package/dist/components/studio-shell.js.map +1 -1
  148. package/dist/components/users/users-module.js +5 -7
  149. package/dist/components/users/users-module.js.map +1 -1
  150. package/dist/components/users/users-store.js +2 -7
  151. package/dist/components/users/users-store.js.map +1 -1
  152. package/dist/lib/auth/index.js +1 -1
  153. package/dist/lib/auth/index.js.map +1 -1
  154. package/dist/lib/clipboard/paste-target.js +1 -3
  155. package/dist/lib/clipboard/paste-target.js.map +1 -1
  156. package/dist/lib/cms-views/view-model.d.ts +0 -2
  157. package/dist/lib/cms-views/view-model.js +0 -7
  158. package/dist/lib/cms-views/view-model.js.map +1 -1
  159. package/dist/lib/diff-view-preference.d.ts +7 -0
  160. package/dist/lib/diff-view-preference.js +21 -0
  161. package/dist/lib/diff-view-preference.js.map +1 -0
  162. package/dist/lib/i18n/messages.en.js +94 -33
  163. package/dist/lib/i18n/messages.en.js.map +1 -1
  164. package/dist/lib/local-preference.d.ts +11 -0
  165. package/dist/lib/local-preference.js +26 -0
  166. package/dist/lib/local-preference.js.map +1 -0
  167. package/dist/lib/locale/fallback.d.ts +45 -0
  168. package/dist/lib/locale/fallback.js +85 -0
  169. package/dist/lib/locale/fallback.js.map +1 -1
  170. package/dist/lib/persona/labels.d.ts +6 -0
  171. package/dist/lib/persona/labels.js +8 -0
  172. package/dist/lib/persona/labels.js.map +1 -1
  173. package/dist/lib/query/index.d.ts +1 -1
  174. package/dist/lib/query/index.js +1 -1
  175. package/dist/lib/query/index.js.map +1 -1
  176. package/dist/lib/query/operations-query.d.ts +14 -2
  177. package/dist/lib/query/operations-query.js +11 -6
  178. package/dist/lib/query/operations-query.js.map +1 -1
  179. package/dist/lib/rail.d.ts +0 -6
  180. package/dist/lib/rail.js +6 -25
  181. package/dist/lib/rail.js.map +1 -1
  182. package/dist/lib/theme.d.ts +0 -6
  183. package/dist/lib/theme.js +10 -13
  184. package/dist/lib/theme.js.map +1 -1
  185. package/dist/router/match.d.ts +1 -0
  186. package/dist/router/match.js +4 -0
  187. package/dist/router/match.js.map +1 -1
  188. package/dist/ui/button.d.ts +1 -1
  189. package/package.json +22 -25
@@ -55,6 +55,84 @@ export function wordDiff(before, after) {
55
55
  afterTokens.push({ text: b[j++], changed: true });
56
56
  return { before: beforeTokens, after: afterTokens };
57
57
  }
58
+ /**
59
+ * Merge a {@link wordDiff} pair into ONE reading lane (EC-317 unified view) —
60
+ * a single stream of `same` / `removed` / `added` tokens instead of two
61
+ * columns. Sound by construction: `wordDiff` pushes an `unchanged` token to
62
+ * BOTH sides in the SAME loop iteration (so the Nth unchanged token in
63
+ * `before` and the Nth unchanged token in `after` are always the same anchor),
64
+ * and every stretch between two anchors is a contiguous `changed` run on
65
+ * either side (possibly empty on one, or both — a pure add/remove — or both
66
+ * nonempty — a replace). Walking both arrays in lockstep and draining each
67
+ * side's changed run between anchors reconstructs that reading order exactly.
68
+ */
69
+ export function mergeWordDiff(before, after) {
70
+ const merged = [];
71
+ let i = 0;
72
+ let j = 0;
73
+ while (i < before.length && j < after.length) {
74
+ if (!before[i].changed && !after[j].changed) {
75
+ merged.push({ text: before[i].text, kind: 'same' });
76
+ i++;
77
+ j++;
78
+ continue;
79
+ }
80
+ while (i < before.length && before[i].changed) {
81
+ merged.push({ text: before[i].text, kind: 'removed' });
82
+ i++;
83
+ }
84
+ while (j < after.length && after[j].changed) {
85
+ merged.push({ text: after[j].text, kind: 'added' });
86
+ j++;
87
+ }
88
+ }
89
+ // A trailing changed run on whichever side still has tokens once the other
90
+ // is exhausted (wordDiff's own trailing while-loops produce exactly this).
91
+ while (i < before.length)
92
+ merged.push({ text: before[i++].text, kind: 'removed' });
93
+ while (j < after.length)
94
+ merged.push({ text: after[j++].text, kind: 'added' });
95
+ return merged;
96
+ }
97
+ /**
98
+ * A sensible default length threshold (characters) past which the EC-317
99
+ * compare view collapses a value behind a per-row "Show more" — long enough to
100
+ * read a couple of sentences at a glance, short enough that a full rich-text
101
+ * body doesn't dominate the change-list.
102
+ */
103
+ export const DIFF_TRUNCATE_THRESHOLD = 400;
104
+ /**
105
+ * Collapse a plain text value for display (EC-317) — presentation only, never
106
+ * touching the semantic/identity diff underneath: the caller always has the
107
+ * full string, this just decides how much of it to render.
108
+ */
109
+ export function truncateText(text, expanded, threshold = DIFF_TRUNCATE_THRESHOLD) {
110
+ const isLong = text.length > threshold;
111
+ if (expanded || !isLong)
112
+ return { shown: text, isLong };
113
+ return { shown: `${text.slice(0, threshold).trimEnd()}…`, isLong };
114
+ }
115
+ /**
116
+ * The token-array counterpart of {@link truncateText} — used for word-diff /
117
+ * merged-diff token streams so a collapsed row still ends on a whole token
118
+ * (never mid-word) and the change markers (`changed`/`kind`) it slices off
119
+ * are simply not rendered, not recomputed.
120
+ */
121
+ export function truncateTokens(tokens, expanded, threshold = DIFF_TRUNCATE_THRESHOLD) {
122
+ const totalLength = tokens.reduce((sum, token) => sum + token.text.length, 0);
123
+ const isLong = totalLength > threshold;
124
+ if (expanded || !isLong)
125
+ return { shown: [...tokens], isLong };
126
+ const shown = [];
127
+ let used = 0;
128
+ for (const token of tokens) {
129
+ if (used >= threshold)
130
+ break;
131
+ shown.push(token);
132
+ used += token.text.length;
133
+ }
134
+ return { shown, isLong };
135
+ }
58
136
  /**
59
137
  * A "rich text" value as readable text for the prose diff — FORMAT-AGNOSTIC, since
60
138
  * the stored value can be anything the field's editor writes: a plain/HTML STRING
@@ -1 +1 @@
1
- {"version":3,"file":"version-compare-text.js","sourceRoot":"","sources":["../../../src/components/cms/version-compare-text.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Pure text helpers for the EC-298 compare view — extracted so the fiddly bits\n * (word-level LCS, format-agnostic \"rich text\" extraction) are unit-tested apart\n * from the React surface.\n */\n\nexport interface WordToken {\n text: string\n changed: boolean\n}\n\nfunction tokenize(s: string): string[] {\n return s.split(/(\\s+)/).filter((t) => t.length > 0)\n}\n\n/**\n * Word-level LCS diff (prose, not code): tokens NOT on the longest common\n * subsequence are marked — removed on the before side, added on the after side.\n * Whitespace tokens participate, so spacing is preserved on render.\n */\nexport function wordDiff(before: string, after: string): { before: WordToken[]; after: WordToken[] } {\n const a = tokenize(before)\n const b = tokenize(after)\n // Bound the DP so a pathological pair can't stall the editor.\n if (a.length + b.length > 4000) {\n return {\n before: a.map((text) => ({ text, changed: true })),\n after: b.map((text) => ({ text, changed: true })),\n }\n }\n const m = a.length\n const n = b.length\n const dp: number[][] = Array.from({ length: m + 1 }, () => new Array<number>(n + 1).fill(0))\n for (let i = m - 1; i >= 0; i--) {\n for (let j = n - 1; j >= 0; j--) {\n dp[i]![j] = a[i] === b[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!)\n }\n }\n const beforeTokens: WordToken[] = []\n const afterTokens: WordToken[] = []\n let i = 0\n let j = 0\n while (i < m && j < n) {\n if (a[i] === b[j]) {\n beforeTokens.push({ text: a[i]!, changed: false })\n afterTokens.push({ text: b[j]!, changed: false })\n i++\n j++\n } else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) {\n beforeTokens.push({ text: a[i]!, changed: true })\n i++\n } else {\n afterTokens.push({ text: b[j]!, changed: true })\n j++\n }\n }\n while (i < m) beforeTokens.push({ text: a[i++]!, changed: true })\n while (j < n) afterTokens.push({ text: b[j++]!, changed: true })\n return { before: beforeTokens, after: afterTokens }\n}\n\n/**\n * A \"rich text\" value as readable text for the prose diff — FORMAT-AGNOSTIC, since\n * the stored value can be anything the field's editor writes: a plain/HTML STRING\n * (a simple text input), a TipTap `{version, doc}` tree, or a bare doc. We show\n * whatever is entered rather than assuming a structure (EC-298).\n */\nexport function richTextToText(value: unknown): string {\n if (typeof value === 'string') return looksLikeHtml(value) ? htmlToText(value) : value\n if (value && typeof value === 'object') {\n const wrapper = value as { doc?: unknown; content?: unknown }\n const doc = (wrapper.doc ?? wrapper) as { content?: unknown }\n if (Array.isArray(doc.content)) return plainTextFromNodes(doc.content)\n }\n return ''\n}\n\nexport function looksLikeHtml(s: string): boolean {\n return /<[a-z!/][\\s\\S]*>/i.test(s)\n}\n\n/** Strip HTML to readable text, keeping block breaks so paragraphs still separate. */\nexport function htmlToText(html: string): string {\n return html\n .replace(/<\\s*br\\s*\\/?>/gi, '\\n')\n .replace(/<\\/(p|div|h[1-6]|blockquote)\\s*>/gi, '\\n\\n')\n .replace(/<\\/(li|tr|ul|ol)\\s*>/gi, '\\n')\n .replace(/<[^>]+>/g, '')\n .replace(/&nbsp;/gi, ' ')\n .replace(/&amp;/gi, '&')\n .replace(/&lt;/gi, '<')\n .replace(/&gt;/gi, '>')\n .replace(/&quot;/gi, '\"')\n .replace(/&#39;/gi, \"'\")\n .replace(/[ \\t]+/g, ' ')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim()\n}\n\n/** Plain text from ProseMirror/TipTap nodes: text runs joined, blocks separated. */\nexport function plainTextFromNodes(nodes: unknown[]): string {\n const blockText = (node: unknown): string => {\n const parts: string[] = []\n const walk = (n: unknown) => {\n if (!n || typeof n !== 'object') return\n const rec = n as { text?: unknown; content?: unknown }\n if (typeof rec.text === 'string') parts.push(rec.text)\n if (Array.isArray(rec.content)) rec.content.forEach(walk)\n }\n walk(node)\n return parts.join('')\n }\n return nodes.map(blockText).filter(Boolean).join('\\n\\n')\n}\n"]}
1
+ {"version":3,"file":"version-compare-text.js","sourceRoot":"","sources":["../../../src/components/cms/version-compare-text.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Pure text helpers for the EC-298 compare view — extracted so the fiddly bits\n * (word-level LCS, format-agnostic \"rich text\" extraction) are unit-tested apart\n * from the React surface.\n */\n\nexport interface WordToken {\n text: string\n changed: boolean\n}\n\n/** A token in the EC-317 unified (single-lane) prose diff: `same` reads plain,\n * `removed`/`added` are the struck/highlighted halves of a changed run. */\nexport interface MergedDiffToken {\n text: string\n kind: 'same' | 'removed' | 'added'\n}\n\nfunction tokenize(s: string): string[] {\n return s.split(/(\\s+)/).filter((t) => t.length > 0)\n}\n\n/**\n * Word-level LCS diff (prose, not code): tokens NOT on the longest common\n * subsequence are marked — removed on the before side, added on the after side.\n * Whitespace tokens participate, so spacing is preserved on render.\n */\nexport function wordDiff(before: string, after: string): { before: WordToken[]; after: WordToken[] } {\n const a = tokenize(before)\n const b = tokenize(after)\n // Bound the DP so a pathological pair can't stall the editor.\n if (a.length + b.length > 4000) {\n return {\n before: a.map((text) => ({ text, changed: true })),\n after: b.map((text) => ({ text, changed: true })),\n }\n }\n const m = a.length\n const n = b.length\n const dp: number[][] = Array.from({ length: m + 1 }, () => new Array<number>(n + 1).fill(0))\n for (let i = m - 1; i >= 0; i--) {\n for (let j = n - 1; j >= 0; j--) {\n dp[i]![j] = a[i] === b[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!)\n }\n }\n const beforeTokens: WordToken[] = []\n const afterTokens: WordToken[] = []\n let i = 0\n let j = 0\n while (i < m && j < n) {\n if (a[i] === b[j]) {\n beforeTokens.push({ text: a[i]!, changed: false })\n afterTokens.push({ text: b[j]!, changed: false })\n i++\n j++\n } else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) {\n beforeTokens.push({ text: a[i]!, changed: true })\n i++\n } else {\n afterTokens.push({ text: b[j]!, changed: true })\n j++\n }\n }\n while (i < m) beforeTokens.push({ text: a[i++]!, changed: true })\n while (j < n) afterTokens.push({ text: b[j++]!, changed: true })\n return { before: beforeTokens, after: afterTokens }\n}\n\n/**\n * Merge a {@link wordDiff} pair into ONE reading lane (EC-317 unified view) —\n * a single stream of `same` / `removed` / `added` tokens instead of two\n * columns. Sound by construction: `wordDiff` pushes an `unchanged` token to\n * BOTH sides in the SAME loop iteration (so the Nth unchanged token in\n * `before` and the Nth unchanged token in `after` are always the same anchor),\n * and every stretch between two anchors is a contiguous `changed` run on\n * either side (possibly empty on one, or both — a pure add/remove — or both\n * nonempty — a replace). Walking both arrays in lockstep and draining each\n * side's changed run between anchors reconstructs that reading order exactly.\n */\nexport function mergeWordDiff(before: WordToken[], after: WordToken[]): MergedDiffToken[] {\n const merged: MergedDiffToken[] = []\n let i = 0\n let j = 0\n while (i < before.length && j < after.length) {\n if (!before[i]!.changed && !after[j]!.changed) {\n merged.push({ text: before[i]!.text, kind: 'same' })\n i++\n j++\n continue\n }\n while (i < before.length && before[i]!.changed) {\n merged.push({ text: before[i]!.text, kind: 'removed' })\n i++\n }\n while (j < after.length && after[j]!.changed) {\n merged.push({ text: after[j]!.text, kind: 'added' })\n j++\n }\n }\n // A trailing changed run on whichever side still has tokens once the other\n // is exhausted (wordDiff's own trailing while-loops produce exactly this).\n while (i < before.length) merged.push({ text: before[i++]!.text, kind: 'removed' })\n while (j < after.length) merged.push({ text: after[j++]!.text, kind: 'added' })\n return merged\n}\n\n/**\n * A sensible default length threshold (characters) past which the EC-317\n * compare view collapses a value behind a per-row \"Show more\" — long enough to\n * read a couple of sentences at a glance, short enough that a full rich-text\n * body doesn't dominate the change-list.\n */\nexport const DIFF_TRUNCATE_THRESHOLD = 400\n\n/**\n * Collapse a plain text value for display (EC-317) — presentation only, never\n * touching the semantic/identity diff underneath: the caller always has the\n * full string, this just decides how much of it to render.\n */\nexport function truncateText(\n text: string,\n expanded: boolean,\n threshold = DIFF_TRUNCATE_THRESHOLD,\n): { shown: string; isLong: boolean } {\n const isLong = text.length > threshold\n if (expanded || !isLong) return { shown: text, isLong }\n return { shown: `${text.slice(0, threshold).trimEnd()}…`, isLong }\n}\n\n/**\n * The token-array counterpart of {@link truncateText} — used for word-diff /\n * merged-diff token streams so a collapsed row still ends on a whole token\n * (never mid-word) and the change markers (`changed`/`kind`) it slices off\n * are simply not rendered, not recomputed.\n */\nexport function truncateTokens<T extends { text: string }>(\n tokens: readonly T[],\n expanded: boolean,\n threshold = DIFF_TRUNCATE_THRESHOLD,\n): { shown: T[]; isLong: boolean } {\n const totalLength = tokens.reduce((sum, token) => sum + token.text.length, 0)\n const isLong = totalLength > threshold\n if (expanded || !isLong) return { shown: [...tokens], isLong }\n const shown: T[] = []\n let used = 0\n for (const token of tokens) {\n if (used >= threshold) break\n shown.push(token)\n used += token.text.length\n }\n return { shown, isLong }\n}\n\n/**\n * A \"rich text\" value as readable text for the prose diff — FORMAT-AGNOSTIC, since\n * the stored value can be anything the field's editor writes: a plain/HTML STRING\n * (a simple text input), a TipTap `{version, doc}` tree, or a bare doc. We show\n * whatever is entered rather than assuming a structure (EC-298).\n */\nexport function richTextToText(value: unknown): string {\n if (typeof value === 'string') return looksLikeHtml(value) ? htmlToText(value) : value\n if (value && typeof value === 'object') {\n const wrapper = value as { doc?: unknown; content?: unknown }\n const doc = (wrapper.doc ?? wrapper) as { content?: unknown }\n if (Array.isArray(doc.content)) return plainTextFromNodes(doc.content)\n }\n return ''\n}\n\nexport function looksLikeHtml(s: string): boolean {\n return /<[a-z!/][\\s\\S]*>/i.test(s)\n}\n\n/** Strip HTML to readable text, keeping block breaks so paragraphs still separate. */\nexport function htmlToText(html: string): string {\n return html\n .replace(/<\\s*br\\s*\\/?>/gi, '\\n')\n .replace(/<\\/(p|div|h[1-6]|blockquote)\\s*>/gi, '\\n\\n')\n .replace(/<\\/(li|tr|ul|ol)\\s*>/gi, '\\n')\n .replace(/<[^>]+>/g, '')\n .replace(/&nbsp;/gi, ' ')\n .replace(/&amp;/gi, '&')\n .replace(/&lt;/gi, '<')\n .replace(/&gt;/gi, '>')\n .replace(/&quot;/gi, '\"')\n .replace(/&#39;/gi, \"'\")\n .replace(/[ \\t]+/g, ' ')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim()\n}\n\n/** Plain text from ProseMirror/TipTap nodes: text runs joined, blocks separated. */\nexport function plainTextFromNodes(nodes: unknown[]): string {\n const blockText = (node: unknown): string => {\n const parts: string[] = []\n const walk = (n: unknown) => {\n if (!n || typeof n !== 'object') return\n const rec = n as { text?: unknown; content?: unknown }\n if (typeof rec.text === 'string') parts.push(rec.text)\n if (Array.isArray(rec.content)) rec.content.forEach(walk)\n }\n walk(node)\n return parts.join('')\n }\n return nodes.map(blockText).filter(Boolean).join('\\n\\n')\n}\n"]}
@@ -3,10 +3,14 @@
3
3
  * (the Versions sidebar pane stays on as the navigator).
4
4
  *
5
5
  * A calm change-list grouped BY FIELD (a localized field's per-locale variants sit
6
- * together): before | after across ONE continuous central divider. Text reads as a
7
- * prose diff (changed words lit in place — rich text via its markdown projection so
8
- * the content, not just "changed", is visible); assets as thumbnails; relations as
9
- * titles. Each field/locale (and each block, at property level) restores back to the
10
- * base version with a single ↩.
6
+ * together): a left **descriptor lane** (EC-317 — field label + locale) identifies
7
+ * each row, next to its before | after value (or one merged **unified** lane —
8
+ * a persisted transient view preference, EC-317). Text reads as a prose diff
9
+ * (changed words lit in place — rich text via its markdown projection); assets as
10
+ * thumbnails; relations as titles. A value past a length threshold collapses
11
+ * behind a per-row "Show more" (+ a global "Expand/Collapse all") — presentation
12
+ * only, the semantic/identity diff underneath (EC-298) is never touched. Rows
13
+ * zebra-stripe for scanability. Each field/locale (and each block, at property
14
+ * level) restores back to the base version with a single ↩.
11
15
  */
12
16
  export declare function VersionCompare(): import("react").JSX.Element | null;
@@ -1,16 +1,18 @@
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
- import { isAssetNode, readAssetId, readDocumentRef } from '@elytracms/core/cms-core';
5
+ import { assetSubfieldsOf, isAssetNode, readAssetId, readDocumentRef } from '@elytracms/core/cms-core';
6
6
  import { Badge, Button, Code, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Row, Spacer, Stack, Text, } from '@elytracms/studio/ui';
7
7
  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,139 @@ 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)))] }))] }));
100
- }
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. */
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, baseVersion: compare.base, canRestore: canRestore, busy: busy, onRestore: restore }, group.field)))] }) }))] }));
150
+ }
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 with a clear central divider (0.0.39: a full-height ruled
161
+ * gutter — the same proportions in every row type, so the before/after split reads
162
+ * consistently across field rows, block-prop rows and word diffs). The columns keep
163
+ * a symmetric inset off the divider so values never touch it. */
103
164
  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 })] }));
165
+ return (_jsxs("div", { className: cn('relative grid grid-cols-2 gap-x-8', 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 pr-1", children: before }), _jsx("div", { className: "min-w-0 pl-1", children: after })] }));
166
+ }
167
+ /**
168
+ * Mode-aware value display (EC-317): side-by-side keeps the before | after
169
+ * columns; unified stacks a single "− before" / "+ after" pair in one lane,
170
+ * omitting whichever side doesn't exist (`added`/`removed` kind).
171
+ */
172
+ function DiffColumns({ before, after, kind }) {
173
+ const { mode } = useDiffDisplay();
174
+ if (mode === 'unified') {
175
+ return (_jsxs(Stack, { gap: "1", children: [kind !== 'added' ? _jsx(UnifiedLine, { tone: "removed", children: before }) : null, kind !== 'removed' ? _jsx(UnifiedLine, { tone: "added", children: after }) : null] }));
176
+ }
177
+ return _jsx(TwoCol, { before: before, after: after });
178
+ }
179
+ function UnifiedLine({ tone, children }) {
180
+ 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 })] }));
181
+ }
182
+ /** Wraps truncatable content with its per-row "Show more"/"Show less" control —
183
+ * hidden while the global "expand all" is active (nothing left to toggle). */
184
+ function ExpandToggle({ id, isLong, expanded, children, }) {
185
+ const { expandAll, toggleExpand } = useDiffDisplay();
186
+ 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
187
  }
106
188
  function VersionMenu({ caption, current, meta, options, onSelect, }) {
107
189
  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
190
  }
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__'))) })] }));
123
- }
124
- function EntryBody({ change, canRestore, busy, onRestore, }) {
191
+ /** The unified/side-by-side segmented toggle (EC-317) — same pill language as
192
+ * the header's `ViewTabs`/locale switcher, so it reads as one lens control. */
193
+ function DiffViewToggle({ mode, onChange }) {
194
+ 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') })] }));
195
+ }
196
+ function DiffViewOption({ active, label, onClick }) {
197
+ 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 }));
198
+ }
199
+ /**
200
+ * One field's diff rows (EC-317: each locale variant is its own zebra-striped
201
+ * row with a descriptor lane — label reused from the studio's EC-241
202
+ * `fieldLabel` active-locale resolver where a schema field exists, falling
203
+ * back to the diff engine's default-locale label for a schema-drift key).
204
+ */
205
+ function FieldGroup({ group, field, zebraStart, baseVersion, canRestore, busy, onRestore, }) {
206
+ const label = field ? fieldLabel(field) : group.label;
207
+ return (_jsx("div", { className: "border-t border-border/30", children: group.entries.map((entry, i) => {
208
+ const rowId = `${group.field}:${entry.locale ?? '_'}`;
209
+ // 0.0.39: ONE restore per row, right-aligned in the descriptor lane. A blocks
210
+ // field restores per BLOCK (each block owns its own restore in its header), so
211
+ // the field-level restore is dropped there — no more two icons per row.
212
+ const fieldRestore = canRestore && !entry.blocks ? (_jsx(RestoreIcon, { busy: busy, title: entry.locale
213
+ ? translate('cms.version.restoreFieldLocale', {
214
+ field: label,
215
+ locale: localeChipLabel(entry.locale),
216
+ version: baseVersion,
217
+ })
218
+ : translate('cms.version.restoreField', { field: label, version: baseVersion }), onClick: () => onRestore(fieldScope(entry)) })) : null;
219
+ return (_jsx(FieldRow, { zebra: (zebraStart + i) % 2 === 1, descriptor: _jsx(FieldDescriptor, { label: label, locale: entry.locale, kind: entry.kind }), restore: fieldRestore, children: _jsx(EntryBody, { change: entry, rowId: rowId, baseVersion: baseVersion, canRestore: canRestore, busy: busy, onRestore: onRestore }) }, entry.locale ?? '__base__'));
220
+ }) }));
221
+ }
222
+ /** The descriptor lane + value area, one grid row (EC-317). Zebra alternates
223
+ * by the row's position across the WHOLE change-list, not per field group. */
224
+ function FieldRow({ descriptor, restore, zebra, children, }) {
225
+ return (_jsxs("div", { className: cn(DIFF_ROW_GRID, 'rounded-sm px-2 py-2.5', zebra && 'bg-muted/40'), children: [_jsxs("div", { className: "flex items-start justify-between gap-1", children: [_jsx("div", { className: "min-w-0", children: descriptor }), restore ? _jsx("div", { className: "shrink-0", children: restore }) : null] }), _jsx("div", { className: "min-w-0", children: children })] }));
226
+ }
227
+ /** The descriptor lane's content (EC-317): field label always, locale chip only
228
+ * when this row is a localized variant, plus the quiet added/removed kind tag. */
229
+ function FieldDescriptor({ label, locale, kind }) {
230
+ 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 })] })] }));
231
+ }
232
+ function EntryBody({ change, rowId, baseVersion, canRestore, busy, onRestore, }) {
125
233
  const localeScope = change.locale !== undefined ? { locale: change.locale } : {};
126
234
  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))) }));
235
+ return (_jsx(Stack, { gap: "2", className: "mt-1", children: change.blocks.map((block) => (_jsx(BlockSection, { block: block, rowId: `${rowId}|block:${block.blockId}`, baseVersion: baseVersion, canRestore: canRestore, busy: busy, onRestore: () => onRestore({ kind: 'block', field: change.field, blockId: block.blockId, ...localeScope }) }, block.blockId))) }));
128
236
  }
129
237
  if (change.type === 'richText') {
130
238
  const before = richTextToText(change.before);
131
239
  const after = richTextToText(change.after);
132
240
  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 });
241
+ return (_jsx(MutedNote, { children: before ? translate('cms.version.textUnchanged') : translate('cms.version.contentChanged') }));
242
+ return _jsx(WordDiffCols, { id: rowId, before: before, after: after });
137
243
  }
138
244
  if (change.kind === 'changed' && typeof change.before === 'string' && typeof change.after === 'string') {
139
- return _jsx(WordDiffCols, { before: change.before, after: change.after });
245
+ return _jsx(WordDiffCols, { id: rowId, before: change.before, after: change.after });
140
246
  }
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 }) }));
247
+ 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
248
  }
143
- function BlockSection({ block, canRestore, busy, onRestore, }) {
249
+ function BlockSection({ block, rowId, baseVersion, canRestore, busy, onRestore, }) {
144
250
  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] }));
251
+ // 0.0.39: each block is its own owned row — a header band (block name + id + kind,
252
+ // restore right-aligned) over the prop changes, indented under it with a left rail
253
+ // so the nesting (field → block → props) reads at a glance.
254
+ return (_jsxs("div", { className: "overflow-hidden rounded-md border border-border/60", children: [_jsxs(Row, { gap: "2", align: "center", className: "border-b border-border/50 bg-muted/30 px-2 py-1.5", children: [_jsx(Text, { size: "xs", weight: "semibold", 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', { version: baseVersion }), onClick: onRestore })) : null] }), _jsxs("div", { className: "px-2 py-2", children: [block.props && block.props.length > 0 ? (_jsx(Stack, { gap: "3", className: "border-l-2 border-border/50 pl-3", 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
255
  }
149
- function PropRow({ prop }) {
256
+ function PropRow({ prop, rowId }) {
150
257
  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 }) }))] }));
258
+ 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
259
  }
153
260
  /** One side of a non-text change — the value, or an "empty" placeholder. */
154
- function SideValue({ value, other, typeHint }) {
261
+ function SideValue({ value, other, typeHint, id, }) {
155
262
  if (value === undefined || value === null || value === '') {
156
263
  return (_jsx(Text, { size: "sm", tone: "subtle", className: "italic", children: translate('cms.version.emptyValue') }));
157
264
  }
158
- return _jsx(ValueView, { value: value, other: other, typeHint: typeHint });
265
+ return _jsx(ValueView, { value: value, other: other, typeHint: typeHint, id: id });
159
266
  }
160
267
  /**
161
268
  * Read-only value display, dispatching on the field type where known and on the
162
269
  * self-describing value shape otherwise (block props carry no schema) — assets as
163
- * thumbnails, relations as titles, everything else as calm text.
270
+ * thumbnails, relations as titles, everything else as calm text (EC-317: the
271
+ * plain-text fallback truncates past the shared length threshold).
164
272
  */
165
- function ValueView({ value, other, typeHint }) {
273
+ function ValueView({ value, other, typeHint, id, }) {
274
+ // Called unconditionally up front (rules-of-hooks) even though only the plain-
275
+ // text fallback branch below needs it — the early returns above it don't hook.
276
+ const { isExpanded } = useDiffDisplay();
166
277
  if (typeHint === 'asset' || isAssetNode(value))
167
278
  return _jsx(AssetValue, { value: value });
168
279
  if (typeHint === 'relation' || readDocumentRef(value))
@@ -175,9 +286,13 @@ function ValueView({ value, other, typeHint }) {
175
286
  return (_jsx(Text, { size: "sm", mono: true, children: String(value).slice(0, 10) }));
176
287
  if (typeof value === 'object')
177
288
  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 })
289
+ ? translate(value.length === 1 ? 'cms.version.itemCount.one' : 'cms.version.itemCount.other', {
290
+ count: value.length,
291
+ })
179
292
  : translate('cms.version.objectValue') }));
180
- return (_jsx(Text, { size: "sm", className: "whitespace-pre-wrap break-words", children: String(value) }));
293
+ const expanded = isExpanded(id);
294
+ const { shown, isLong } = truncateText(String(value), expanded);
295
+ return (_jsx(ExpandToggle, { id: id, isLong: isLong, expanded: expanded, children: _jsx(Text, { size: "sm", className: "whitespace-pre-wrap break-words", children: shown }) }));
181
296
  }
182
297
  function RelationValue({ value, other }) {
183
298
  const source = useCollectionView();
@@ -196,13 +311,29 @@ function RelationValue({ value, other }) {
196
311
  .join(', ');
197
312
  return _jsx(Text, { size: "sm", children: label });
198
313
  }
314
+ /**
315
+ * Asset-record-owned keys — resolved off the asset RECORD at render (url, alt,
316
+ * dimensions, focal point, …), never authored on the referencing document. Mirrors
317
+ * the diff engine's own denylist (`document-diff.ts`): those keys never register as a
318
+ * change, so they are never worth SHOWING as one either. Only authored per-use
319
+ * sub-fields (EC-301, e.g. a placement's `caption`) surface beneath the thumbnail.
320
+ */
321
+ const ASSET_RECORD_KEYS = new Set([
322
+ 'url', 'alt', 'width', 'height', 'focalPoint', 'contentType', 'mimeType',
323
+ 'byteSize', 'storageKey', 'title', 'filename', 'meta', 'createdAt', 'updatedAt',
324
+ ]);
325
+ function authoredAssetSubfields(value) {
326
+ return Object.entries(assetSubfieldsOf(value)).filter(([key]) => !ASSET_RECORD_KEYS.has(key));
327
+ }
199
328
  function AssetValue({ value }) {
200
- const ids = (Array.isArray(value) ? value : [value])
201
- .map(readAssetId)
202
- .filter((id) => id !== undefined);
203
- if (ids.length === 0)
329
+ const items = (Array.isArray(value) ? value : [value])
330
+ .map((entry) => ({ id: readAssetId(entry), sub: authoredAssetSubfields(entry) }))
331
+ .filter((a) => a.id !== undefined);
332
+ if (items.length === 0)
204
333
  return (_jsx(Text, { size: "sm", className: "whitespace-pre-wrap break-words", children: String(value) }));
205
- return (_jsx("div", { className: "flex flex-wrap gap-2", children: ids.map((id) => (_jsx(AssetThumb, { id: id }, id))) }));
334
+ return (_jsx("div", { className: "flex flex-wrap gap-2", children: items.map(({ id, sub }, i) => (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(AssetThumb, { id: id }), sub.length > 0 ? (_jsx(Stack, { gap: "1", className: "pl-0.5", children: sub.map(([key, val]) => (_jsxs(Text, { size: "xs", tone: "muted", className: "break-words", children: [_jsxs("span", { className: "uppercase tracking-wide text-muted-foreground/70", children: [prettyName(key), ":"] }), ' ', typeof val === 'object' && val !== null
335
+ ? translate('cms.version.objectValue')
336
+ : String(val)] }, key))) })) : null] }, `${id}-${i}`))) }));
206
337
  }
207
338
  /** An asset as a thumbnail (image with a resolvable URL) or a labelled badge —
208
339
  * the visual diff for a cover-image change, not two identical filenames. */
@@ -219,16 +350,38 @@ function AssetThumb({ id }) {
219
350
  return (_jsxs(Badge, { variant: "outline", children: [_jsx(ImageIcon, { className: "size-3" }), " ", label] }));
220
351
  }
221
352
  // --- Prose word diff --------------------------------------------------------------
222
- /** Prose-diff across the central divider, both sides from one LCS. */
223
- function WordDiffCols({ before, after }) {
353
+ /**
354
+ * Prose diff across a row (EC-317): side-by-side reads as two word-diffed
355
+ * columns; unified merges into ONE reading lane (`mergeWordDiff`). Either way,
356
+ * a value past the length threshold collapses behind the row's expand control
357
+ * — the full `wordDiff`/`mergeWordDiff` computation underneath is untouched.
358
+ */
359
+ function WordDiffCols({ id, before, after }) {
360
+ const { mode, isExpanded } = useDiffDisplay();
361
+ const expanded = isExpanded(id);
224
362
  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" }) }));
363
+ const merged = useMemo(() => mergeWordDiff(wd.before, wd.after), [wd]);
364
+ if (mode === 'unified') {
365
+ const { shown, isLong } = truncateTokens(merged, expanded, DIFF_TRUNCATE_THRESHOLD);
366
+ return (_jsx(ExpandToggle, { id: id, isLong: isLong, expanded: expanded, children: _jsx(UnifiedWordTokens, { tokens: shown }) }));
367
+ }
368
+ const beforeSlice = truncateTokens(wd.before, expanded, DIFF_TRUNCATE_THRESHOLD);
369
+ const afterSlice = truncateTokens(wd.after, expanded, DIFF_TRUNCATE_THRESHOLD);
370
+ const isLong = beforeSlice.isLong || afterSlice.isLong;
371
+ 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
372
  }
227
373
  function WordTokens({ tokens, side }) {
228
374
  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
375
  ? 'bg-destructive/15 text-destructive line-through decoration-destructive/40'
230
376
  : 'bg-success/20 text-foreground'), children: token.text }, i)) : (_jsx("span", { className: "text-muted-foreground", children: token.text }, i))) }));
231
377
  }
378
+ /** The unified lane's merged token stream — same marks as `WordTokens`, one
379
+ * continuous reading order instead of two columns. */
380
+ function UnifiedWordTokens({ tokens }) {
381
+ 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'
382
+ ? 'bg-destructive/15 text-destructive line-through decoration-destructive/40'
383
+ : 'bg-success/20 text-foreground'), children: token.text }, i))) }));
384
+ }
232
385
  // --- Small shared bits ------------------------------------------------------------
233
386
  /** A quiet "· new" / "· removed" tag; nothing for a plain change (implicit). */
234
387
  function KindTag({ kind }) {