@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.
@@ -10,13 +10,21 @@
10
10
  // from the report itself: each `tested` entry is the scenario the Tester walked, and
11
11
  // outcomes / concerns are grouped under it by name. Deeper linkage to the in-repo
12
12
  // `spec/features/*.feature` files would need a spec endpoint (a future enhancement).
13
- import type { TestConcern, TestOutcome, TestReport } from '~/types/domain'
13
+ import { computed, onUnmounted, ref, watch } from 'vue'
14
+ import type { TestConcern, TestOutcome, TestReport, TestScreenshot } from '~/types/domain'
15
+ import { useArtifactBlobs } from '~/composables/useArtifactBlobs'
16
+ import { useFocusTrap } from '~/composables/useFocusTrap'
17
+ import ArtifactLightbox from '~/components/media/ArtifactLightbox.vue'
14
18
  import StepRestartControl from '~/components/panels/StepRestartControl.vue'
15
19
  import StepRunMeta from '~/components/panels/StepRunMeta.vue'
16
20
 
17
21
  const board = useBoardStore()
18
22
  const execution = useExecutionStore()
19
23
 
24
+ // Per-window blob cache for the captured screenshots; revoked on unmount.
25
+ const blobs = useArtifactBlobs()
26
+ onUnmounted(() => blobs.revokeAll())
27
+
20
28
  // Shared seam contract (open/blockId/close + Escape). No `onOpen` loader: this window reads
21
29
  // its report straight off the execution step, so there's nothing to fetch on open.
22
30
  const { open, blockId, instanceId, stepIndex, close } = useResultView('tester')
