@cat-factory/app 0.43.0 → 0.45.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.
@@ -11,7 +11,8 @@
11
11
  // button is disabled with a hint. Linking needs the block id,
12
12
  // so chosen items are staged locally and import-and-linked once the task is created
13
13
  // (see useContextLinking) — the same context the agents see for every step of the run.
14
- import type { CreateTaskType, TaskSourceKind, TaskTypeFields } from '~/types/domain'
14
+ import type { CreateTaskType, DocKind, TaskSourceKind, TaskTypeFields } from '~/types/domain'
15
+ import { DOC_KINDS } from '~/types/domain'
15
16
  import ContextDocumentPicker from '~/components/documents/ContextDocumentPicker.vue'
16
17
  import ContextIssuePicker from '~/components/tasks/ContextIssuePicker.vue'
17
18
  import { mergePresetOptionLabel, mergePresetThresholds } from '~/utils/mergePreset'
@@ -64,9 +65,13 @@ const isRecurring = computed(() => taskType.value === 'recurring')
64
65
  const severity = ref<'low' | 'medium' | 'high' | 'critical' | ''>('')
65
66
  const stepsToReproduce = ref('')
66
67
  const timeboxHours = ref<number | undefined>(undefined)
67
- const docKind = ref<'prd' | 'rfc' | 'runbook' | 'reference' | 'other' | ''>('')
68
+ // `DOC_KINDS` (and the `DocKind` type) are owned by the contracts package re-exported via
69
+ // `~/types/domain` — so the picker and the create payload can't drift from the backend list.
70
+ const docKind = ref<DocKind | ''>('')
71
+ const docAudience = ref('')
72
+ const docTargetPath = ref('')
73
+ const docOutlineHints = ref('')
68
74
  const SEVERITIES = ['low', 'medium', 'high', 'critical'] as const
69
- const DOC_KINDS = ['prd', 'rfc', 'runbook', 'reference', 'other'] as const
70
75
 
