@cat-factory/app 0.42.1 → 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/board/RecurringPipelineModal.vue +16 -1
- package/app/components/layout/IntegrationsHub.vue +2 -1
- package/app/components/media/ArtifactLightbox.vue +273 -0
- package/app/components/media/ImageCompare.vue +305 -0
- package/app/components/settings/IssueTrackerPanel.vue +96 -6
- 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/tracker.ts +2 -0
- package/app/stores/visualConfirm.ts +6 -35
- package/app/types/domain.ts +1 -0
- package/package.json +2 -2
|
@@ -25,6 +25,7 @@ const toast = useToast()
|
|
|
25
25
|
// --- filing tracker + writeback (one Save, persisted on tracker settings) -----
|
|
26
26
|
const trackerKind = ref<TrackerKind | null>(null)
|
|
27
27
|
const jiraProjectKey = ref('')
|
|
28
|
+
const linearTeamId = ref('')
|
|
28
29
|
const commentOnPrOpen = ref(false)
|
|
29
30
|
const resolveOnMerge = ref(false)
|
|
30
31
|
const saving = ref(false)
|
|
@@ -32,6 +33,7 @@ const saving = ref(false)
|
|
|
32
33
|
function hydrate() {
|
|
33
34
|
trackerKind.value = tracker.settings.tracker
|
|
34
35
|
jiraProjectKey.value = tracker.settings.jiraProjectKey ?? ''
|
|
36
|
+
linearTeamId.value = tracker.settings.linearTeamId ?? ''
|
|
35
37
|
commentOnPrOpen.value = tracker.settings.writebackCommentOnPrOpen
|
|
36
38
|
resolveOnMerge.value = tracker.settings.writebackResolveOnMerge
|
|
37
39
|
}
|
|
@@ -48,17 +50,22 @@ watch(() => tracker.settings, hydrate)
|
|
|
48
50
|
// Per-source live state (available = usable now; enabled = offered to the workspace).
|
|
49
51
|
const github = computed(() => tasks.descriptorFor('github'))
|
|
50
52
|
const jira = computed(() => tasks.descriptorFor('jira'))
|
|
53
|
+
const linear = computed(() => tasks.descriptorFor('linear'))
|
|
51
54
|
|
|
52
55
|
// A tracker can only file where it can authenticate: GitHub rides the installed
|
|
53
|
-
// App, Jira
|
|
54
|
-
// won't file until set up), but we surface the gap inline.
|
|
56
|
+
// App, Jira/Linear need a connection. Selecting an unusable tracker is allowed (it
|
|
57
|
+
// just won't file until set up), but we surface the gap inline.
|
|
55
58
|
const githubAvailable = computed(() => github.value?.available ?? false)
|
|
56
59
|
const jiraConnected = computed(() => tasks.isConnected('jira'))
|
|
60
|
+
const linearConnected = computed(() => tasks.isConnected('linear'))
|
|
57
61
|
|
|
58
|
-
// Jira needs a project key to file into; block Save on
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
)
|
|
62
|
+
// Jira needs a project key and Linear needs a team id to file into; block Save on
|
|
63
|
+
// an empty one when that tracker is picked.
|
|
64
|
+
const canSave = computed(() => {
|
|
65
|
+
if (trackerKind.value === 'jira') return jiraProjectKey.value.trim().length > 0
|
|
66
|
+
if (trackerKind.value === 'linear') return linearTeamId.value.trim().length > 0
|
|
67
|
+
return true
|
|
68
|
+
})
|
|
62
69
|
|
|
63
70
|
async function save() {
|
|
64
71
|
if (!canSave.value) return
|
|
@@ -67,6 +74,7 @@ async function save() {
|
|
|
67
74
|
await tracker.save({
|
|
68
75
|
tracker: trackerKind.value,
|
|
69
76
|
jiraProjectKey: trackerKind.value === 'jira' ? jiraProjectKey.value.trim() : null,
|
|
77
|
+
linearTeamId: trackerKind.value === 'linear' ? linearTeamId.value.trim() : null,
|
|
70
78
|
writebackCommentOnPrOpen: commentOnPrOpen.value,
|
|
71
79
|
writebackResolveOnMerge: resolveOnMerge.value,
|
|
72
80
|
})
|
|
@@ -197,6 +205,15 @@ const STATUS_UI: Record<
|
|
|
197
205
|
>
|
|
198
206
|
Jira
|
|
199
207
|
</UButton>
|
|
208
|
+
<UButton
|
|
209
|
+
icon="i-lucide-square-kanban"
|
|
210
|
+
size="sm"
|
|
211
|
+
:color="trackerKind === 'linear' ? 'primary' : 'neutral'"
|
|
212
|
+
:variant="trackerKind === 'linear' ? 'solid' : 'subtle'"
|
|
213
|
+
@click="trackerKind = 'linear'"
|
|
214
|
+
>
|
|
215
|
+
Linear
|
|
216
|
+
</UButton>
|
|
200
217
|
</div>
|
|
201
218
|
|
|
202
219
|
<!-- Inline readiness hints for the picked tracker. -->
|
|
@@ -210,6 +227,14 @@ const STATUS_UI: Record<
|
|
|
210
227
|
<button class="underline" @click="ui.openTaskConnect('jira')">Connect it</button> to file
|
|
211
228
|
and link issues.
|
|
212
229
|
</p>
|
|
230
|
+
<p
|
|
231
|
+
v-else-if="trackerKind === 'linear' && !linearConnected"
|
|
232
|
+
class="text-[11px] text-amber-400"
|
|
233
|
+
>
|
|
234
|
+
Linear isn't connected yet.
|
|
235
|
+
<button class="underline" @click="ui.openTaskConnect('linear')">Connect it</button> to file
|
|
236
|
+
and link issues.
|
|
237
|
+
</p>
|
|
213
238
|
|
|
214
239
|
<UFormField v-if="trackerKind === 'jira'" label="Jira project key" class="w-48">
|
|
215
240
|
<UInput v-model="jiraProjectKey" placeholder="ENG" size="sm" class="w-full" />
|
|
@@ -217,6 +242,15 @@ const STATUS_UI: Record<
|
|
|
217
242
|
<span class="text-[11px] text-slate-500">New tickets are filed under this project.</span>
|
|
218
243
|
</template>
|
|
219
244
|
</UFormField>
|
|
245
|
+
|
|
246
|
+
<UFormField v-if="trackerKind === 'linear'" label="Linear team id" class="w-64">
|
|
247
|
+
<UInput v-model="linearTeamId" placeholder="team_…" size="sm" class="w-full" />
|
|
248
|
+
<template #help>
|
|
249
|
+
<span class="text-[11px] text-slate-500">
|
|
250
|
+
New issues are created under this team (Linear requires a team to create an issue).
|
|
251
|
+
</span>
|
|
252
|
+
</template>
|
|
253
|
+
</UFormField>
|
|
220
254
|
</section>
|
|
221
255
|
|
|
222
256
|
<!-- 2. Linking sources ---------------------------------------------------->
|
|
@@ -344,6 +378,62 @@ const STATUS_UI: Record<
|
|
|
344
378
|
:ui="{ description: 'text-[11px]' }"
|
|
345
379
|
/>
|
|
346
380
|
</div>
|
|
381
|
+
|
|
382
|
+
<!-- Linear source -->
|
|
383
|
+
<div class="rounded-lg border border-slate-800 bg-slate-800/40 px-3 py-2.5">
|
|
384
|
+
<div class="flex items-center justify-between gap-2">
|
|
385
|
+
<div class="flex min-w-0 items-center gap-2.5">
|
|
386
|
+
<UIcon name="i-lucide-square-kanban" class="h-5 w-5 shrink-0 text-slate-300" />
|
|
387
|
+
<div class="min-w-0">
|
|
388
|
+
<div class="text-sm font-medium text-slate-200">Linear</div>
|
|
389
|
+
<div class="text-[11px] text-slate-500">
|
|
390
|
+
{{ linearConnected ? 'Connected.' : 'Connect with a Linear personal API key.' }}
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
<div class="flex shrink-0 items-center gap-2">
|
|
395
|
+
<UButton
|
|
396
|
+
v-if="linearConnected"
|
|
397
|
+
size="xs"
|
|
398
|
+
color="neutral"
|
|
399
|
+
variant="ghost"
|
|
400
|
+
icon="i-lucide-stethoscope"
|
|
401
|
+
:loading="tasks.checking === 'linear'"
|
|
402
|
+
@click="checkSetup('linear')"
|
|
403
|
+
>
|
|
404
|
+
Check setup
|
|
405
|
+
</UButton>
|
|
406
|
+
<USwitch
|
|
407
|
+
v-if="linear?.available"
|
|
408
|
+
:model-value="linear?.enabled ?? false"
|
|
409
|
+
:loading="togglingSource === 'linear'"
|
|
410
|
+
@update:model-value="(v: boolean) => toggleSource('linear', v)"
|
|
411
|
+
/>
|
|
412
|
+
<UButton
|
|
413
|
+
v-else
|
|
414
|
+
size="xs"
|
|
415
|
+
color="neutral"
|
|
416
|
+
variant="soft"
|
|
417
|
+
icon="i-lucide-plug"
|
|
418
|
+
@click="ui.openTaskConnect('linear')"
|
|
419
|
+
>
|
|
420
|
+
Connect
|
|
421
|
+
</UButton>
|
|
422
|
+
</div>
|
|
423
|
+
</div>
|
|
424
|
+
<UAlert
|
|
425
|
+
v-if="tasks.diagnostics.linear"
|
|
426
|
+
class="mt-2.5"
|
|
427
|
+
:color="STATUS_UI[tasks.diagnostics.linear.status].color"
|
|
428
|
+
variant="subtle"
|
|
429
|
+
:icon="STATUS_UI[tasks.diagnostics.linear.status].icon"
|
|
430
|
+
:description="
|
|
431
|
+
tasks.diagnostics.linear.message +
|
|
432
|
+
(tasks.diagnostics.linear.detail ? ` ${tasks.diagnostics.linear.detail}` : '')
|
|
433
|
+
"
|
|
434
|
+
:ui="{ description: 'text-[11px]' }"
|
|
435
|
+
/>
|
|
436
|
+
</div>
|
|
347
437
|
</section>
|
|
348
438
|
|
|
349
439
|
<!-- 3. Writeback ---------------------------------------------------------->
|
|
@@ -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>
|