@getdom/studio 0.1.0

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 (213) hide show
  1. package/LICENSE.md +109 -0
  2. package/README.md +44 -0
  3. package/SKILL.md +83 -0
  4. package/package.json +73 -0
  5. package/public/llms.txt +57 -0
  6. package/public/skills.sh +11 -0
  7. package/src/pages/_layout/componentCatalog.generated.js +4514 -0
  8. package/src/pages/_layout/componentInspector.js +60 -0
  9. package/src/pages/_layout/componentManager.js +192 -0
  10. package/src/pages/_layout/componentRuntimeRecords.js +49 -0
  11. package/src/pages/_layout/inspector/componentRegistry.js +639 -0
  12. package/src/pages/_layout/inspector/serialize.js +134 -0
  13. package/src/pages/_layout/inspector/useInspector.js +293 -0
  14. package/src/pages/components/accordion/DomAccordion.vue +39 -0
  15. package/src/pages/components/breadcrumbs/DomBreadcrumbs.vue +347 -0
  16. package/src/pages/components/button/DomButton.vue +132 -0
  17. package/src/pages/components/command-palette/DomCommandPalette.vue +482 -0
  18. package/src/pages/components/context-menu/DomContextMenu.vue +178 -0
  19. package/src/pages/components/dialog/DomDialog.vue +271 -0
  20. package/src/pages/components/dialog/DomDialogSchemaForm.vue +72 -0
  21. package/src/pages/components/dialog/DomDialogStack.vue +289 -0
  22. package/src/pages/components/dialog/useDialogs.js +253 -0
  23. package/src/pages/components/drawer/DomDrawer.vue +159 -0
  24. package/src/pages/components/dropdown/DomDropdown.vue +261 -0
  25. package/src/pages/components/icon-button/DomIconButton.vue +155 -0
  26. package/src/pages/components/lightbox/DomLightbox.vue +1561 -0
  27. package/src/pages/components/menu/DomMenu.vue +359 -0
  28. package/src/pages/components/menu/DomMenuItem.vue +233 -0
  29. package/src/pages/components/menu/DomMenuLabel.vue +19 -0
  30. package/src/pages/components/menu/DomMenuSeparator.vue +14 -0
  31. package/src/pages/components/menu/menuContext.js +11 -0
  32. package/src/pages/components/menu/menuPlacement.js +26 -0
  33. package/src/pages/components/menu/submenu.js +58 -0
  34. package/src/pages/components/pagination/DomPagination.vue +265 -0
  35. package/src/pages/components/playground/DomRenderer.vue +48 -0
  36. package/src/pages/components/popover/DomPopover.vue +244 -0
  37. package/src/pages/components/profile-menu/DomProfileMenu.vue +408 -0
  38. package/src/pages/components/profile-menu/DomProfileMenuItemContent.vue +45 -0
  39. package/src/pages/components/scroll-menu/DomScrollMenu.vue +430 -0
  40. package/src/pages/components/scroll-menu/DomScrollMenuItem.vue +212 -0
  41. package/src/pages/components/scroll-menu/scrollMenuContext.js +22 -0
  42. package/src/pages/components/splitter-panel/DomSplitterPanel.vue +283 -0
  43. package/src/pages/components/stepper/DomStepper.vue +386 -0
  44. package/src/pages/components/tabs/DomTabs.vue +149 -0
  45. package/src/pages/components/theme-switcher/DomThemeSwitcher.vue +226 -0
  46. package/src/pages/components/toast/DomToastItem.vue +52 -0
  47. package/src/pages/components/toast/DomToastStack.vue +768 -0
  48. package/src/pages/components/toast/useToasts.js +237 -0
  49. package/src/pages/components/toolbar/DomToolbar.vue +158 -0
  50. package/src/pages/components/tooltip/DomTooltip.vue +69 -0
  51. package/src/pages/components/tooltip/vDomTooltip.js +106 -0
  52. package/src/pages/components/tree-view/DomTreeView.vue +961 -0
  53. package/src/pages/components/tree-view/treeSchemas.js +129 -0
  54. package/src/pages/components/tree-view/treeUtils.js +228 -0
  55. package/src/pages/data/_shared/calculateVirtualWindow.js +101 -0
  56. package/src/pages/data/_shared/resourceRecord.js +29 -0
  57. package/src/pages/data/activity-timeline/DomActivityTimeline.vue +417 -0
  58. package/src/pages/data/agent-chat/DomAgentChatShell.vue +565 -0
  59. package/src/pages/data/agent-chat/DomAgentConversation.vue +737 -0
  60. package/src/pages/data/agent-chat/DomAgentConversationList.vue +319 -0
  61. package/src/pages/data/agent-chat/examples/agentChatMockApi.js +620 -0
  62. package/src/pages/data/agent-chat/examples/useAgentChatDemoStore.js +730 -0
  63. package/src/pages/data/data-grid/DataGridVirtualRows.vue +220 -0
  64. package/src/pages/data/data-grid/DomDataGrid.vue +1643 -0
  65. package/src/pages/data/document-editor/DomDocumentEditor.vue +3030 -0
  66. package/src/pages/data/document-editor/documentEditorCodeBlock.js +282 -0
  67. package/src/pages/data/document-editor/examples/documentEditorMockApi.js +272 -0
  68. package/src/pages/data/document-editor/examples/useDocumentEditorDemoStore.js +284 -0
  69. package/src/pages/data/filter-bar/DomFilterBar.vue +533 -0
  70. package/src/pages/data/kanban/DomKanban.vue +1703 -0
  71. package/src/pages/data/kanban/kanbanApi.js +80 -0
  72. package/src/pages/data/media-browser/DomMediaBrowser.vue +2344 -0
  73. package/src/pages/data/media-browser/examples/mediaBrowserMockApi.js +653 -0
  74. package/src/pages/data/media-browser/examples/useMediaBrowserDemoStore.js +1157 -0
  75. package/src/pages/data/month-calendar/DomMonthCalendar.vue +1718 -0
  76. package/src/pages/data/week-planner/DomWeekPlanner.vue +2308 -0
  77. package/src/pages/dev/code-block/DomCodeBlock.vue +263 -0
  78. package/src/pages/dev/json-viewer/DomJsonViewer.vue +962 -0
  79. package/src/pages/forms/_shared/DomFieldLoadingSpinner.vue +12 -0
  80. package/src/pages/forms/_shared/options.js +61 -0
  81. package/src/pages/forms/_shared/useOptionMenu.js +59 -0
  82. package/src/pages/forms/autocomplete/DomAutocomplete.vue +180 -0
  83. package/src/pages/forms/calendar/DomCalendar.vue +442 -0
  84. package/src/pages/forms/checkbox/DomCheckbox.vue +69 -0
  85. package/src/pages/forms/class-toggle-input/DomClassToggleInput.vue +316 -0
  86. package/src/pages/forms/code-input/DomCodeInput.vue +202 -0
  87. package/src/pages/forms/color-input/DomColorInput.vue +118 -0
  88. package/src/pages/forms/combobox/DomCombobox.vue +237 -0
  89. package/src/pages/forms/confirmation-code-input/DomConfirmationCodeInput.vue +345 -0
  90. package/src/pages/forms/date-picker/DomDatePicker.vue +299 -0
  91. package/src/pages/forms/date-time-picker/DomDateTimePicker.vue +630 -0
  92. package/src/pages/forms/email-input/DomEmailInput.vue +55 -0
  93. package/src/pages/forms/field/DomField.vue +267 -0
  94. package/src/pages/forms/field/fieldLayout.js +1 -0
  95. package/src/pages/forms/field/fieldProps.js +114 -0
  96. package/src/pages/forms/field/layouts/DomGridFieldLayout.vue +38 -0
  97. package/src/pages/forms/field/useField.js +343 -0
  98. package/src/pages/forms/field/validators.js +364 -0
  99. package/src/pages/forms/file-upload/DomFileUpload.vue +361 -0
  100. package/src/pages/forms/form/DomForm.vue +985 -0
  101. package/src/pages/forms/form/adapters/fieldBuilder.js +45 -0
  102. package/src/pages/forms/form/adapters/jsonSchema.js +264 -0
  103. package/src/pages/forms/form/adapters/shared.js +53 -0
  104. package/src/pages/forms/form/adapters/zod.js +235 -0
  105. package/src/pages/forms/form/formApi.js +38 -0
  106. package/src/pages/forms/form/formDefinition.js +392 -0
  107. package/src/pages/forms/form/schemaAdapter.js +54 -0
  108. package/src/pages/forms/icon-selector/DomIconSelector.vue +460 -0
  109. package/src/pages/forms/icon-selector/iconSelectorItems.js +490 -0
  110. package/src/pages/forms/input-group/DomInputGroup.vue +232 -0
  111. package/src/pages/forms/json-input/DomJsonInput.vue +227 -0
  112. package/src/pages/forms/json-list-input/DomJsonListInput.vue +206 -0
  113. package/src/pages/forms/listbox/DomListbox.vue +105 -0
  114. package/src/pages/forms/money-input/DomMoneyInput.vue +147 -0
  115. package/src/pages/forms/native-select/DomNativeSelect.vue +67 -0
  116. package/src/pages/forms/number-input/DomNumberInput.vue +47 -0
  117. package/src/pages/forms/password-input/DomPasswordInput.vue +218 -0
  118. package/src/pages/forms/password-input/pwnedPasswords.js +47 -0
  119. package/src/pages/forms/password-strength/DomPasswordStrength.vue +128 -0
  120. package/src/pages/forms/password-strength/passwordStrength.js +92 -0
  121. package/src/pages/forms/position-input/DomPositionInput.vue +83 -0
  122. package/src/pages/forms/radio-group/DomRadioGroup.vue +102 -0
  123. package/src/pages/forms/range-input/DomRangeInput.vue +296 -0
  124. package/src/pages/forms/select/DomSelect.vue +479 -0
  125. package/src/pages/forms/select-input/DomSelectInput.vue +155 -0
  126. package/src/pages/forms/tag-combobox/DomTagCombobox.vue +358 -0
  127. package/src/pages/forms/text-input/DomTextInput.vue +48 -0
  128. package/src/pages/forms/textarea-composer/DomTextareaComposer.vue +234 -0
  129. package/src/pages/forms/textarea-input/DomTextareaInput.vue +86 -0
  130. package/src/pages/forms/textarea-input/useTextareaAutosize.js +147 -0
  131. package/src/pages/forms/time-selector/DomTimeSelector.vue +389 -0
  132. package/src/pages/forms/time-selector/timeUtils.js +194 -0
  133. package/src/pages/forms/toggle/DomToggle.vue +158 -0
  134. package/src/pages/forms/toggle-button/DomToggleButton.vue +88 -0
  135. package/src/pages/forms/toggle-button-group/DomToggleButtonGroup.vue +340 -0
  136. package/src/pages/forms/url-input/DomUrlInput.vue +55 -0
  137. package/src/pages/forms/wysiwyg/DomWysiwyg.vue +204 -0
  138. package/src/pages/lib/css/color.js +245 -0
  139. package/src/pages/lib/headless/accordion.d.ts +7 -0
  140. package/src/pages/lib/headless/accordion.js +246 -0
  141. package/src/pages/lib/headless/autocomplete.d.ts +4 -0
  142. package/src/pages/lib/headless/autocomplete.js +33 -0
  143. package/src/pages/lib/headless/base.d.ts +25 -0
  144. package/src/pages/lib/headless/base.js +127 -0
  145. package/src/pages/lib/headless/checkbox.d.ts +19 -0
  146. package/src/pages/lib/headless/checkbox.js +99 -0
  147. package/src/pages/lib/headless/combobox.d.ts +7 -0
  148. package/src/pages/lib/headless/combobox.js +488 -0
  149. package/src/pages/lib/headless/dialog.d.ts +23 -0
  150. package/src/pages/lib/headless/dialog.js +384 -0
  151. package/src/pages/lib/headless/drawer.d.ts +21 -0
  152. package/src/pages/lib/headless/drawer.js +682 -0
  153. package/src/pages/lib/headless/dropdown.d.ts +19 -0
  154. package/src/pages/lib/headless/dropdown.js +412 -0
  155. package/src/pages/lib/headless/floating-effect.js +118 -0
  156. package/src/pages/lib/headless/floating.d.ts +34 -0
  157. package/src/pages/lib/headless/floating.js +382 -0
  158. package/src/pages/lib/headless/index.d.ts +19 -0
  159. package/src/pages/lib/headless/index.js +19 -0
  160. package/src/pages/lib/headless/listbox.d.ts +19 -0
  161. package/src/pages/lib/headless/listbox.js +126 -0
  162. package/src/pages/lib/headless/menu.d.ts +18 -0
  163. package/src/pages/lib/headless/menu.js +117 -0
  164. package/src/pages/lib/headless/popover-panel.d.ts +18 -0
  165. package/src/pages/lib/headless/popover-panel.js +309 -0
  166. package/src/pages/lib/headless/popover.d.ts +19 -0
  167. package/src/pages/lib/headless/popover.js +391 -0
  168. package/src/pages/lib/headless/radio-group.d.ts +19 -0
  169. package/src/pages/lib/headless/radio-group.js +100 -0
  170. package/src/pages/lib/headless/tabs.d.ts +18 -0
  171. package/src/pages/lib/headless/tabs.js +119 -0
  172. package/src/pages/lib/headless/toast.d.ts +13 -0
  173. package/src/pages/lib/headless/toast.js +65 -0
  174. package/src/pages/lib/headless/toggle.d.ts +19 -0
  175. package/src/pages/lib/headless/toggle.js +162 -0
  176. package/src/pages/lib/headless/tooltip-controller.d.ts +22 -0
  177. package/src/pages/lib/headless/tooltip-controller.js +549 -0
  178. package/src/pages/lib/headless/tooltip.d.ts +19 -0
  179. package/src/pages/lib/headless/tooltip.js +153 -0
  180. package/src/pages/lib/headless/transitions.d.ts +5 -0
  181. package/src/pages/lib/headless/transitions.js +89 -0
  182. package/src/pages/lib/vue/_internal/DomLiquidGlassSurface.vue +310 -0
  183. package/src/pages/lib/vue/_internal/liquidGlass.js +112 -0
  184. package/src/pages/lib/vue/_internal/liquidGlassConfig.js +10 -0
  185. package/src/pages/lib/vue/codeHighlighter.js +132 -0
  186. package/src/pages/lib/vue/floatingTeleportTarget.js +23 -0
  187. package/src/pages/lib/vue/index.d.ts +6806 -0
  188. package/src/pages/lib/vue/index.js +218 -0
  189. package/src/pages/lib/vue/useTheme.js +116 -0
  190. package/src/pages/mobile/action-sheet/DomActionSheet.vue +186 -0
  191. package/src/pages/mobile/bottom-nav/DomAppBottomNav.vue +94 -0
  192. package/src/pages/mobile/list-item/DomAppListItem.vue +90 -0
  193. package/src/pages/mobile/safe-area/DomSafeArea.vue +39 -0
  194. package/src/pages/mobile/shell/DomAppShell.vue +51 -0
  195. package/src/pages/mobile/stack/AppStackScreen.vue +63 -0
  196. package/src/pages/mobile/stack/DomAppStack.vue +711 -0
  197. package/src/pages/mobile/top-bar/DomAppTopBar.vue +67 -0
  198. package/src/pages/visual/alert/DomAlert.vue +363 -0
  199. package/src/pages/visual/avatar/DomAvatar.vue +110 -0
  200. package/src/pages/visual/avatar-stack/DomAvatarStack.vue +89 -0
  201. package/src/pages/visual/badge/DomBadge.vue +105 -0
  202. package/src/pages/visual/card/DomCard.vue +65 -0
  203. package/src/pages/visual/card-rail/DomCardRail.vue +99 -0
  204. package/src/pages/visual/empty-state/DomEmptyState.vue +88 -0
  205. package/src/pages/visual/progress/DomProgress.vue +212 -0
  206. package/src/pages/visual/progress/DomRadialProgress.vue +186 -0
  207. package/src/pages/visual/progress/DomSegmentedProgress.vue +350 -0
  208. package/src/pages/visual/skeleton/DomSkeleton.vue +174 -0
  209. package/src/pages/visual/spinner/DomSpinner.vue +122 -0
  210. package/src/pages/visual/status-pill/DomStatusPill.vue +75 -0
  211. package/src/pages/visual/text-diff/DomTextDiff.vue +577 -0
  212. package/src/pages/visual/text-diff/textDiff.js +300 -0
  213. package/src/style.css +446 -0