@@ -32,6 +40,18 @@ const step = computed(() => {
32
40
  const report = computed<TestReport | null>(() => step.value?.test?.lastReport ?? null)
33
41
  const testState = computed(() => step.value?.test ?? null)
34
42
 
43
+ const screenshots = computed<TestScreenshot[]>(() => report.value?.screenshots ?? [])
44
+ // Resolve each capture into an object URL for the gallery + lightbox. The shared cache
45
+ // dedupes, so the lightbox reuses what the thumbnails fetched. (The reference design is not
46
+ // shown in this window — that's the visual-confirmation gate's job — so we don't fetch it.)
47
+ watch(
48
+ screenshots,
49
+ (next) => {
50
+ for (const s of next) void blobs.resolve(s.artifactId)
51
+ },
52
+ { immediate: true },
53
+ )
54
+
35
55
  const STATUS_META: Record<TestOutcome['status'], { icon: string; text: string; label: string }> = {
36
56
  passed: { icon: 'i-lucide-circle-check', text: 'text-emerald-400', label: 'Passed' },
37
57
  failed: { icon: 'i-lucide-circle-x', text: 'text-rose-400', label: 'Failed' },
@@ -61,6 +81,7 @@ interface ScenarioGroup {
61
81
  other: boolean
62
82
  outcomes: TestOutcome[]
63
83
  concerns: TestConcern[]
84
+ screenshots: TestScreenshot[]
64
85
  status: 'passed' | 'failed' | 'skipped' | 'mixed' | 'empty'
65
86
  }
66
87
 
@@ -74,16 +95,18 @@ function rollUp(outcomes: TestOutcome[], concerns: TestConcern[]): ScenarioGroup
74
95
  return 'mixed'
75
96
  }
76
97
 
77
- // Group outcomes + concerns under the scenarios the Tester listed in `tested`. An
78
- // outcome/concern falls under a scenario when their names are related; anything left
79
- // over lands in a synthetic "Other checks" bucket so nothing is dropped.
80
- const groups = computed<ScenarioGroup[]>(() => {
98
+ // Group outcomes + concerns + screenshots under the scenarios the Tester listed in
99
+ // `tested`. An item falls under a scenario when their names are related; anything left over
100
+ // lands in a synthetic "Other checks" bucket / the standalone gallery so nothing is dropped.
101
+ const scenarioLayout = computed<{ groups: ScenarioGroup[]; ungrouped: TestScreenshot[] }>(() => {
81
102
  const r = report.value
82
- if (!r) return []
103
+ if (!r) return { groups: [], ungrouped: [] }
83
104
  const outcomes = r.outcomes ?? []
84
105
  const concerns = r.concerns ?? []
106
+ const shots = r.screenshots ?? []
85
107
  const usedOutcome = new Set<number>()
86
108
  const usedConcern = new Set<number>()
109
+ const usedShot = new Set<number>()
87
110
  const out: ScenarioGroup[] = []
88
111
 
89
112
  r.tested.forEach((area, i) => {
@@ -103,12 +126,21 @@ const groups = computed<ScenarioGroup[]>(() => {
103
126
  }
104
127
  return false
105
128
  })
129
+ const groupShots = shots.filter((s, si) => {
130
+ if (usedShot.has(si)) return false
131
+ if (related(area, s.view) || groupOutcomes.some((o) => related(o.name, s.view))) {
132
+ usedShot.add(si)
133
+ return true
134
+ }
135
+ return false
136
+ })
106
137
  out.push({
107
138
  key: `s${i}`,
108
139
  title: area,
109
140
  other: false,
110
141
  outcomes: groupOutcomes,
111
142
  concerns: groupConcerns,
143
+ screenshots: groupShots,
112
144
  status: rollUp(groupOutcomes, groupConcerns),
113
145
  })
114
146
  })
@@ -122,11 +154,38 @@ const groups = computed<ScenarioGroup[]>(() => {
122
154
  other: true,
123
155
  outcomes: leftoverOutcomes,
124
156
  concerns: leftoverConcerns,
157
+ screenshots: [],
125
158
  status: rollUp(leftoverOutcomes, leftoverConcerns),
126
159
  })
127
160
  }
128
- return out
161
+ const ungrouped = shots.filter((_, si) => !usedShot.has(si))
162
+ return { groups: out, ungrouped }
129
163
  })
164
+ const groups = computed(() => scenarioLayout.value.groups)
165
+ const ungroupedScreenshots = computed(() => scenarioLayout.value.ungrouped)
166
+
167
+ // Shared lightbox over ALL captured screenshots (in report order).
168
+ const lightboxItems = computed(() =>
169
+ screenshots.value.map((s) => ({
170
+ artifactId: s.artifactId,
171
+ label: s.view,
172
+ alt: `${s.view} (screenshot)`,
173
+ })),
174
+ )
175
+ const lightboxOpen = ref(false)
176
+ const lightboxIndex = ref(0)
177
+ function openShot(artifactId: string) {
178
+ const i = lightboxItems.value.findIndex((it) => it.artifactId === artifactId)
179
+ lightboxIndex.value = i < 0 ? 0 : i
180
+ lightboxOpen.value = true
181
+ }
182
+
183
+ // Focus management for the modal panel; hands the Tab trap off to the lightbox while it's open.
184
+ const dialogRoot = ref<HTMLElement | null>(null)
185
+ useFocusTrap(
186
+ dialogRoot,
187
+ computed(() => open.value && !lightboxOpen.value),
188
+ )
130
189
 
131
190
  const sortedConcerns = computed<TestConcern[]>(() => {
132
191
  const r = report.value
@@ -175,7 +234,12 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
175
234
  @click.self="close"
176
235
  >
177
236
  <div
178
- class="m-4 flex w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
237
+ ref="dialogRoot"
238
+ tabindex="-1"
239
+ role="dialog"
240
+ aria-modal="true"
241
+ aria-label="Test report"
242
+ class="m-4 flex w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl focus:outline-none"
179
243
  >
180
244
  <!-- Header -->
181
245
  <header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
@@ -272,6 +336,12 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
272
336
  >
273
337
  {{ g.title }}
274
338
  </span>
339
+ <UIcon
340
+ v-if="g.screenshots.length"
341
+ name="i-lucide-camera"
342
+ class="h-3.5 w-3.5 shrink-0 text-slate-500"
343
+ :title="`${g.screenshots.length} screenshot${g.screenshots.length === 1 ? '' : 's'}`"
344
+ />
275
345
  <span class="shrink-0 text-[11px] text-slate-500">
276
346
  {{ g.outcomes.length }} check{{ g.outcomes.length === 1 ? '' : 's' }}
277
347
  <template v-if="g.concerns.length">
@@ -329,9 +399,72 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
329
399
  </p>
330
400
  </div>
331
401
  </div>
402
+
403
+ <!-- Screenshots captured for this scenario -->
404
+ <div v-if="g.screenshots.length" class="mt-2 flex flex-wrap gap-2">
405
+ <button
406
+ v-for="(s, si) in g.screenshots"
407
+ :key="`shot${si}`"
408
+ class="group relative h-20 w-28 shrink-0 overflow-hidden rounded border border-slate-800 bg-slate-950/60 hover:border-slate-600"
409
+ :title="s.view"
410
+ @click="openShot(s.artifactId)"
411
+ >
412
+ <img
413
+ v-if="blobs.urlFor(s.artifactId)"
414
+ :src="blobs.urlFor(s.artifactId)"
415
+ :alt="`${s.view} (screenshot)`"
416
+ class="h-full w-full object-cover object-top"
417
+ />
418
+ <span
419
+ v-else
420
+ class="flex h-full w-full items-center justify-center text-[10px] text-slate-600"
421
+ >
422
+ {{ blobs.statusFor(s.artifactId) === 'error' ? 'Failed' : 'Loading…' }}
423
+ </span>
424
+ <span
425
+ class="absolute inset-x-0 bottom-0 truncate bg-slate-950/80 px-1 py-0.5 text-[9px] text-slate-300"
426
+ >{{ s.view }}</span
427
+ >
428
+ </button>
429
+ </div>
332
430
  </div>
333
431
  </li>
334
432
  </ul>
433
+
434
+ <!-- Standalone gallery: any captures not mapped to a scenario above -->
435
+ <section v-if="ungroupedScreenshots.length" class="mt-5">
436
+ <h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
437
+ Screenshots
438
+ </h3>
439
+ <div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
440
+ <button
441
+ v-for="(s, si) in ungroupedScreenshots"
442
+ :key="`gal${si}`"
443
+ class="group relative aspect-video overflow-hidden rounded-lg border border-slate-800 bg-slate-950/60 hover:border-slate-600"
444
+ :title="s.view"
445
+ @click="openShot(s.artifactId)"
446
+ >
447
+ <img
448
+ v-if="blobs.urlFor(s.artifactId)"
449
+ :src="blobs.urlFor(s.artifactId)"
450
+ :alt="`${s.view} (screenshot)`"
451
+ class="h-full w-full object-cover object-top"
452
+ />
453
+ <span
454
+ v-else
455
+ class="flex h-full w-full items-center justify-center text-[11px] text-slate-600"
456
+ >
457
+ {{
458
+ blobs.statusFor(s.artifactId) === 'error' ? 'Failed to load' : 'Loading…'
459
+ }}
460
+ </span>
461
+ <span
462
+ class="absolute inset-x-0 bottom-0 truncate bg-slate-950/80 px-1.5 py-0.5 text-[10px] text-slate-300"
463
+ >{{ s.view }}</span
464
+ >
465
+ </button>
466
+ </div>
467
+ </section>
335
468
  </template>
336
469
  </div>
337
470
 
@@ -409,5 +542,13 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
409
542
  </div>
410
543
  </div>
411
544
  </div>
545
+
546
+ <!-- Shared zoom/pan viewer for the captured screenshots. -->
547
+ <ArtifactLightbox
548
+ v-model:open="lightboxOpen"
549
+ v-model:index="lightboxIndex"
550
+ :items="lightboxItems"
551
+ :blobs="blobs"
552
+ />
412
553
  </Teleport>
413
554
  </template>
@@ -2,21 +2,27 @@
2
2
  // Visual-confirmation gate window — the dedicated surface for a `visual-confirmation` step
3
3
  // (opened via the universal result-view host, the same seam the human-test / tester windows
4
4
  // use). It reads the gate's live state off the execution step (`step.visualConfirm`, pushed
5
- // over the stream), renders each captured screenshot next to its reference design (paired by
6
- // view), and drives the human actions: approve (advance), request a fix from findings (the
7
- // Tester's fixer), or recapture (refresh the pairs). It also lets the human upload reference
8
- // design images for the task.
9
- import { onUnmounted, reactive, ref, watch } from 'vue'
5
+ // over the stream), renders each captured screenshot against its reference design (via the
6
+ // reusable <ImageCompare>: side-by-side / overlay / swipe / diff, with click-to-zoom into the
7
+ // shared <ArtifactLightbox>), and drives the human actions: approve (advance), request a fix
8
+ // (per-view notes + a freeform box, composed into the Tester's fixer findings), or recapture.
9
+ // References can be dropped straight onto a pair, or uploaded for any view below.
10
+ import { computed, onUnmounted, reactive, ref, watch } from 'vue'
10
11
  import type { VisualConfirmStepState } from '~/types/execution'
12
+ import { useArtifactBlobs } from '~/composables/useArtifactBlobs'
13
+ import { useFocusTrap } from '~/composables/useFocusTrap'
14
+ import ImageCompare from '~/components/media/ImageCompare.vue'
15
+ import ArtifactLightbox from '~/components/media/ArtifactLightbox.vue'
11
16
  import StepRunMeta from '~/components/panels/StepRunMeta.vue'
12
17
 
13
18
  const board = useBoardStore()
14
19
  const execution = useExecutionStore()
15
20
  const visualConfirm = useVisualConfirmStore()
16
21
 
17
- // Release the cached screenshot/reference object URLs when the window goes away, so the
18
- // (potentially large) blob bytes don't linger in memory for the rest of the session.
19
- onUnmounted(() => visualConfirm.revokeBlobs())
22
+ // Per-window blob cache; release the cached screenshot/reference object URLs when the window
23
+ // goes away, so the (potentially large) blob bytes don't linger for the rest of the session.
24
+ const blobs = useArtifactBlobs()
25
+ onUnmounted(() => blobs.revokeAll())
20
26
 
21
27
  const { open, blockId, instanceId, stepIndex, close } = useResultView('visual-confirm')
22
28
  const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
@@ -41,70 +47,134 @@ const PHASE_LABEL: Record<NonNullable<VisualConfirmStepState['phase']>, string>
41
47
  approved: 'Approved',
42
48
  }
43
49
 
44
- // Resolve each pair's artifact ids to object URLs for the <img>s (cached in the store).
45
- const urls = reactive<Record<string, string>>({})
46
- async function resolveUrl(id: string | null | undefined) {
47
- if (!id || urls[id]) return
48
- const url = await visualConfirm.blobUrl(id)
49
- if (url) urls[id] = url
50
- }
50
+ // Resolve every pair's artifacts (the gallery + the lightbox share this one cache).
51
51
  watch(
52
52
  pairs,
53
53
  (next) => {
54
54
  for (const p of next) {
55
- void resolveUrl(p.actualArtifactId)
56
- void resolveUrl(p.referenceArtifactId)
55
+ void blobs.resolve(p.actualArtifactId)
56
+ void blobs.resolve(p.referenceArtifactId)
57
57
  }
58
58
  },
59
59
  { immediate: true },
60
60
  )
61
61
 
62
- const findings = ref('')
63
- const showFindings = ref(false)
62
+ // Flat list of all images (actual then reference, per pair) for the lightbox + its index.
63
+ const lightboxItems = computed(() => {
64
+ const items: { artifactId: string; label: string; alt: string }[] = []
65
+ for (const p of pairs.value) {
66
+ if (p.actualArtifactId)
67
+ items.push({
68
+ artifactId: p.actualArtifactId,
69
+ label: `${p.view} — actual`,
70
+ alt: `${p.view} (actual)`,
71
+ })
72
+ if (p.referenceArtifactId)
73
+ items.push({
74
+ artifactId: p.referenceArtifactId,
75
+ label: `${p.view} — reference`,
76
+ alt: `${p.view} (reference)`,
77
+ })
78
+ }
79
+ return items
80
+ })
81
+ const lightboxOpen = ref(false)
82
+ const lightboxIndex = ref(0)
83
+ function expand(artifactId: string) {
84
+ const i = lightboxItems.value.findIndex((it) => it.artifactId === artifactId)
85
+ lightboxIndex.value = i < 0 ? 0 : i
86
+ lightboxOpen.value = true
87
+ }
64
88
 
65
- // When the gate flags its screenshots as an unreliable basis (`degradedReason` no capture
66
- // happened, a fix failed, or a fix landed AFTER these shots were taken), approving is no longer
67
- // a safe one-click: require the human to explicitly acknowledge they reviewed the change another
68
- // way (or recaptured) first. Re-armed whenever the reason changes so a fresh warning re-gates.
69
- const ackDegraded = ref(false)
70
- watch(
71
- () => vc.value?.degradedReason ?? null,
72
- () => {
73
- ackDegraded.value = false
74
- },
89
+ // Focus management for the modal panel. While the lightbox is open it owns the trap, so the
90
+ // window hands off (active = open && !lightbox) to avoid two Tab traps fighting.
91
+ const dialogRoot = ref<HTMLElement | null>(null)
92
+ useFocusTrap(
93
+ dialogRoot,
94
+ computed(() => open.value && !lightboxOpen.value),
75
95
  )
76
- const needsAck = computed(() => !!vc.value?.degradedReason)
77
- const canApprove = computed(
78
- () => awaitingHuman.value && !busy.value && (!needsAck.value || ackDegraded.value),
96
+
97
+ // --- Request a fix: per-view notes + a freeform box, composed into one findings string. ---
98
+ const perViewNotes = reactive<Record<string, string>>({})
99
+ const noteOpen = reactive<Record<string, boolean>>({})
100
+ const globalFindings = ref('')
101
+
102
+ const hasFindings = computed(
103
+ () => globalFindings.value.trim() !== '' || pairs.value.some((p) => perViewNotes[p.view]?.trim()),
79
104
  )
80
105
 
106
+ /** Compose the per-view notes + freeform text into the fixer's findings (and a structured
107
+ * mirror, so a future structured-findings contract is a one-line swap). */
108
+ function buildFindings(): { text: string; structured: { view?: string; note: string }[] } {
109
+ const structured: { view?: string; note: string }[] = []
110
+ const blocks: string[] = []
111
+ for (const p of pairs.value) {
112
+ const note = perViewNotes[p.view]?.trim()
113
+ if (note) {
114
+ structured.push({ view: p.view, note })
115
+ blocks.push(`### ${p.view}\n${note}`)
116
+ }
117
+ }
118
+ const general = globalFindings.value.trim()
119
+ if (general) {
120
+ structured.push({ note: general })
121
+ blocks.push(`### General\n${general}`)
122
+ }
123
+ return { text: blocks.join('\n\n'), structured }
124
+ }
125
+
81
126
  async function approve() {
82
127
  if (!blockId.value || !canApprove.value) return
83
128
  await visualConfirm.approve(blockId.value)
84
129
  close()
85
130
  }
86
131
  async function submitFix() {
87
- if (!blockId.value || !findings.value.trim()) return
88
- await visualConfirm.requestFix(blockId.value, findings.value.trim())
89
- findings.value = ''
90
- showFindings.value = false
132
+ if (!blockId.value || !hasFindings.value) return
133
+ await visualConfirm.requestFix(blockId.value, buildFindings().text)
134
+ globalFindings.value = ''
135
+ for (const k of Object.keys(perViewNotes)) delete perViewNotes[k]
136
+ for (const k of Object.keys(noteOpen)) delete noteOpen[k]
91
137
  }
92
138
  async function recapture() {
93
139
  if (!blockId.value) return
94
140
  await visualConfirm.recapture(blockId.value)
95
141
  }
96
142
 
97
- // Reference upload.
143
+ // --- Reference upload (per-pair drop, plus a free "any view" picker below). ---
144
+ async function uploadFor(view: string, file: File) {
145
+ if (!blockId.value) return
146
+ await visualConfirm.uploadReference(blockId.value, file, view)
147
+ }
98
148
  const uploadView = ref('')
99
149
  const fileInput = ref<HTMLInputElement | null>(null)
100
150
  async function onFilePicked(e: Event) {
101
151
  const input = e.target as HTMLInputElement
102
152
  const file = input.files?.[0]
103
- if (!file || !blockId.value) return
104
- await visualConfirm.uploadReference(blockId.value, file, uploadView.value.trim())
153
+ const view = uploadView.value.trim()
154
+ // Require a view name: a reference with no view can't pair with any captured screenshot,
155
+ // so it would be silently orphaned. The input is also disabled until a view is entered.
156
+ if (!file || !blockId.value || !view) {
157
+ if (fileInput.value) fileInput.value.value = ''
158
+ return
159
+ }
160
+ await visualConfirm.uploadReference(blockId.value, file, view)
105
161
  uploadView.value = ''
106
162
  if (fileInput.value) fileInput.value.value = ''
107
163
  }
164
+
165
+ // Degraded-basis approval guard (no capture / a fix landed after these shots): require an
166
+ // explicit "I reviewed this another way" acknowledgement before the one-click approve.
167
+ const ackDegraded = ref(false)
168
+ watch(
169
+ () => vc.value?.degradedReason ?? null,
170
+ () => {
171
+ ackDegraded.value = false
172
+ },
173
+ )
174
+ const needsAck = computed(() => !!vc.value?.degradedReason)
175
+ const canApprove = computed(
176
+ () => awaitingHuman.value && !busy.value && (!needsAck.value || ackDegraded.value),
177
+ )
108
178
  </script>
109
179
 
110
180
  <template>
@@ -115,7 +185,12 @@ async function onFilePicked(e: Event) {
115
185
  @click.self="close"
116
186
  >
117
187
  <div
118
- class="m-4 flex w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
188
+ ref="dialogRoot"
189
+ tabindex="-1"
190
+ role="dialog"
191
+ aria-modal="true"
192
+ aria-label="Visual confirmation"
193
+ class="m-4 flex w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl focus:outline-none"
119
194
  >
120
195
  <header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
121
196
  <span
@@ -164,57 +239,52 @@ async function onFilePicked(e: Event) {
164
239
  {{ phase ? PHASE_LABEL[phase] : '' }}
165
240
  </p>
166
241
 
167
- <!-- Actual-vs-reference gallery -->
242
+ <!-- Actual-vs-reference gallery. Keyed by `view` (the contract's unique per-pair
243
+ identity) so a pair's note/expand state stays bound to its view across recaptures. -->
168
244
  <section v-if="pairs.length" class="space-y-4">
169
- <div
170
- v-for="(p, i) in pairs"
171
- :key="i"
172
- class="rounded-lg border border-slate-800 bg-slate-900/60 p-3"
173
- >
174
- <h3 class="mb-2 text-[12px] font-semibold text-slate-200">{{ p.view }}</h3>
175
- <div class="grid grid-cols-2 gap-3">
176
- <figure class="space-y-1">
177
- <figcaption class="text-[10px] uppercase tracking-wide text-slate-500">
178
- Actual
179
- </figcaption>
180
- <img
181
- v-if="p.actualArtifactId && urls[p.actualArtifactId]"
182
- :src="urls[p.actualArtifactId]"
183
- :alt="`${p.view} (actual)`"
184
- class="w-full rounded border border-slate-800"
185
- />
186
- <div
187
- v-else
188
- class="flex h-32 items-center justify-center rounded border border-dashed border-slate-700 text-[11px] text-slate-600"
189
- >
190
- {{ p.actualArtifactId ? 'Loading…' : 'Not captured' }}
191
- </div>
192
- </figure>
193
- <figure class="space-y-1">
194
- <figcaption class="text-[10px] uppercase tracking-wide text-slate-500">
195
- Reference
196
- </figcaption>
197
- <img
198
- v-if="p.referenceArtifactId && urls[p.referenceArtifactId]"
199
- :src="urls[p.referenceArtifactId]"
200
- :alt="`${p.view} (reference)`"
201
- class="w-full rounded border border-slate-800"
245
+ <div v-for="p in pairs" :key="p.view" class="space-y-2">
246
+ <ImageCompare
247
+ :view="p.view"
248
+ :actual-id="p.actualArtifactId"
249
+ :reference-id="p.referenceArtifactId"
250
+ :blobs="blobs"
251
+ :busy="busy"
252
+ @expand="expand"
253
+ @upload-reference="(file: File) => uploadFor(p.view, file)"
254
+ />
255
+ <!-- Per-view note (folded into the fixer findings) -->
256
+ <div v-if="awaitingHuman" class="px-1">
257
+ <button
258
+ class="flex items-center gap-1.5 text-[11px] text-slate-400 hover:text-slate-200"
259
+ @click="noteOpen[p.view] = !noteOpen[p.view]"
260
+ >
261
+ <UIcon
262
+ :name="noteOpen[p.view] ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
263
+ class="h-3 w-3"
202
264
  />
203
- <div
204
- v-else
205
- class="flex h-32 items-center justify-center rounded border border-dashed border-slate-700 text-[11px] text-slate-600"
265
+ Note an issue with {{ p.view }}
266
+ <span
267
+ v-if="perViewNotes[p.view]?.trim()"
268
+ class="rounded-full bg-amber-500/15 px-1.5 text-[9px] text-amber-300"
269
+ >noted</span
206
270
  >
207
- {{ p.referenceArtifactId ? 'Loading…' : 'No reference' }}
208
- </div>
209
- </figure>
271
+ </button>
272
+ <textarea
273
+ v-if="noteOpen[p.view]"
274
+ v-model="perViewNotes[p.view]"
275
+ rows="2"
276
+ :placeholder="`What looks wrong on ${p.view}?`"
277
+ class="mt-1 w-full rounded-md border border-slate-700 bg-slate-950 px-2 py-1.5 text-[12px] text-slate-200 placeholder:text-slate-600 focus:border-amber-500 focus:outline-none"
278
+ />
210
279
  </div>
211
280
  </div>
212
281
  </section>
213
282
  <p v-else class="text-[12px] italic text-slate-500">
214
- No screenshots were captured — review the change manually.
283
+ No screenshots were captured — review the change manually, or upload a reference
284
+ below.
215
285
  </p>
216
286
 
217
- <!-- Reference upload -->
287
+ <!-- Upload a reference for any view -->
218
288
  <section class="rounded-lg border border-slate-800 bg-slate-900/60 p-3">
219
289
  <h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
220
290
  Upload a reference design
@@ -222,18 +292,29 @@ async function onFilePicked(e: Event) {
222
292
  <div class="flex flex-wrap items-center gap-2">
223
293
  <input
224
294
  v-model="uploadView"
295
+ list="vc-views"
225
296
  placeholder="View name (e.g. login)"
226
297
  class="rounded-md border border-slate-700 bg-slate-950 px-2 py-1 text-[12px] text-slate-200 placeholder:text-slate-600"
227
298
  />
299
+ <datalist id="vc-views">
300
+ <option v-for="p in pairs" :key="p.view" :value="p.view" />
301
+ </datalist>
228
302
  <input
229
303
  ref="fileInput"
230
304
  type="file"
231
305
  accept="image/png,image/jpeg"
232
- :disabled="busy"
233
- class="text-[12px] text-slate-300 file:mr-2 file:rounded file:border-0 file:bg-slate-800 file:px-2 file:py-1 file:text-slate-200"
306
+ :disabled="busy || !uploadView.trim()"
307
+ class="text-[12px] text-slate-300 file:mr-2 file:rounded file:border-0 file:bg-slate-800 file:px-2 file:py-1 file:text-slate-200 disabled:opacity-40"
234
308
  @change="onFilePicked"
235
309
  />
236
310
  </div>
311
+ <p class="mt-1.5 text-[10px] text-slate-600">
312
+ {{
313
+ uploadView.trim()
314
+ ? 'Tip: drop an image straight onto a pair above to set its reference.'
315
+ : 'Enter a view name first, then choose a file. Or drop an image straight onto a pair above.'
316
+ }}
317
+ </p>
237
318
  </section>
238
319
 
239
320
  <!-- Request fix -->
@@ -241,30 +322,25 @@ async function onFilePicked(e: Event) {
241
322
  v-if="awaitingHuman"
242
323
  class="rounded-lg border border-slate-800 bg-slate-900/60 p-3"
243
324
  >
244
- <div class="flex items-center justify-between">
245
- <h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
246
- Needs changes?
247
- </h3>
248
- <button
249
- class="text-[12px] text-slate-400 hover:text-slate-200"
250
- @click="showFindings = !showFindings"
251
- >
252
- {{ showFindings ? 'Cancel' : 'Request a fix' }}
253
- </button>
254
- </div>
255
- <div v-if="showFindings" class="mt-2 space-y-2">
256
- <textarea
257
- v-model="findings"
258
- rows="4"
259
- placeholder="Describe what looks wrong — the Fixer agent gets this as context."
260
- class="w-full rounded-md border border-slate-700 bg-slate-950 px-3 py-2 text-[13px] text-slate-200 placeholder:text-slate-600 focus:border-amber-500 focus:outline-none"
261
- />
325
+ <h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
326
+ Request a fix
327
+ </h3>
328
+ <textarea
329
+ v-model="globalFindings"
330
+ rows="3"
331
+ placeholder="Anything else the Fixer should know (in addition to any per-view notes above)."
332
+ class="w-full rounded-md border border-slate-700 bg-slate-950 px-3 py-2 text-[13px] text-slate-200 placeholder:text-slate-600 focus:border-amber-500 focus:outline-none"
333
+ />
334
+ <div class="mt-2 flex items-center justify-between">
335
+ <span class="text-[11px] text-slate-500">
336
+ Per-view notes are folded in automatically.
337
+ </span>
262
338
  <UButton
263
339
  size="sm"
264
340
  color="warning"
265
341
  icon="i-lucide-wrench"
266
342
  :loading="busy"
267
- :disabled="busy || !findings.trim()"
343
+ :disabled="busy || !hasFindings"
268
344
  @click="submitFix"
269
345
  >
270
346
  Send to Fixer
@@ -300,7 +376,9 @@ async function onFilePicked(e: Event) {
300
376
  >
301
377
  {{ r.outcome ?? 'in progress' }}
302
378
  </span>
303
- <p v-if="r.findings" class="leading-snug text-slate-400">{{ r.findings }}</p>
379
+ <p v-if="r.findings" class="whitespace-pre-wrap leading-snug text-slate-400">
380
+ {{ r.findings }}
381
+ </p>
304
382
  </div>
305
383
  </li>
306
384
  </ol>
@@ -353,5 +431,13 @@ async function onFilePicked(e: Event) {
353
431
  </footer>
354
432
  </div>
355
433
  </div>
434
+
435
+ <!-- Shared zoom/pan viewer for any screenshot in the gallery. -->
436
+ <ArtifactLightbox
437
+ v-model:open="lightboxOpen"
438
+ v-model:index="lightboxIndex"
439
+ :items="lightboxItems"
440
+ :blobs="blobs"
441
+ />
356
442
  </Teleport>
357
443
  </template>