@joewinke/jatui 0.1.10 → 0.1.19

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 (91) hide show
  1. package/README.md +123 -0
  2. package/package.json +3 -1
  3. package/src/lib/actions/railNav.ts +473 -0
  4. package/src/lib/components/AnnotationLayer.svelte +108 -0
  5. package/src/lib/components/AnnotationPanel.svelte +319 -0
  6. package/src/lib/components/AudioWaveform.svelte +9 -5
  7. package/src/lib/components/AvailabilityModal.svelte +7 -3
  8. package/src/lib/components/AvatarUpload.svelte +27 -4
  9. package/src/lib/components/BookingForm.svelte +11 -9
  10. package/src/lib/components/BurndownChart.svelte +778 -0
  11. package/src/lib/components/Button.svelte +10 -1
  12. package/src/lib/components/CalendarPicker.svelte +3 -3
  13. package/src/lib/components/Card.svelte +2 -2
  14. package/src/lib/components/ChipInput.svelte +21 -15
  15. package/src/lib/components/ColorSelector.svelte +17 -13
  16. package/src/lib/components/CommentThread.svelte +773 -0
  17. package/src/lib/components/ConfirmDialog.svelte +348 -0
  18. package/src/lib/components/ConfirmModal.svelte +78 -11
  19. package/src/lib/components/ContextMenu.svelte +188 -0
  20. package/src/lib/components/CountdownTimer.svelte +1 -1
  21. package/src/lib/components/DateRangePicker.svelte +6 -4
  22. package/src/lib/components/Drawer.svelte +36 -3
  23. package/src/lib/components/EntityPreviewCard.svelte +104 -0
  24. package/src/lib/components/FileDropzone.svelte +493 -0
  25. package/src/lib/components/FilePicker.svelte +83 -14
  26. package/src/lib/components/FileThumbnail.svelte +80 -0
  27. package/src/lib/components/FilterDropdown.svelte +11 -11
  28. package/src/lib/components/HunkDiffView.svelte +348 -0
  29. package/src/lib/components/ImageLightbox.svelte +274 -0
  30. package/src/lib/components/ImageUpload.svelte +58 -9
  31. package/src/lib/components/InlineEdit.svelte +15 -9
  32. package/src/lib/components/InputDialog.svelte +327 -0
  33. package/src/lib/components/LazyImage.svelte +1 -0
  34. package/src/lib/components/LinkShortener.svelte +1 -1
  35. package/src/lib/components/LoadingSpinner.svelte +6 -2
  36. package/src/lib/components/MarkupEditor.svelte +485 -0
  37. package/src/lib/components/MarkupOverlay.svelte +55 -0
  38. package/src/lib/components/MediaWorkbench.svelte +871 -0
  39. package/src/lib/components/MilestoneCard.svelte +1 -1
  40. package/src/lib/components/MilestoneTimeline.svelte +1 -1
  41. package/src/lib/components/Modal.svelte +39 -4
  42. package/src/lib/components/PDFViewer.svelte +105 -0
  43. package/src/lib/components/PdfThumbnail.svelte +3 -1
  44. package/src/lib/components/PhoneInput.svelte +183 -63
  45. package/src/lib/components/ResizablePanel.svelte +4 -4
  46. package/src/lib/components/SearchDropdown.svelte +26 -13
  47. package/src/lib/components/SelectInput.svelte +26 -4
  48. package/src/lib/components/SidebarUserFooter.svelte +1 -1
  49. package/src/lib/components/SignaturePad.svelte +8 -4
  50. package/src/lib/components/SmartImageEditor.svelte +720 -0
  51. package/src/lib/components/SortDropdown.svelte +9 -3
  52. package/src/lib/components/Sparkline.svelte +9 -0
  53. package/src/lib/components/StatusBadge.svelte +20 -18
  54. package/src/lib/components/TextArea.svelte +24 -5
  55. package/src/lib/components/TextInput.svelte +29 -6
  56. package/src/lib/components/ThemeSelector.svelte +15 -4
  57. package/src/lib/components/TimeSlotPicker.svelte +7 -7
  58. package/src/lib/components/UserAvatar.svelte +14 -1
  59. package/src/lib/components/VariablePicker.svelte +170 -0
  60. package/src/lib/components/VoicePlayer.svelte +4 -3
  61. package/src/lib/components/markup.ts +287 -0
  62. package/src/lib/components/messaging/ChannelInfoModal.svelte +9 -9
  63. package/src/lib/components/messaging/ChannelList.svelte +1 -1
  64. package/src/lib/components/messaging/ChannelMembersModal.svelte +1 -1
  65. package/src/lib/components/messaging/CreateChannelModal.svelte +1 -1
  66. package/src/lib/components/messaging/DirectMessageList.svelte +1 -1
  67. package/src/lib/components/messaging/EmojiSelector.svelte +2 -1
  68. package/src/lib/components/messaging/MentionAutocomplete.svelte +1 -1
  69. package/src/lib/components/messaging/MessageAttachment.svelte +3 -3
  70. package/src/lib/components/messaging/MessageAttachmentUpload.svelte +3 -3
  71. package/src/lib/components/messaging/MessageInput.svelte +1 -1
  72. package/src/lib/components/messaging/MessageItem.svelte +6 -3
  73. package/src/lib/components/messaging/NotificationSettingsModal.svelte +1 -1
  74. package/src/lib/components/messaging/QuotedMessageDisplay.svelte +6 -1
  75. package/src/lib/components/messaging/StartDMModal.svelte +1 -1
  76. package/src/lib/components/pipeline/Pipeline.svelte +4 -4
  77. package/src/lib/components/pipeline/PipelineCard.svelte +1 -1
  78. package/src/lib/components/pipeline/PipelineColumn.svelte +8 -3
  79. package/src/lib/index.ts +105 -1
  80. package/src/lib/stores/confirmDialog.svelte.ts +48 -0
  81. package/src/lib/stores/inputDialog.svelte.ts +51 -0
  82. package/src/lib/styles/rail.css +63 -0
  83. package/src/lib/types/annotation.ts +38 -0
  84. package/src/lib/types/comments.ts +97 -0
  85. package/src/lib/types/entityPreview.ts +45 -0
  86. package/src/lib/types/filePicker.ts +2 -0
  87. package/src/lib/types/smartImageEditor.ts +39 -0
  88. package/src/lib/types/templateVars.ts +36 -0
  89. package/src/lib/utils/dateFormatters.ts +12 -10
  90. package/src/lib/utils/phone.ts +80 -0
  91. package/src/lib/utils/taskUtils.ts +21 -7