@@ -0,0 +1,577 @@
1
+ <script setup>
2
+ import { computed, ref, watch } from 'vue';
3
+ import { createInlineDiff, createSplitDiff, createUnifiedDiff } from './textDiff.js';
4
+
5
+ defineOptions({
6
+ __doc: {
7
+ name: 'Text diff',
8
+ tag: '<DomTextDiff>',
9
+ description: 'A standalone text comparison for AI rewrites, code patches, editorial review, and before-and-after copy changes.',
10
+ icon: 'M8 7H4m4 0L6 5m2 2L6 9m10 8h4m-2-2v4M11 5h3a3 3 0 0 1 3 3v1m-6 10H8a3 3 0 0 1-3-3v-1',
11
+ playground: {
12
+ initial: {
13
+ original: 'Write better pages with a flexible visual editor.',
14
+ proposed: 'Create clearer pages with an editor built for focused teams.',
15
+ mode: 'diff',
16
+ view: 'inline',
17
+ format: 'text',
18
+ originalLabel: 'Current',
19
+ proposedLabel: 'Suggested',
20
+ showHeader: true,
21
+ showModeSwitcher: true,
22
+ framed: true,
23
+ },
24
+ },
25
+ slots: [
26
+ { name: 'header', payload: '{ additions, removals, mode, setMode, originalLabel, proposedLabel }', description: 'Replace the comparison header and optionally control the active content mode.' },
27
+ ],
28
+ events: [
29
+ { name: 'update:mode', payload: "'diff' | 'original' | 'replacement'", description: 'Fired when the active content mode changes.' },
30
+ { name: 'mode-change', payload: "'diff' | 'original' | 'replacement'", description: 'Fired after the viewer switches between the diff, current text, and replacement text.' },
31
+ ],
32
+ },
33
+ });
34
+
35
+ const props = defineProps({
36
+ original: {
37
+ type: String,
38
+ default: '',
39
+ _edit: { description: 'Current or original text.' },
40
+ },
41
+ proposed: {
42
+ type: String,
43
+ default: '',
44
+ _edit: { description: 'Suggested replacement text.' },
45
+ },
46
+ mode: {
47
+ type: String,
48
+ default: 'diff',
49
+ _edit: { options: ['diff', 'original', 'replacement'], description: 'Show the complete diff, current text, or finished replacement.' },
50
+ },
51
+ view: {
52
+ type: String,
53
+ default: 'inline',
54
+ _edit: { options: ['inline', 'unified', 'split'], description: 'Inline prose, unified patch, or side-by-side comparison.' },
55
+ },
56
+ format: {
57
+ type: String,
58
+ default: 'text',
59
+ _edit: { options: ['text', 'code'], description: 'Use proportional text or code-oriented monospace styling.' },
60
+ },
61
+ originalLabel: {
62
+ type: String,
63
+ default: 'Original',
64
+ _edit: { description: 'Label for the original content.' },
65
+ },
66
+ proposedLabel: {
67
+ type: String,
68
+ default: 'Suggested',
69
+ _edit: { description: 'Label for the proposed content.' },
70
+ },
71
+ showHeader: {
72
+ type: Boolean,
73
+ default: true,
74
+ _edit: { description: 'Show comparison labels and change counts.' },
75
+ },
76
+ showModeSwitcher: {
77
+ type: Boolean,
78
+ default: true,
79
+ _edit: { description: 'Show the Diff, Current, and Replacement controls inside the standard header.' },
80
+ },
81
+ framed: {
82
+ type: Boolean,
83
+ default: true,
84
+ _edit: { description: 'Render the standard border, radius, and canvas background.' },
85
+ },
86
+ });
87
+
88
+ const emit = defineEmits(['update:mode', 'mode-change']);
89
+ const modeOptions = [
90
+ { value: 'diff', label: 'Diff' },
91
+ { value: 'original', label: 'Current' },
92
+ { value: 'replacement', label: 'Replacement' },
93
+ ];
94
+ const activeMode = ref(normalizeMode(props.mode));
95
+
96
+ const resolvedView = computed(resolveView);
97
+ const resolvedFormat = computed(resolveFormat);
98
+ const inlineSegments = computed(resolveInlineSegments);
99
+ const unifiedRows = computed(resolveUnifiedRows);
100
+ const splitRows = computed(resolveSplitRows);
101
+ const changeStats = computed(calculateChangeStats);
102
+ const figureLabel = computed(resolveFigureLabel);
103
+ const completeText = computed(resolveCompleteText);
104
+ const completeTextLabel = computed(resolveCompleteTextLabel);
105
+
106
+ watch(readModeProp, syncModeFromProp);
107
+
108
+ /**
109
+ * Checks whether a content mode is supported.
110
+ *
111
+ * @param {string} value Candidate content mode.
112
+ * @returns {boolean} Whether the mode is supported.
113
+ */
114
+ function isSupportedMode(value) {
115
+ for (const option of modeOptions) {
116
+ if (option.value === value) return true;
117
+ }
118
+ return false;
119
+ }
120
+
121
+ /**
122
+ * Normalizes a content mode to the complete diff view.
123
+ *
124
+ * @param {string} value Candidate content mode.
125
+ * @returns {'diff'|'original'|'replacement'} Supported content mode.
126
+ */
127
+ function normalizeMode(value) {
128
+ return isSupportedMode(value) ? value : 'diff';
129
+ }
130
+
131
+ /**
132
+ * Reads the externally supplied mode for the prop watcher.
133
+ *
134
+ * @returns {string} Current mode prop.
135
+ */
136
+ function readModeProp() {
137
+ return props.mode;
138
+ }
139
+
140
+ /**
141
+ * Synchronizes externally controlled mode changes with the local viewer.
142
+ *
143
+ * @param {string} value Updated mode prop.
144
+ * @returns {void}
145
+ */
146
+ function syncModeFromProp(value) {
147
+ activeMode.value = normalizeMode(value);
148
+ }
149
+
150
+ /**
151
+ * Switches the visible content and notifies controlled consumers.
152
+ *
153
+ * @param {'diff'|'original'|'replacement'} value Requested content mode.
154
+ * @returns {void}
155
+ */
156
+ function setMode(value) {
157
+ const nextMode = normalizeMode(value);
158
+ if (nextMode === activeMode.value) return;
159
+ activeMode.value = nextMode;
160
+ emit('update:mode', nextMode);
161
+ emit('mode-change', nextMode);
162
+ }
163
+
164
+ /**
165
+ * Checks whether a diff view is supported.
166
+ *
167
+ * @param {string} value Candidate view.
168
+ * @returns {boolean} Whether the view is supported.
169
+ */
170
+ function isSupportedView(value) {
171
+ return ['inline', 'unified', 'split'].includes(value);
172
+ }
173
+
174
+ /**
175
+ * Checks whether a text format is supported.
176
+ *
177
+ * @param {string} value Candidate format.
178
+ * @returns {boolean} Whether the format is supported.
179
+ */
180
+ function isSupportedFormat(value) {
181
+ return ['text', 'code'].includes(value);
182
+ }
183
+
184
+ /**
185
+ * Resolves unknown view values to the inline presentation.
186
+ *
187
+ * @returns {'inline'|'unified'|'split'} Supported view.
188
+ */
189
+ function resolveView() {
190
+ return isSupportedView(props.view) ? props.view : 'inline';
191
+ }
192
+
193
+ /**
194
+ * Resolves unknown format values to proportional text.
195
+ *
196
+ * @returns {'text'|'code'} Supported format.
197
+ */
198
+ function resolveFormat() {
199
+ return isSupportedFormat(props.format) ? props.format : 'text';
200
+ }
201
+
202
+ /**
203
+ * Creates inline segments for the current component values.
204
+ *
205
+ * @returns {Array<Record<string, unknown>>} Inline diff segments.
206
+ */
207
+ function resolveInlineSegments() {
208
+ return createInlineDiff(props.original, props.proposed);
209
+ }
210
+
211
+ /**
212
+ * Creates unified rows for the current component values.
213
+ *
214
+ * @returns {Array<Record<string, unknown>>} Unified diff rows.
215
+ */
216
+ function resolveUnifiedRows() {
217
+ return createUnifiedDiff(props.original, props.proposed);
218
+ }
219
+
220
+ /**
221
+ * Creates split rows for the current component values.
222
+ *
223
+ * @returns {Array<Record<string, unknown>>} Split diff rows.
224
+ */
225
+ function resolveSplitRows() {
226
+ return createSplitDiff(props.original, props.proposed);
227
+ }
228
+
229
+ /**
230
+ * Describes the currently visible comparison or complete text.
231
+ *
232
+ * @returns {string} Accessible figure label.
233
+ */
234
+ function resolveFigureLabel() {
235
+ if (activeMode.value === 'original') return `${props.originalLabel} text`;
236
+ if (activeMode.value === 'replacement') return `${props.proposedLabel} replacement text`;
237
+ return `${props.originalLabel} compared with ${props.proposedLabel}`;
238
+ }
239
+
240
+ /**
241
+ * Returns the complete text for the active non-diff mode.
242
+ *
243
+ * @returns {string} Original or proposed text.
244
+ */
245
+ function resolveCompleteText() {
246
+ return activeMode.value === 'original' ? props.original : props.proposed;
247
+ }
248
+
249
+ /**
250
+ * Returns the accessible label for the active complete text.
251
+ *
252
+ * @returns {string} Complete text label.
253
+ */
254
+ function resolveCompleteTextLabel() {
255
+ return activeMode.value === 'original' ? props.originalLabel : props.proposedLabel;
256
+ }
257
+
258
+ /**
259
+ * Calculates visible addition and removal counts for the active view.
260
+ *
261
+ * @returns {{ additions: number, removals: number }} Change counts.
262
+ */
263
+ function calculateChangeStats() {
264
+ const operations = resolvedView.value === 'inline' ? inlineSegments.value : unifiedRows.value;
265
+ return {
266
+ additions: operations.filter(isAddedOperation).length,
267
+ removals: operations.filter(isRemovedOperation).length,
268
+ };
269
+ }
270
+
271
+ /**
272
+ * Checks whether an operation represents added content.
273
+ *
274
+ * @param {Record<string, unknown>} operation Diff operation.
275
+ * @returns {boolean} Whether content was added.
276
+ */
277
+ function isAddedOperation(operation) {
278
+ return operation.type === 'added';
279
+ }
280
+
281
+ /**
282
+ * Checks whether an operation represents removed content.
283
+ *
284
+ * @param {Record<string, unknown>} operation Diff operation.
285
+ * @returns {boolean} Whether content was removed.
286
+ */
287
+ function isRemovedOperation(operation) {
288
+ return operation.type === 'removed';
289
+ }
290
+
291
+ /**
292
+ * Returns the visible sign for a unified diff row.
293
+ *
294
+ * @param {Record<string, unknown>} row Diff row.
295
+ * @returns {string} Addition, removal, or unchanged sign.
296
+ */
297
+ function rowSign(row) {
298
+ if (row.type === 'added') return '+';
299
+ if (row.type === 'removed') return '−';
300
+ return '';
301
+ }
302
+
303
+ /**
304
+ * Returns the visible line number pair for a unified row.
305
+ *
306
+ * @param {Record<string, unknown>} row Diff row.
307
+ * @returns {string} Original and proposed line numbers.
308
+ */
309
+ function rowNumbers(row) {
310
+ return `${row.originalNumber ?? ''} ${row.proposedNumber ?? ''}`.trim();
311
+ }
312
+
313
+ /**
314
+ * Returns the semantic background utility for a diff line.
315
+ *
316
+ * @param {string} type Diff line state.
317
+ * @returns {string} Tailwind background utility.
318
+ */
319
+ function lineBackgroundClass(type) {
320
+ if (type === 'removed') return 'bg-destructive/10';
321
+ if (type === 'added') return 'bg-success/10';
322
+ if (type === 'blank') return 'bg-secondary/35';
323
+ return '';
324
+ }
325
+
326
+ /**
327
+ * Returns the semantic text utility for a diff sign.
328
+ *
329
+ * @param {string} type Diff line state.
330
+ * @returns {string} Tailwind text utility.
331
+ */
332
+ function lineSignClass(type) {
333
+ if (type === 'removed') return 'text-destructive';
334
+ if (type === 'added') return 'text-success';
335
+ return 'text-muted-fg';
336
+ }
337
+
338
+ /**
339
+ * Returns the semantic highlight utility for changed text.
340
+ *
341
+ * @param {string} type Diff line state.
342
+ * @returns {string} Tailwind background utility.
343
+ */
344
+ function markBackgroundClass(type) {
345
+ return type === 'removed' ? 'bg-destructive/20' : 'bg-success/25';
346
+ }
347
+ </script>
348
+
349
+ <template>
350
+ <figure
351
+ class="w-full min-w-0 overflow-hidden bg-canvas text-sm leading-[1.65] text-canvas-fg [container-type:inline-size]"
352
+ :class="framed ? 'rounded-xl border border-border' : ''"
353
+ :aria-label="figureLabel"
354
+ >
355
+ <header
356
+ v-if="showHeader"
357
+ data-diff-header
358
+ class="grid min-h-[2.65rem] grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-4 border-b border-border bg-secondary/45 px-3 py-[0.55rem]"
359
+ :class="framed ? '' : 'rounded-t-lg border border-border'"
360
+ >
361
+ <slot
362
+ name="header"
363
+ :additions="changeStats.additions"
364
+ :removals="changeStats.removals"
365
+ :mode="activeMode"
366
+ :set-mode="setMode"
367
+ :original-label="originalLabel"
368
+ :proposed-label="proposedLabel"
369
+ >
370
+ <div class="flex min-w-0 items-center gap-2 text-xs font-[650]">
371
+ <span>{{ originalLabel }}</span>
372
+ <svg
373
+ v-if="resolvedView !== 'split'"
374
+ class="size-4 shrink-0 text-muted-fg"
375
+ viewBox="0 0 20 20"
376
+ fill="none"
377
+ stroke="currentColor"
378
+ stroke-linecap="round"
379
+ stroke-linejoin="round"
380
+ stroke-width="1.5"
381
+ aria-hidden="true"
382
+ >
383
+ <path d="M4 10h12m-4-4 4 4-4 4" />
384
+ </svg>
385
+ <span>{{ proposedLabel }}</span>
386
+ </div>
387
+ <div
388
+ v-if="showModeSwitcher"
389
+ data-mode-switcher
390
+ class="col-start-3 row-start-1 flex items-center rounded-lg border border-border bg-secondary/70 p-0.5"
391
+ role="group"
392
+ aria-label="Displayed text"
393
+ >
394
+ <button
395
+ v-for="option in modeOptions"
396
+ :key="option.value"
397
+ type="button"
398
+ class="cursor-pointer rounded-md border-0 px-2 py-1.5 font-[inherit] text-[0.6875rem] font-[650] leading-none transition-[background-color,color,box-shadow] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring motion-reduce:transition-none"
399
+ :class="activeMode === option.value
400
+ ? 'bg-canvas text-canvas-fg shadow-sm'
401
+ : 'bg-transparent text-muted-fg hover:text-canvas-fg'"
402
+ :aria-pressed="activeMode === option.value"
403
+ @click="setMode(option.value)"
404
+ >
405
+ {{ option.label }}
406
+ </button>
407
+ </div>
408
+ <Transition
409
+ enter-active-class="transition-opacity duration-150 ease-out motion-reduce:transition-none"
410
+ enter-from-class="opacity-0"
411
+ enter-to-class="opacity-100"
412
+ leave-active-class="transition-opacity duration-100 ease-in motion-reduce:transition-none"
413
+ leave-from-class="opacity-100"
414
+ leave-to-class="opacity-0"
415
+ >
416
+ <div
417
+ v-if="activeMode === 'diff'"
418
+ class="col-start-2 row-start-1 flex items-center gap-2"
419
+ aria-label="Change summary"
420
+ >
421
+ <span class="rounded-md bg-destructive/10 px-1.5 py-1 font-mono text-[0.6875rem] font-bold leading-none text-destructive">−{{ changeStats.removals }}</span>
422
+ <span class="rounded-md bg-success/15 px-1.5 py-1 font-mono text-[0.6875rem] font-bold leading-none text-success">+{{ changeStats.additions }}</span>
423
+ </div>
424
+ </Transition>
425
+ </slot>
426
+ </header>
427
+
428
+ <div class="grid min-w-0">
429
+ <Transition
430
+ enter-active-class="transition-opacity duration-150 ease-out motion-reduce:transition-none"
431
+ enter-from-class="opacity-0"
432
+ enter-to-class="opacity-100"
433
+ leave-active-class="transition-opacity duration-100 ease-in motion-reduce:transition-none"
434
+ leave-from-class="opacity-100"
435
+ leave-to-class="opacity-0"
436
+ >
437
+ <div
438
+ v-if="activeMode !== 'diff'"
439
+ :key="activeMode"
440
+ class="col-start-1 row-start-1 min-h-14 overflow-auto whitespace-pre-wrap break-words bg-secondary/20 px-4 py-3.5"
441
+ :aria-label="completeTextLabel"
442
+ >
443
+ <pre v-if="resolvedFormat === 'code'" class="m-0 min-w-max whitespace-pre font-mono text-[0.8125rem] leading-[1.65]"><code>{{ completeText }}</code></pre>
444
+ <div v-else role="document">{{ completeText }}</div>
445
+ </div>
446
+
447
+ <div
448
+ v-else-if="resolvedView === 'inline'"
449
+ key="diff-inline"
450
+ class="col-start-1 row-start-1 min-h-14 whitespace-pre-wrap break-words bg-secondary/20 px-4 py-3.5"
451
+ :class="resolvedFormat === 'code' ? 'font-mono text-[0.8125rem]' : ''"
452
+ >
453
+ <template v-for="segment in inlineSegments" :key="segment.id">
454
+ <ins
455
+ v-if="segment.type === 'added'"
456
+ class="rounded-[0.2rem] bg-success/15 px-[0.14em] py-[0.08em] text-success no-underline [box-decoration-break:clone] [-webkit-box-decoration-break:clone]"
457
+ >{{ segment.value }}</ins>
458
+ <del
459
+ v-else-if="segment.type === 'removed'"
460
+ class="rounded-[0.2rem] bg-destructive/15 px-[0.14em] py-[0.08em] text-destructive decoration-[1px] [box-decoration-break:clone] [-webkit-box-decoration-break:clone]"
461
+ >{{ segment.value }}</del>
462
+ <span v-else>{{ segment.value }}</span>
463
+ </template>
464
+ </div>
465
+
466
+ <div
467
+ v-else-if="resolvedView === 'unified'"
468
+ key="diff-unified"
469
+ class="col-start-1 row-start-1 min-w-0 overflow-auto"
470
+ >
471
+ <div
472
+ class="w-max min-w-full text-xs leading-[1.65]"
473
+ :class="resolvedFormat === 'code' ? 'font-mono' : ''"
474
+ role="table"
475
+ aria-label="Unified text changes"
476
+ >
477
+ <div
478
+ v-for="row in unifiedRows"
479
+ :key="row.id"
480
+ class="grid min-h-7 grid-cols-[1.75rem_3.75rem_minmax(20rem,1fr)] border-t border-border/50 first:border-t-0"
481
+ :class="lineBackgroundClass(row.type)"
482
+ role="row"
483
+ >
484
+ <span
485
+ class="select-none px-[0.45rem] py-[0.14rem] text-center font-extrabold tabular-nums"
486
+ :class="lineSignClass(row.type)"
487
+ aria-hidden="true"
488
+ >{{ rowSign(row) }}</span>
489
+ <span
490
+ class="select-none px-[0.45rem] py-[0.14rem] text-right text-muted-fg tabular-nums"
491
+ :aria-label="`Lines ${rowNumbers(row)}`"
492
+ >{{ rowNumbers(row) }}</span>
493
+ <code class="block whitespace-pre border-l border-border/60 px-3 py-[0.14rem]" role="cell">
494
+ <template v-for="segment in row.segments" :key="segment.id">
495
+ <mark
496
+ v-if="segment.type === row.type && row.type !== 'same'"
497
+ class="rounded-[0.18rem] px-[0.1em] py-[0.05em] font-[inherit] text-inherit"
498
+ :class="markBackgroundClass(row.type)"
499
+ >{{ segment.value }}</mark>
500
+ <span v-else>{{ segment.value }}</span>
501
+ </template>
502
+ </code>
503
+ </div>
504
+ </div>
505
+ </div>
506
+
507
+ <div v-else key="diff-split" class="col-start-1 row-start-1 min-w-0 overflow-auto">
508
+ <div
509
+ class="w-max min-w-max text-xs leading-[1.65]"
510
+ :class="resolvedFormat === 'code' ? 'font-mono' : ''"
511
+ role="table"
512
+ aria-label="Side-by-side text changes"
513
+ >
514
+ <div
515
+ v-for="row in splitRows"
516
+ :key="row.id"
517
+ class="grid grid-cols-[minmax(20rem,1fr)_minmax(20rem,1fr)] border-t border-border/50 first:border-t-0"
518
+ role="row"
519
+ >
520
+ <div
521
+ class="grid min-h-7 grid-cols-[2.5rem_minmax(17.5rem,1fr)]"
522
+ :class="lineBackgroundClass(row.original?.type || 'blank')"
523
+ role="cell"
524
+ >
525
+ <span class="select-none px-[0.45rem] py-[0.14rem] text-right text-muted-fg tabular-nums">{{ row.original?.originalNumber || '' }}</span>
526
+ <code class="block whitespace-pre border-l border-border/60 px-2.5 py-[0.14rem]">
527
+ <template v-for="segment in row.original?.segments || []" :key="segment.id">
528
+ <mark
529
+ v-if="segment.type === 'removed'"
530
+ class="rounded-[0.18rem] bg-destructive/20 px-[0.1em] py-[0.05em] font-[inherit] text-inherit"
531
+ >{{ segment.value }}</mark>
532
+ <span v-else>{{ segment.value }}</span>
533
+ </template>
534
+ </code>
535
+ </div>
536
+ <div
537
+ class="grid min-h-7 grid-cols-[2.5rem_minmax(17.5rem,1fr)] border-l border-border"
538
+ :class="lineBackgroundClass(row.proposed?.type || 'blank')"
539
+ role="cell"
540
+ >
541
+ <span class="select-none px-[0.45rem] py-[0.14rem] text-right text-muted-fg tabular-nums">{{ row.proposed?.proposedNumber || '' }}</span>
542
+ <code class="block whitespace-pre border-l border-border/60 px-2.5 py-[0.14rem]">
543
+ <template v-for="segment in row.proposed?.segments || []" :key="segment.id">
544
+ <mark
545
+ v-if="segment.type === 'added'"
546
+ class="rounded-[0.18rem] bg-success/25 px-[0.1em] py-[0.05em] font-[inherit] text-inherit"
547
+ >{{ segment.value }}</mark>
548
+ <span v-else>{{ segment.value }}</span>
549
+ </template>
550
+ </code>
551
+ </div>
552
+ </div>
553
+ </div>
554
+ </div>
555
+ </Transition>
556
+ </div>
557
+ </figure>
558
+ </template>
559
+
560
+ <style scoped>
561
+ @container (max-width: 32rem) {
562
+ [data-diff-header] {
563
+ gap: 0.55rem;
564
+ grid-template-columns: minmax(0, 1fr) auto;
565
+ }
566
+
567
+ [data-mode-switcher] {
568
+ grid-column: 1 / -1;
569
+ grid-row: 2;
570
+ width: 100%;
571
+ }
572
+
573
+ [data-mode-switcher] button {
574
+ flex: 1;
575
+ }
576
+ }
577
+ </style>