71
76
  function buildTypeFields(): TaskTypeFields | undefined {
72
77
  if (taskType.value === 'bug') {
@@ -85,7 +90,12 @@ function buildTypeFields(): TaskTypeFields | undefined {
85
90
  : undefined
86
91
  }
87
92
  if (taskType.value === 'document') {
88
- return docKind.value ? { docKind: docKind.value } : undefined
93
+ const f: TaskTypeFields = {}
94
+ if (docKind.value) f.docKind = docKind.value
95
+ if (docAudience.value.trim()) f.audience = docAudience.value.trim()
96
+ if (docTargetPath.value.trim()) f.targetPath = docTargetPath.value.trim()
97
+ if (docOutlineHints.value.trim()) f.outlineHints = docOutlineHints.value.trim()
98
+ return Object.keys(f).length ? f : undefined
89
99
  }
90
100
  return undefined
91
101
  }
@@ -284,6 +294,9 @@ watch(open, (isOpen) => {
284
294
  stepsToReproduce.value = ''
285
295
  timeboxHours.value = undefined
286
296
  docKind.value = ''
297
+ docAudience.value = ''
298
+ docTargetPath.value = ''
299
+ docOutlineHints.value = ''
287
300
  mergePresetId.value = ''
288
301
  modelPresetId.value = ''
289
302
  pipelineId.value = ''
@@ -505,21 +518,47 @@ async function add() {
505
518
  />
506
519
  </UFormField>
507
520
 
508
- <UFormField v-else-if="taskType === 'document'" label="Document kind">
509
- <div class="flex flex-wrap gap-1">
510
- <UButton
511
- v-for="k in DOC_KINDS"
512
- :key="k"
513
- :color="docKind === k ? 'primary' : 'neutral'"
514
- :variant="docKind === k ? 'soft' : 'ghost'"
515
- size="xs"
516
- class="uppercase"
517
- @click="docKind = docKind === k ? '' : k"
518
- >
519
- {{ k }}
520
- </UButton>
521
+ <div v-else-if="taskType === 'document'" class="space-y-3">
522
+ <UFormField label="Document kind">
523
+ <div class="flex flex-wrap gap-1">
524
+ <UButton
525
+ v-for="k in DOC_KINDS"
526
+ :key="k"
527
+ :color="docKind === k ? 'primary' : 'neutral'"
528
+ :variant="docKind === k ? 'soft' : 'ghost'"
529
+ size="xs"
530
+ class="uppercase"
531
+ @click="docKind = docKind === k ? '' : k"
532
+ >
533
+ {{ k }}
534
+ </UButton>
535
+ </div>
536
+ </UFormField>
537
+ <div class="grid grid-cols-2 gap-3">
538
+ <UFormField label="Audience" hint="optional">
539
+ <UInput
540
+ v-model="docAudience"
541
+ placeholder="e.g. platform engineers"
542
+ class="w-full"
543
+ />
544
+ </UFormField>
545
+ <UFormField label="Target path" hint="optional">
546
+ <UInput
547
+ v-model="docTargetPath"
548
+ placeholder="e.g. docs/rfcs/0001-foo.md"
549
+ class="w-full"
550
+ />
551
+ </UFormField>
521
552
  </div>
522
- </UFormField>
553
+ <UFormField label="Outline hints" hint="optional">
554
+ <UTextarea
555
+ v-model="docOutlineHints"
556
+ :rows="2"
557
+ placeholder="Sections or points the document should cover"
558
+ class="w-full"
559
+ />
560
+ </UFormField>
561
+ </div>
523
562
 
524
563
  <div class="grid grid-cols-2 gap-3">
525
564
  <UFormField label="Pipeline">
@@ -0,0 +1,273 @@
1
+ <script setup lang="ts">
2
+ // Full-screen artifact viewer — a reusable zoom/pan lightbox for a SET of stored images
3
+ // (screenshots / reference designs), shared by the visual-confirmation gate and the test
4
+ // report window. It reuses the owner's `useArtifactBlobs` cache (passed in as `blobs`) so
5
+ // opening the lightbox never re-fetches a blob the gallery already resolved.
6
+ //
7
+ // Zoom is pure CSS `transform` (GPU, no canvas), so even large PNGs stay smooth. Keyboard:
8
+ // Esc close · ←/→ prev/next · +/- zoom · 0 reset · double-click toggle fit↔2×.
9
+ import { computed, ref, watch } from 'vue'
10
+ import type { ArtifactBlobs } from '~/composables/useArtifactBlobs'
11
+ import { useFocusTrap } from '~/composables/useFocusTrap'
12
+
13
+ interface LightboxItem {
14
+ artifactId: string
15
+ label: string
16
+ alt: string
17
+ }
18
+
19
+ const props = defineProps<{
20
+ open: boolean
21
+ index: number
22
+ items: LightboxItem[]
23
+ blobs: ArtifactBlobs
24
+ }>()
25
+
26
+ const emit = defineEmits<{
27
+ (e: 'update:open', value: boolean): void
28
+ (e: 'update:index', value: number): void
29
+ }>()
30
+
31
+ const MIN_SCALE = 1
32
+ const MAX_SCALE = 8
33
+ const scale = ref(1)
34
+ const tx = ref(0)
35
+ const ty = ref(0)
36
+
37
+ // Move focus into the lightbox on open + trap Tab within it (and restore focus on close).
38
+ // It's the topmost surface, so its trap stays live even over an owning review window.
39
+ const dialogRoot = ref<HTMLElement | null>(null)
40
+ useFocusTrap(
41
+ dialogRoot,
42
+ computed(() => props.open),
43
+ )
44
+
45
+ const current = computed(() => props.items[props.index] ?? null)
46
+ const total = computed(() => props.items.length)
47
+ const url = computed(() =>
48
+ current.value ? props.blobs.urlFor(current.value.artifactId) : undefined,
49
+ )
50
+ const state = computed(() =>
51
+ current.value ? props.blobs.statusFor(current.value.artifactId) : 'idle',
52
+ )
53
+
54
+ function resetView() {
55
+ scale.value = 1
56
+ tx.value = 0
57
+ ty.value = 0
58
+ }
59
+
60
+ // Resolve the active item (plus its immediate neighbours, so ←/→ is instant) whenever the
61
+ // lightbox is open or the index moves. Reset the zoom/pan on every navigation.
62
+ watch(
63
+ () => [props.open, props.index, props.items.length] as const,
64
+ () => {
65
+ if (!props.open) return
66
+ resetView()
67
+ const ids = [props.index - 1, props.index, props.index + 1]
68
+ .map((i) => props.items[i]?.artifactId)
69
+ .filter((v): v is string => !!v)
70
+ for (const id of ids) void props.blobs.resolve(id)
71
+ },
72
+ { immediate: true },
73
+ )
74
+
75
+ function close() {
76
+ emit('update:open', false)
77
+ }
78
+ function go(delta: number) {
79
+ if (!total.value) return
80
+ const next = (props.index + delta + total.value) % total.value
81
+ emit('update:index', next)
82
+ }
83
+ function zoomBy(factor: number) {
84
+ scale.value = Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale.value * factor))
85
+ if (scale.value === 1) {
86
+ tx.value = 0
87
+ ty.value = 0
88
+ }
89
+ }
90
+ function onWheel(e: WheelEvent) {
91
+ e.preventDefault()
92
+ zoomBy(e.deltaY < 0 ? 1.15 : 1 / 1.15)
93
+ }
94
+ function toggleZoom() {
95
+ if (scale.value > 1) resetView()
96
+ else scale.value = 2
97
+ }
98
+
99
+ // Pointer drag to pan (only meaningful when zoomed in).
100
+ const dragging = ref(false)
101
+ let startX = 0
102
+ let startY = 0
103
+ let baseX = 0
104
+ let baseY = 0
105
+ function onPointerDown(e: PointerEvent) {
106
+ if (scale.value <= 1) return
107
+ dragging.value = true
108
+ startX = e.clientX
109
+ startY = e.clientY
110
+ baseX = tx.value
111
+ baseY = ty.value
112
+ ;(e.target as HTMLElement).setPointerCapture?.(e.pointerId)
113
+ }
114
+ function onPointerMove(e: PointerEvent) {
115
+ if (!dragging.value) return
116
+ tx.value = baseX + (e.clientX - startX)
117
+ ty.value = baseY + (e.clientY - startY)
118
+ }
119
+ function onPointerUp() {
120
+ dragging.value = false
121
+ }
122
+
123
+ function onKey(e: KeyboardEvent) {
124
+ if (!props.open) return
125
+ switch (e.key) {
126
+ case 'Escape':
127
+ e.stopPropagation()
128
+ close()
129
+ break
130
+ case 'ArrowLeft':
131
+ go(-1)
132
+ break
133
+ case 'ArrowRight':
134
+ go(1)
135
+ break
136
+ case '+':
137
+ case '=':
138
+ zoomBy(1.25)
139
+ break
140
+ case '-':
141
+ case '_':
142
+ zoomBy(1 / 1.25)
143
+ break
144
+ case '0':
145
+ resetView()
146
+ break
147
+ }
148
+ }
149
+ // Capture-phase so Esc closes the lightbox BEFORE the underlying window's own Esc handler
150
+ // (both use window keydown; the lightbox is the topmost surface so it wins).
151
+ onMounted(() => window.addEventListener('keydown', onKey, true))
152
+ onBeforeUnmount(() => window.removeEventListener('keydown', onKey, true))
153
+ </script>
154
+
155
+ <template>
156
+ <Teleport to="body">
157
+ <div
158
+ v-if="open"
159
+ ref="dialogRoot"
160
+ tabindex="-1"
161
+ class="fixed inset-0 z-[60] flex flex-col bg-slate-950/95 backdrop-blur-sm focus:outline-none"
162
+ role="dialog"
163
+ aria-modal="true"
164
+ :aria-label="current ? `Screenshot: ${current.label}` : 'Screenshot viewer'"
165
+ @click.self="close"
166
+ >
167
+ <!-- Toolbar -->
168
+ <div class="flex items-center gap-3 border-b border-slate-800/60 px-4 py-2.5">
169
+ <span class="min-w-0 flex-1 truncate text-[13px] font-medium text-slate-200">
170
+ {{ current?.label ?? 'Screenshot' }}
171
+ </span>
172
+ <span v-if="total > 1" class="shrink-0 text-[12px] tabular-nums text-slate-400">
173
+ {{ index + 1 }} / {{ total }}
174
+ </span>
175
+ <div class="flex shrink-0 items-center gap-1">
176
+ <button
177
+ class="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200 disabled:opacity-40"
178
+ title="Zoom out (-)"
179
+ :disabled="scale <= MIN_SCALE"
180
+ @click="zoomBy(1 / 1.25)"
181
+ >
182
+ <UIcon name="i-lucide-zoom-out" class="h-4 w-4" />
183
+ </button>
184
+ <span class="w-10 text-center text-[11px] tabular-nums text-slate-500"
185
+ >{{ Math.round(scale * 100) }}%</span
186
+ >
187
+ <button
188
+ class="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200 disabled:opacity-40"
189
+ title="Zoom in (+)"
190
+ :disabled="scale >= MAX_SCALE"
191
+ @click="zoomBy(1.25)"
192
+ >
193
+ <UIcon name="i-lucide-zoom-in" class="h-4 w-4" />
194
+ </button>
195
+ <button
196
+ class="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
197
+ title="Reset (0)"
198
+ @click="resetView"
199
+ >
200
+ <UIcon name="i-lucide-maximize" class="h-4 w-4" />
201
+ </button>
202
+ <button
203
+ class="ml-1 rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
204
+ title="Close (Esc)"
205
+ @click="close"
206
+ >
207
+ <UIcon name="i-lucide-x" class="h-4 w-4" />
208
+ </button>
209
+ </div>
210
+ </div>
211
+
212
+ <!-- Stage -->
213
+ <div
214
+ class="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden p-4"
215
+ @wheel="onWheel"
216
+ @click.self="close"
217
+ >
218
+ <button
219
+ v-if="total > 1"
220
+ class="absolute left-3 top-1/2 z-10 -translate-y-1/2 rounded-full bg-slate-900/80 p-2 text-slate-300 hover:bg-slate-800 hover:text-white"
221
+ title="Previous (←)"
222
+ @click="go(-1)"
223
+ >
224
+ <UIcon name="i-lucide-chevron-left" class="h-5 w-5" />
225
+ </button>
226
+
227
+ <img
228
+ v-if="url"
229
+ :src="url"
230
+ :alt="current?.alt ?? ''"
231
+ draggable="false"
232
+ class="max-h-full max-w-full select-none rounded shadow-2xl"
233
+ :class="[
234
+ scale > 1 ? (dragging ? 'cursor-grabbing' : 'cursor-grab') : 'cursor-zoom-in',
235
+ dragging ? '' : 'transition-transform duration-100',
236
+ ]"
237
+ :style="{ transform: `translate(${tx}px, ${ty}px) scale(${scale})` }"
238
+ @dblclick="toggleZoom"
239
+ @pointerdown="onPointerDown"
240
+ @pointermove="onPointerMove"
241
+ @pointerup="onPointerUp"
242
+ @pointercancel="onPointerUp"
243
+ />
244
+ <div v-else class="flex flex-col items-center gap-2 text-slate-500">
245
+ <UIcon
246
+ :name="state === 'error' ? 'i-lucide-image-off' : 'i-lucide-loader'"
247
+ class="h-8 w-8"
248
+ :class="state === 'error' ? '' : 'animate-spin'"
249
+ />
250
+ <p class="text-[12px]">
251
+ {{ state === 'error' ? 'Failed to load image.' : 'Loading…' }}
252
+ </p>
253
+ <button
254
+ v-if="state === 'error' && current"
255
+ class="text-[12px] text-amber-300 hover:underline"
256
+ @click="props.blobs.retry(current.artifactId)"
257
+ >
258
+ Retry
259
+ </button>
260
+ </div>
261
+
262
+ <button
263
+ v-if="total > 1"
264
+ class="absolute right-3 top-1/2 z-10 -translate-y-1/2 rounded-full bg-slate-900/80 p-2 text-slate-300 hover:bg-slate-800 hover:text-white"
265
+ title="Next (→)"
266
+ @click="go(1)"
267
+ >
268
+ <UIcon name="i-lucide-chevron-right" class="h-5 w-5" />
269
+ </button>
270
+ </div>
271
+ </div>
272
+ </Teleport>
273
+ </template>
@@ -0,0 +1,305 @@
1
+ <script setup lang="ts">
2
+ // Actual-vs-reference comparator for one view. Four modes:
3
+ // · side-by-side — the two images in a 2-col grid (the original layout)
4
+ // · overlay — stacked, with an opacity (onion-skin) slider on the actual layer
5
+ // · swipe — reference under, actual clipped by a draggable split handle
6
+ // · diff — canvas `difference` composite (identical pixels go black)
7
+ // Modes that need both images hide themselves when there's no reference yet. Clicking any
8
+ // image emits `expand` so the owner can open the shared lightbox; the reference slot doubles
9
+ // as a drag-and-drop / click upload target (emits `uploadReference`).
10
+ import { computed, nextTick, ref, watch } from 'vue'
11
+ import type { ArtifactBlobs } from '~/composables/useArtifactBlobs'
12
+
13
+ const props = defineProps<{
14
+ view: string
15
+ actualId: string | null | undefined
16
+ referenceId: string | null | undefined
17
+ blobs: ArtifactBlobs
18
+ busy?: boolean
19
+ }>()
20
+
21
+ const emit = defineEmits<{
22
+ (e: 'expand', artifactId: string): void
23
+ (e: 'uploadReference', file: File): void
24
+ }>()
25
+
26
+ type Mode = 'side-by-side' | 'overlay' | 'swipe' | 'diff'
27
+
28
+ const actualUrl = computed(() => props.blobs.urlFor(props.actualId))
29
+ const refUrl = computed(() => props.blobs.urlFor(props.referenceId))
30
+ const hasBoth = computed(() => !!actualUrl.value && !!refUrl.value)
31
+
32
+ const diffFailed = ref(false)
33
+ const MODES = computed<{ id: Mode; icon: string; label: string }[]>(() => {
34
+ const base: { id: Mode; icon: string; label: string }[] = [
35
+ { id: 'side-by-side', icon: 'i-lucide-columns-2', label: 'Side by side' },
36
+ ]
37
+ if (hasBoth.value) {
38
+ base.push(
39
+ { id: 'overlay', icon: 'i-lucide-layers', label: 'Overlay' },
40
+ { id: 'swipe', icon: 'i-lucide-flip-horizontal-2', label: 'Swipe' },
41
+ )
42
+ if (!diffFailed.value) base.push({ id: 'diff', icon: 'i-lucide-contrast', label: 'Difference' })
43
+ }
44
+ return base
45
+ })
46
+
47
+ const mode = ref<Mode>('side-by-side')
48
+ // Fall back to side-by-side if the active mode stops being available (e.g. reference removed).
49
+ watch(MODES, (list) => {
50
+ if (!list.some((m) => m.id === mode.value)) mode.value = 'side-by-side'
51
+ })
52
+
53
+ const overlayOpacity = ref(50)
54
+ const splitPct = ref(50)
55
+
56
+ // --- swipe handle drag ---
57
+ const swipeBox = ref<HTMLElement | null>(null)
58
+ const swiping = ref(false)
59
+ function onSwipeDown(e: PointerEvent) {
60
+ swiping.value = true
61
+ ;(e.target as HTMLElement).setPointerCapture?.(e.pointerId)
62
+ moveSwipe(e)
63
+ }
64
+ function moveSwipe(e: PointerEvent) {
65
+ if (!swiping.value || !swipeBox.value) return
66
+ const r = swipeBox.value.getBoundingClientRect()
67
+ splitPct.value = Math.min(100, Math.max(0, ((e.clientX - r.left) / r.width) * 100))
68
+ }
69
+ function onSwipeUp() {
70
+ swiping.value = false
71
+ }
72
+
73
+ // --- diff canvas ---
74
+ const diffCanvas = ref<HTMLCanvasElement | null>(null)
75
+ const CAP = 2000
76
+ // Bumped on every renderDiff entry so a render whose async work (image decode) is overtaken
77
+ // by a newer mode/image change bails out instead of drawing stale pixels onto the canvas.
78
+ let renderToken = 0
79
+ function loadImage(src: string): Promise<HTMLImageElement> {
80
+ return new Promise((resolve, reject) => {
81
+ const img = new Image()
82
+ img.onload = () => resolve(img)
83
+ img.onerror = reject
84
+ img.src = src
85
+ })
86
+ }
87
+ async function renderDiff() {
88
+ if (mode.value !== 'diff' || !actualUrl.value || !refUrl.value) return
89
+ const token = ++renderToken
90
+ await nextTick()
91
+ const canvas = diffCanvas.value
92
+ if (!canvas || token !== renderToken) return
93
+ try {
94
+ const [a, b] = await Promise.all([loadImage(actualUrl.value), loadImage(refUrl.value)])
95
+ if (token !== renderToken) return
96
+ const scale = Math.min(1, CAP / Math.max(a.naturalWidth, a.naturalHeight || 1))
97
+ const w = Math.max(1, Math.round((a.naturalWidth || 1) * scale))
98
+ const h = Math.max(1, Math.round((a.naturalHeight || 1) * scale))
99
+ canvas.width = w
100
+ canvas.height = h
101
+ const ctx = canvas.getContext('2d')
102
+ if (!ctx) throw new Error('no 2d context')
103
+ ctx.clearRect(0, 0, w, h)
104
+ ctx.globalCompositeOperation = 'source-over'
105
+ ctx.drawImage(b, 0, 0, w, h)
106
+ ctx.globalCompositeOperation = 'difference'
107
+ ctx.drawImage(a, 0, 0, w, h)
108
+ ctx.globalCompositeOperation = 'source-over'
109
+ // Touch the pixels to surface a taint SecurityError early (untainted for same-origin
110
+ // blobs, but degrade gracefully if that ever changes).
111
+ ctx.getImageData(0, 0, 1, 1)
112
+ diffFailed.value = false
113
+ } catch {
114
+ diffFailed.value = true
115
+ if (mode.value === 'diff') mode.value = 'overlay'
116
+ }
117
+ }
118
+ watch([mode, actualUrl, refUrl], renderDiff, { immediate: true })
119
+
120
+ // --- reference upload (drag-drop + click) ---
121
+ const dragOver = ref(false)
122
+ const refInput = ref<HTMLInputElement | null>(null)
123
+ // Accept only the formats the file input advertises (PNG/JPEG), so a dropped GIF/SVG/WebP
124
+ // can't slip past the picker's `accept` filter.
125
+ const ACCEPTED = /^image\/(png|jpeg)$/
126
+ function pickFile(files: FileList | null | undefined) {
127
+ const file = files?.[0]
128
+ if (file && ACCEPTED.test(file.type)) emit('uploadReference', file)
129
+ }
130
+ function onDrop(e: DragEvent) {
131
+ dragOver.value = false
132
+ pickFile(e.dataTransfer?.files)
133
+ }
134
+ function onRefInput(e: Event) {
135
+ pickFile((e.target as HTMLInputElement).files)
136
+ if (refInput.value) refInput.value.value = ''
137
+ }
138
+ </script>
139
+
140
+ <template>
141
+ <div class="rounded-lg border border-slate-800 bg-slate-900/60 p-3">
142
+ <div class="mb-2 flex items-center justify-between gap-2">
143
+ <h3 class="min-w-0 truncate text-[12px] font-semibold text-slate-200">{{ view }}</h3>
144
+ <!-- Mode switch -->
145
+ <div
146
+ v-if="MODES.length > 1"
147
+ class="flex items-center gap-0.5 rounded-md border border-slate-800 bg-slate-950/60 p-0.5"
148
+ >
149
+ <button
150
+ v-for="m in MODES"
151
+ :key="m.id"
152
+ class="rounded px-1.5 py-1 text-slate-400 hover:text-slate-200"
153
+ :class="mode === m.id ? 'bg-slate-800 text-slate-100' : ''"
154
+ :title="m.label"
155
+ @click="mode = m.id"
156
+ >
157
+ <UIcon :name="m.icon" class="h-3.5 w-3.5" />
158
+ </button>
159
+ </div>
160
+ </div>
161
+
162
+ <!-- SIDE BY SIDE -->
163
+ <div v-if="mode === 'side-by-side'" class="grid grid-cols-2 gap-3">
164
+ <figure class="space-y-1">
165
+ <figcaption class="text-[10px] uppercase tracking-wide text-slate-500">Actual</figcaption>
166
+ <button
167
+ v-if="actualUrl"
168
+ class="block w-full overflow-hidden rounded border border-slate-800 hover:border-slate-600"
169
+ @click="actualId && emit('expand', actualId)"
170
+ >
171
+ <img :src="actualUrl" :alt="`${view} (actual)`" class="w-full cursor-zoom-in" />
172
+ </button>
173
+ <div
174
+ v-else
175
+ class="flex h-32 items-center justify-center rounded border border-dashed border-slate-700 text-[11px] text-slate-600"
176
+ >
177
+ {{
178
+ props.blobs.statusFor(actualId) === 'error'
179
+ ? 'Failed to load'
180
+ : actualId
181
+ ? 'Loading…'
182
+ : 'Not captured'
183
+ }}
184
+ </div>
185
+ </figure>
186
+
187
+ <figure class="space-y-1">
188
+ <figcaption class="text-[10px] uppercase tracking-wide text-slate-500">
189
+ Reference
190
+ </figcaption>
191
+ <button
192
+ v-if="refUrl"
193
+ class="group relative block w-full overflow-hidden rounded border border-slate-800 hover:border-slate-600"
194
+ @click="referenceId && emit('expand', referenceId)"
195
+ >
196
+ <img :src="refUrl" :alt="`${view} (reference)`" class="w-full cursor-zoom-in" />
197
+ <span
198
+ class="absolute bottom-1 right-1 rounded bg-slate-950/80 px-1.5 py-0.5 text-[10px] text-slate-300 opacity-0 group-hover:opacity-100"
199
+ @click.stop="refInput?.click()"
200
+ >
201
+ Replace
202
+ </span>
203
+ </button>
204
+ <!-- Drop zone when no reference yet -->
205
+ <div
206
+ v-else
207
+ class="flex h-32 cursor-pointer flex-col items-center justify-center gap-1 rounded border border-dashed text-[11px] transition"
208
+ :class="
209
+ dragOver
210
+ ? 'border-amber-500 bg-amber-500/5 text-amber-300'
211
+ : 'border-slate-700 text-slate-600 hover:border-slate-500 hover:text-slate-400'
212
+ "
213
+ @click="refInput?.click()"
214
+ @dragover.prevent="dragOver = true"
215
+ @dragleave.prevent="dragOver = false"
216
+ @drop.prevent="onDrop"
217
+ >
218
+ <UIcon name="i-lucide-image-up" class="h-5 w-5" />
219
+ <span>Drop or click to add a reference</span>
220
+ </div>
221
+ </figure>
222
+ </div>
223
+
224
+ <!-- OVERLAY (onion-skin) -->
225
+ <div v-else-if="mode === 'overlay'" class="space-y-2">
226
+ <div class="relative w-full overflow-hidden rounded border border-slate-800">
227
+ <img :src="refUrl" :alt="`${view} (reference)`" class="w-full" />
228
+ <!-- object-contain so a differing aspect ratio onion-skins undistorted over the reference. -->
229
+ <img
230
+ :src="actualUrl"
231
+ :alt="`${view} (actual)`"
232
+ class="absolute inset-0 h-full w-full object-contain"
233
+ :style="{ opacity: overlayOpacity / 100 }"
234
+ />
235
+ </div>
236
+ <div class="flex items-center gap-2 text-[10px] uppercase tracking-wide text-slate-500">
237
+ <span>Reference</span>
238
+ <input
239
+ v-model.number="overlayOpacity"
240
+ type="range"
241
+ min="0"
242
+ max="100"
243
+ class="flex-1 accent-amber-500"
244
+ />
245
+ <span>Actual</span>
246
+ </div>
247
+ </div>
248
+
249
+ <!-- SWIPE (split slider) -->
250
+ <div
251
+ v-else-if="mode === 'swipe'"
252
+ ref="swipeBox"
253
+ class="relative w-full cursor-ew-resize select-none overflow-hidden rounded border border-slate-800"
254
+ @pointerdown="onSwipeDown"
255
+ @pointermove="moveSwipe"
256
+ @pointerup="onSwipeUp"
257
+ @pointercancel="onSwipeUp"
258
+ >
259
+ <img :src="refUrl" :alt="`${view} (reference)`" class="block w-full" />
260
+ <div
261
+ class="absolute inset-0 overflow-hidden"
262
+ :style="{ clipPath: `inset(0 ${100 - splitPct}% 0 0)` }"
263
+ >
264
+ <!-- Fit the actual within the reference's box (object-contain) so a differing aspect
265
+ ratio doesn't stretch it; the split then compares like-for-like. -->
266
+ <img
267
+ :src="actualUrl"
268
+ :alt="`${view} (actual)`"
269
+ class="absolute inset-0 block h-full w-full object-contain"
270
+ />
271
+ </div>
272
+ <div class="absolute inset-y-0 w-0.5 bg-amber-400" :style="{ left: `${splitPct}%` }">
273
+ <span
274
+ class="absolute top-1/2 left-1/2 flex h-6 w-6 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-amber-400 text-slate-950 shadow"
275
+ >
276
+ <UIcon name="i-lucide-move-horizontal" class="h-3.5 w-3.5" />
277
+ </span>
278
+ </div>
279
+ <span
280
+ class="absolute left-1 top-1 rounded bg-slate-950/70 px-1 text-[9px] uppercase text-slate-300"
281
+ >Actual</span
282
+ >
283
+ <span
284
+ class="absolute right-1 top-1 rounded bg-slate-950/70 px-1 text-[9px] uppercase text-slate-300"
285
+ >Reference</span
286
+ >
287
+ </div>
288
+
289
+ <!-- DIFF (canvas) -->
290
+ <div v-else-if="mode === 'diff'" class="space-y-1">
291
+ <canvas ref="diffCanvas" class="w-full rounded border border-slate-800 bg-black" />
292
+ <p class="text-[10px] text-slate-500">Identical pixels appear black; differences glow.</p>
293
+ </div>
294
+
295
+ <!-- Hidden file input shared by replace/drop zone -->
296
+ <input
297
+ ref="refInput"
298
+ type="file"
299
+ accept="image/png,image/jpeg"
300
+ class="hidden"
301
+ :disabled="busy"
302
+ @change="onRefInput"
303
+ />
304
+ </div>
305
+ </template>