@@ -0,0 +1,80 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ /** Display name of the file */
4
+ name: string
5
+ /** MIME type — used to pick the right icon / thumbnail */
6
+ mimeType: string
7
+ /** URL to render as image thumbnail (for image files) */
8
+ url?: string
9
+ size?: "sm" | "md" | "lg" | "xl"
10
+ }
11
+
12
+ let { name, mimeType, url, size = "md" }: Props = $props()
13
+
14
+ let imageError = $state(false)
15
+ let isLoading = $state(true)
16
+
17
+ const sizeClasses: Record<string, string> = {
18
+ sm: "h-8 w-8",
19
+ md: "h-16 w-16",
20
+ lg: "h-24 w-24",
21
+ xl: "h-32 w-32",
22
+ }
23
+
24
+ const isImageFile = $derived(mimeType.startsWith("image/"))
25
+ const isPdfFile = $derived(mimeType === "application/pdf")
26
+ const isCadFile = $derived(mimeType.includes("dwg") || mimeType.includes("autocad"))
27
+ const isZipFile = $derived(
28
+ mimeType === "application/zip" ||
29
+ mimeType === "application/x-zip-compressed" ||
30
+ mimeType === "application/x-zip"
31
+ )
32
+ </script>
33
+
34
+ <div class="relative {sizeClasses[size]} flex-shrink-0">
35
+ {#if isImageFile && url && !imageError}
36
+ <img
37
+ src={url}
38
+ alt={name}
39
+ class="w-full h-full object-cover rounded border border-base-300 shadow-sm"
40
+ onload={() => { isLoading = false }}
41
+ onerror={() => { imageError = true; isLoading = false }}
42
+ />
43
+ {:else}
44
+ <div class="w-full h-full bg-base-200 rounded border border-base-300 flex items-center justify-center">
45
+ {#if isPdfFile}
46
+ <svg class="h-6 w-6 text-error" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
47
+ <path d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20M10.92,12.31C10.68,11.54 10.15,9.08 11.55,9.04C12.95,9 12.03,12.16 12.03,12.16C12.42,13.65 14.05,14.72 14.05,14.72C14.55,14.57 17.4,14.24 17,15.72C16.57,17.2 13.5,15.81 13.5,15.81C11.55,15.95 10.09,16.47 10.09,16.47C8.96,18.58 7.64,19.5 7.1,18.61C6.43,17.5 9.23,16.07 9.23,16.07C10.68,13.67 10.92,12.31 10.92,12.31Z" />
48
+ </svg>
49
+ {:else if isCadFile}
50
+ <svg class="h-6 w-6 text-info" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
51
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
52
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 10l5-5 5 5" />
53
+ </svg>
54
+ {:else if isZipFile}
55
+ <svg class="h-6 w-6 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
56
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
57
+ </svg>
58
+ {:else}
59
+ <svg class="h-6 w-6 text-base-content/40" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
60
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
61
+ </svg>
62
+ {/if}
63
+ </div>
64
+ {/if}
65
+
66
+ {#if isLoading && isImageFile && url}
67
+ <div class="absolute inset-0 bg-base-200 rounded animate-pulse"></div>
68
+ {/if}
69
+
70
+ <!-- File type badge for small size -->
71
+ {#if size === "sm" && (isPdfFile || isCadFile)}
72
+ <div
73
+ class="absolute -top-1 -right-1 w-3 h-3 rounded-full text-xs font-bold text-white flex items-center justify-center
74
+ {isPdfFile ? 'bg-error' : 'bg-info'}"
75
+ aria-hidden="true"
76
+ >
77
+ {isPdfFile ? "P" : "D"}
78
+ </div>
79
+ {/if}
80
+ </div>
@@ -109,18 +109,18 @@
109
109
  onchange={() => handleToggle(opt.value)}
110
110
  />
111
111
  <span>{opt.label}</span>
112
- <span class="text-xs opacity-60">({opt.count})</span>
112
+ <span class="text-[0.75rem] opacity-60">({opt.count})</span>
113
113
  </label>
114
114
  {:else}
115
115
  <button
116
116
  class="badge badge-sm transition-all duration-200 cursor-pointer {selected.has(opt.value)
117
- ? colorFn(opt.value, true) + ' shadow-md'
118
- : 'badge-ghost hover:badge-primary/20 hover:shadow-sm hover:scale-105'}"
117
+ ? colorFn(opt.value, true)
118
+ : 'badge-ghost hover:badge-primary/20'}"
119
119
  onclick={() => handleToggle(opt.value)}
120
120
  onkeydown={(e) => handleKeydown(e, opt.value)}
121
121
  >
122
122
  {opt.label}
123
- <span class="ml-1 opacity-70">({opt.count})</span>
123
+ <span class="ml-1 text-[0.75rem] opacity-60">({opt.count})</span>
124
124
  </button>
125
125
  {/if}
126
126
  {/each}
@@ -132,11 +132,11 @@
132
132
  <div
133
133
  tabindex="0"
134
134
  role="button"
135
- class="px-2.5 py-1 rounded cursor-pointer transition-all flex items-center gap-1.5 font-mono text-xs tracking-wider bg-base-200 border border-base-300 text-base-content/60"
135
+ class="px-2.5 py-1 rounded cursor-pointer transition-all flex items-center gap-1.5 text-[0.8125rem] bg-base-200 border border-base-300 text-base-content/60"
136
136
  >
137
- <span class="uppercase">{label}</span>
137
+ <span>{label}</span>
138
138
  <span
139
- class="px-1.5 py-0.5 rounded text-xs font-mono {selected.size > 0 && !(emptyMeansAll && selected.size === 0) ? 'bg-primary/20 text-primary' : 'bg-base-300 text-base-content opacity-60'}"
139
+ class="px-1.5 py-0.5 rounded text-[0.75rem] {selected.size > 0 && !(emptyMeansAll && selected.size === 0) ? 'bg-primary/20 text-primary' : 'bg-base-300 text-base-content opacity-60'}"
140
140
  >
141
141
  {displayText}
142
142
  </span>
@@ -171,8 +171,8 @@
171
171
  checked={selected.has(opt.value)}
172
172
  onchange={() => handleToggle(opt.value)}
173
173
  />
174
- <span class="truncate font-mono text-xs">{opt.label}</span>
175
- <span class="text-xs font-mono text-base-content/50">({opt.count})</span>
174
+ <span class="truncate text-[0.8125rem]">{opt.label}</span>
175
+ <span class="text-[0.75rem] text-base-content/45">({opt.count})</span>
176
176
  </label>
177
177
  </li>
178
178
  {/each}
@@ -183,12 +183,12 @@
183
183
  <div
184
184
  tabindex="0"
185
185
  role="menu"
186
- class="dropdown-content rounded-box shadow-lg p-2 z-40 {menuWidth} mt-1 {maxHeight ? maxHeight + ' overflow-y-auto' : ''} bg-base-200 border border-base-300"
186
+ class="dropdown-content rounded-box p-2 z-40 {menuWidth} mt-1 {maxHeight ? maxHeight + ' overflow-y-auto' : ''} bg-base-200 border border-base-300"
187
187
  >
188
188
  <div class="flex flex-wrap gap-1.5">
189
189
  {#each options as opt, index}
190
190
  <button
191
- class="px-2 py-0.5 rounded font-mono text-xs transition-all cursor-pointer border {selected.has(opt.value) ? 'bg-primary/20 border-primary/40 text-primary' : 'bg-base-300 border-base-content/20 text-base-content opacity-70 hover:opacity-100'}"
191
+ class="px-2 py-0.5 rounded text-[0.75rem] transition-all cursor-pointer border {selected.has(opt.value) ? 'bg-primary/20 border-primary/40 text-primary' : 'bg-base-300 border-base-content/20 text-base-content opacity-70 hover:opacity-100'}"
192
192
  onclick={() => handleToggle(opt.value)}
193
193
  onkeydown={(e) => handleKeydown(e, opt.value)}
194
194
  >
@@ -0,0 +1,348 @@
1
+ <script lang="ts">
2
+ /**
3
+ * HunkDiffView — side-by-side line-level diff with inline word highlights.
4
+ *
5
+ * Renders the before/after of a single DiffHunk with the visual treatment of
6
+ * a real code-review tool:
7
+ * - Line numbers in gutter
8
+ * - "−" / "+" markers per line
9
+ * - Red background + strikethrough for removed lines
10
+ * - Green background for added lines
11
+ * - Inline word-level highlights when a line was modified (Myers diff)
12
+ * - Monospace, syntax-naive (YAML is plain enough)
13
+ *
14
+ * Pure presentation — no state, no side effects. Pairs with DiffReview's
15
+ * per-hunk Accept/Reject buttons.
16
+ */
17
+
18
+ import { diffLines, diffWordsWithSpace, type Change } from 'diff'
19
+
20
+ interface Props {
21
+ oldText: string | null
22
+ newText: string | null
23
+ /** When false (default), shows two columns. When true, unified single column. */
24
+ unified?: boolean
25
+ }
26
+
27
+ let { oldText, newText, unified = false }: Props = $props()
28
+
29
+ type Row = {
30
+ kind: 'context' | 'removed' | 'added' | 'modified'
31
+ oldLineNo: number | null
32
+ newLineNo: number | null
33
+ oldContent: string | null
34
+ newContent: string | null
35
+ /** When kind=='modified', word-level segments per side */
36
+ oldSegments?: Array<{ text: string; changed: boolean }>
37
+ newSegments?: Array<{ text: string; changed: boolean }>
38
+ }
39
+
40
+ /**
41
+ * Build paired rows for side-by-side rendering.
42
+ * Adjacent removed+added blocks of equal-ish line count are zipped into
43
+ * 'modified' rows so the eye can compare line-for-line. Otherwise they
44
+ * render as separate red/green rows (with blank counterpart on the other
45
+ * side).
46
+ */
47
+ function buildRows(oldT: string, newT: string): Row[] {
48
+ const changes = diffLines(oldT, newT)
49
+ const rows: Row[] = []
50
+ let oldLine = 1
51
+ let newLine = 1
52
+
53
+ for (let i = 0; i < changes.length; i++) {
54
+ const c = changes[i]
55
+ const lines = c.value.replace(/\n$/, '').split('\n')
56
+
57
+ if (!c.added && !c.removed) {
58
+ // context
59
+ for (const line of lines) {
60
+ rows.push({
61
+ kind: 'context',
62
+ oldLineNo: oldLine++,
63
+ newLineNo: newLine++,
64
+ oldContent: line,
65
+ newContent: line,
66
+ })
67
+ }
68
+ } else if (c.removed) {
69
+ // Lookahead: is the very next change an 'added' block? If so, pair them
70
+ const next = changes[i + 1]
71
+ if (next?.added) {
72
+ const remLines = lines
73
+ const addLines = next.value.replace(/\n$/, '').split('\n')
74
+ const pairs = Math.max(remLines.length, addLines.length)
75
+ for (let j = 0; j < pairs; j++) {
76
+ const r = remLines[j]
77
+ const a = addLines[j]
78
+ if (r !== undefined && a !== undefined) {
79
+ // both present — modified row with inline word diff
80
+ const wordChanges = diffWordsWithSpace(r, a)
81
+ const oldSegments: Array<{ text: string; changed: boolean }> = []
82
+ const newSegments: Array<{ text: string; changed: boolean }> = []
83
+ for (const w of wordChanges) {
84
+ if (w.added) newSegments.push({ text: w.value, changed: true })
85
+ else if (w.removed) oldSegments.push({ text: w.value, changed: true })
86
+ else {
87
+ oldSegments.push({ text: w.value, changed: false })
88
+ newSegments.push({ text: w.value, changed: false })
89
+ }
90
+ }
91
+ rows.push({
92
+ kind: 'modified',
93
+ oldLineNo: oldLine++,
94
+ newLineNo: newLine++,
95
+ oldContent: r,
96
+ newContent: a,
97
+ oldSegments,
98
+ newSegments,
99
+ })
100
+ } else if (r !== undefined) {
101
+ rows.push({
102
+ kind: 'removed',
103
+ oldLineNo: oldLine++,
104
+ newLineNo: null,
105
+ oldContent: r,
106
+ newContent: null,
107
+ })
108
+ } else if (a !== undefined) {
109
+ rows.push({
110
+ kind: 'added',
111
+ oldLineNo: null,
112
+ newLineNo: newLine++,
113
+ oldContent: null,
114
+ newContent: a,
115
+ })
116
+ }
117
+ }
118
+ i++ // consumed the next 'added' change
119
+ } else {
120
+ for (const line of lines) {
121
+ rows.push({
122
+ kind: 'removed',
123
+ oldLineNo: oldLine++,
124
+ newLineNo: null,
125
+ oldContent: line,
126
+ newContent: null,
127
+ })
128
+ }
129
+ }
130
+ } else if (c.added) {
131
+ // standalone added (not preceded by a removed block — already handled above)
132
+ for (const line of lines) {
133
+ rows.push({
134
+ kind: 'added',
135
+ oldLineNo: null,
136
+ newLineNo: newLine++,
137
+ oldContent: null,
138
+ newContent: line,
139
+ })
140
+ }
141
+ }
142
+ }
143
+
144
+ return rows
145
+ }
146
+
147
+ const rows = $derived.by<Row[]>(() => {
148
+ const oldT = oldText ?? ''
149
+ const newT = newText ?? ''
150
+ if (!oldT && !newT) return []
151
+ if (!oldT) {
152
+ // pure-add — every line is 'added'
153
+ return newT.replace(/\n$/, '').split('\n').map((line, i) => ({
154
+ kind: 'added' as const,
155
+ oldLineNo: null,
156
+ newLineNo: i + 1,
157
+ oldContent: null,
158
+ newContent: line,
159
+ }))
160
+ }
161
+ if (!newT) {
162
+ // pure-remove — every line is 'removed'
163
+ return oldT.replace(/\n$/, '').split('\n').map((line, i) => ({
164
+ kind: 'removed' as const,
165
+ oldLineNo: i + 1,
166
+ newLineNo: null,
167
+ oldContent: line,
168
+ newContent: null,
169
+ }))
170
+ }
171
+ return buildRows(oldT, newT)
172
+ })
173
+
174
+ function lineNoStr(n: number | null): string {
175
+ return n === null ? ' ' : String(n).padStart(3, ' ')
176
+ }
177
+ </script>
178
+
179
+ <div class="hunk-diff text-xs font-mono leading-relaxed rounded border border-base-300/60 overflow-hidden bg-base-200/30">
180
+ {#if rows.length === 0}
181
+ <div class="px-3 py-4 text-center text-base-content/40 italic">no content</div>
182
+ {:else if unified}
183
+ <!-- Unified column -->
184
+ <div class="flex flex-col">
185
+ {#each rows as row, i (i)}
186
+ {#if row.kind === 'context'}
187
+ <div class="diff-row diff-row-context">
188
+ <span class="diff-gutter">{lineNoStr(row.oldLineNo)}</span>
189
+ <span class="diff-gutter">{lineNoStr(row.newLineNo)}</span>
190
+ <span class="diff-marker"> </span>
191
+ <span class="diff-content">{row.oldContent}</span>
192
+ </div>
193
+ {:else if row.kind === 'removed'}
194
+ <div class="diff-row diff-row-removed">
195
+ <span class="diff-gutter">{lineNoStr(row.oldLineNo)}</span>
196
+ <span class="diff-gutter"> </span>
197
+ <span class="diff-marker">−</span>
198
+ <span class="diff-content line-through opacity-80">{row.oldContent}</span>
199
+ </div>
200
+ {:else if row.kind === 'added'}
201
+ <div class="diff-row diff-row-added">
202
+ <span class="diff-gutter"> </span>
203
+ <span class="diff-gutter">{lineNoStr(row.newLineNo)}</span>
204
+ <span class="diff-marker">+</span>
205
+ <span class="diff-content">{row.newContent}</span>
206
+ </div>
207
+ {:else if row.kind === 'modified'}
208
+ <div class="diff-row diff-row-removed">
209
+ <span class="diff-gutter">{lineNoStr(row.oldLineNo)}</span>
210
+ <span class="diff-gutter"> </span>
211
+ <span class="diff-marker">−</span>
212
+ <span class="diff-content">
213
+ {#each row.oldSegments ?? [] as seg, j (j)}{#if seg.changed}<span class="word-removed">{seg.text}</span>{:else}<span>{seg.text}</span>{/if}{/each}
214
+ </span>
215
+ </div>
216
+ <div class="diff-row diff-row-added">
217
+ <span class="diff-gutter"> </span>
218
+ <span class="diff-gutter">{lineNoStr(row.newLineNo)}</span>
219
+ <span class="diff-marker">+</span>
220
+ <span class="diff-content">
221
+ {#each row.newSegments ?? [] as seg, j (j)}{#if seg.changed}<span class="word-added">{seg.text}</span>{:else}<span>{seg.text}</span>{/if}{/each}
222
+ </span>
223
+ </div>
224
+ {/if}
225
+ {/each}
226
+ </div>
227
+ {:else}
228
+ <!-- Side-by-side: two synced columns -->
229
+ <div class="grid grid-cols-2 divide-x divide-base-300/60">
230
+ <!-- LEFT (current/old) -->
231
+ <div class="flex flex-col">
232
+ {#each rows as row, i (i)}
233
+ {#if row.kind === 'context'}
234
+ <div class="diff-row diff-row-context">
235
+ <span class="diff-gutter">{lineNoStr(row.oldLineNo)}</span>
236
+ <span class="diff-marker"> </span>
237
+ <span class="diff-content">{row.oldContent}</span>
238
+ </div>
239
+ {:else if row.kind === 'removed'}
240
+ <div class="diff-row diff-row-removed">
241
+ <span class="diff-gutter">{lineNoStr(row.oldLineNo)}</span>
242
+ <span class="diff-marker">−</span>
243
+ <span class="diff-content line-through opacity-80">{row.oldContent}</span>
244
+ </div>
245
+ {:else if row.kind === 'added'}
246
+ <div class="diff-row diff-row-empty">
247
+ <span class="diff-gutter"> </span>
248
+ <span class="diff-marker"> </span>
249
+ <span class="diff-content">&nbsp;</span>
250
+ </div>
251
+ {:else if row.kind === 'modified'}
252
+ <div class="diff-row diff-row-removed">
253
+ <span class="diff-gutter">{lineNoStr(row.oldLineNo)}</span>
254
+ <span class="diff-marker">−</span>
255
+ <span class="diff-content">
256
+ {#each row.oldSegments ?? [] as seg, j (j)}{#if seg.changed}<span class="word-removed">{seg.text}</span>{:else}<span>{seg.text}</span>{/if}{/each}
257
+ </span>
258
+ </div>
259
+ {/if}
260
+ {/each}
261
+ </div>
262
+
263
+ <!-- RIGHT (proposed/new) -->
264
+ <div class="flex flex-col">
265
+ {#each rows as row, i (i)}
266
+ {#if row.kind === 'context'}
267
+ <div class="diff-row diff-row-context">
268
+ <span class="diff-gutter">{lineNoStr(row.newLineNo)}</span>
269
+ <span class="diff-marker"> </span>
270
+ <span class="diff-content">{row.newContent}</span>
271
+ </div>
272
+ {:else if row.kind === 'added'}
273
+ <div class="diff-row diff-row-added">
274
+ <span class="diff-gutter">{lineNoStr(row.newLineNo)}</span>
275
+ <span class="diff-marker">+</span>
276
+ <span class="diff-content">{row.newContent}</span>
277
+ </div>
278
+ {:else if row.kind === 'removed'}
279
+ <div class="diff-row diff-row-empty">
280
+ <span class="diff-gutter"> </span>
281
+ <span class="diff-marker"> </span>
282
+ <span class="diff-content">&nbsp;</span>
283
+ </div>
284
+ {:else if row.kind === 'modified'}
285
+ <div class="diff-row diff-row-added">
286
+ <span class="diff-gutter">{lineNoStr(row.newLineNo)}</span>
287
+ <span class="diff-marker">+</span>
288
+ <span class="diff-content">
289
+ {#each row.newSegments ?? [] as seg, j (j)}{#if seg.changed}<span class="word-added">{seg.text}</span>{:else}<span>{seg.text}</span>{/if}{/each}
290
+ </span>
291
+ </div>
292
+ {/if}
293
+ {/each}
294
+ </div>
295
+ </div>
296
+ {/if}
297
+ </div>
298
+
299
+ <style>
300
+ .diff-row {
301
+ display: grid;
302
+ grid-template-columns: auto auto 1fr;
303
+ column-gap: 0.5rem;
304
+ padding: 0 0.5rem;
305
+ align-items: baseline;
306
+ min-height: 1.4em;
307
+ white-space: pre-wrap;
308
+ word-break: break-word;
309
+ }
310
+ .diff-gutter {
311
+ color: color-mix(in oklab, currentColor 35%, transparent);
312
+ font-variant-numeric: tabular-nums;
313
+ user-select: none;
314
+ text-align: right;
315
+ }
316
+ .diff-marker {
317
+ color: color-mix(in oklab, currentColor 55%, transparent);
318
+ width: 0.75em;
319
+ user-select: none;
320
+ }
321
+ .diff-row-removed {
322
+ background-color: color-mix(in oklab, var(--color-error, oklch(0.68 0.18 28)) 12%, transparent);
323
+ }
324
+ .diff-row-removed .diff-marker {
325
+ color: var(--color-error, oklch(0.68 0.18 28));
326
+ }
327
+ .diff-row-added {
328
+ background-color: color-mix(in oklab, var(--color-success, oklch(0.7 0.17 145)) 12%, transparent);
329
+ }
330
+ .diff-row-added .diff-marker {
331
+ color: var(--color-success, oklch(0.7 0.17 145));
332
+ }
333
+ .diff-row-empty {
334
+ background-color: color-mix(in oklab, currentColor 3%, transparent);
335
+ }
336
+ .diff-row-context {
337
+ background-color: transparent;
338
+ }
339
+ .word-removed {
340
+ background-color: color-mix(in oklab, var(--color-error, oklch(0.68 0.18 28)) 28%, transparent);
341
+ text-decoration: line-through;
342
+ text-decoration-color: color-mix(in oklab, var(--color-error, oklch(0.68 0.18 28)) 65%, transparent);
343
+ }
344
+ .word-added {
345
+ background-color: color-mix(in oklab, var(--color-success, oklch(0.7 0.17 145)) 28%, transparent);
346
+ font-weight: 600;
347
+ }
348
+ </style>