@cat-factory/app 0.43.0 → 0.44.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.
- package/app/components/media/ArtifactLightbox.vue +273 -0
- package/app/components/media/ImageCompare.vue +305 -0
- package/app/components/testing/TestReportWindow.vue +149 -8
- package/app/components/visualConfirm/VisualConfirmationWindow.vue +190 -104
- package/app/composables/useArtifactBlobs.ts +120 -0
- package/app/composables/useFocusTrap.ts +72 -0
- package/app/stores/visualConfirm.ts +6 -35
- package/app/types/domain.ts +1 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
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
|
-
//
|
|
18
|
-
// (potentially large) blob bytes don't linger
|
|
19
|
-
|
|
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
|
|
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
|
|
56
|
-
void
|
|
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
|
-
|
|
63
|
-
const
|
|
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
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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 || !
|
|
88
|
-
await visualConfirm.requestFix(blockId.value,
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
<!--
|
|
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
|
-
<
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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 || !
|
|
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">
|
|
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>
|