@cat-factory/app 0.137.2 → 0.138.1

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.
@@ -14,6 +14,7 @@ import type {
14
14
  // The round outcome union (contracts state it inline, so derive it off the round type).
15
15
  type HumanTestRoundOutcome = NonNullable<HumanTestRound['outcome']>
16
16
  import StepRunMeta from '~/components/panels/StepRunMeta.vue'
17
+ import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
17
18
 
18
19
  const board = useBoardStore()
19
20
  const execution = useExecutionStore()
@@ -23,10 +24,16 @@ const toast = useToast()
23
24
  const access = useWorkspaceAccess()
24
25
  const { confirmAction, toastDone } = useConfirmAction()
25
26
 
26
- // Shared seam contract (open/blockId/close + Escape). No `onOpen` loader: the gate state
27
- // rides on the execution step, pushed over the stream.
28
- const { open, blockId, instanceId, stepIndex, close } = useResultView('human-test')
27
+ // Shared seam contract (open/blockId/close). No `onOpen` loader: the gate state rides on the
28
+ // execution step, pushed over the stream. `manageEscape: false` — `ResultWindowShell` owns
29
+ // Escape (and focus trap + scroll lock + stacking).
30
+ const { open, blockId, instanceId, stepIndex, close } = useResultView('human-test', {
31
+ manageEscape: false,
32
+ })
29
33
  const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
34
+ const headerTitle = computed(() =>
35
+ block.value ? t('humanTest.titleWithBlock', { title: block.value.title }) : t('humanTest.title'),
36
+ )
30
37
 
31
38
  const instance = computed(() =>
32
39
  instanceId.value === null ? null : (execution.getInstance(instanceId.value) ?? null),
@@ -135,250 +142,219 @@ const canDestroy = computed(
135
142
  </script>
136
143
 
137
144
  <template>
138
- <Teleport to="body">
139
- <div
140
- v-if="open"
141
- class="fixed inset-0 z-50 flex max-h-[100dvh] items-stretch justify-center bg-slate-950/70 backdrop-blur-sm"
142
- @click.self="close"
143
- >
145
+ <ResultWindowShell
146
+ :open="open"
147
+ icon="i-lucide-user-check"
148
+ icon-class="bg-amber-500/15 text-amber-300"
149
+ :title="headerTitle"
150
+ :subtitle="phase ? t(PHASE_LABEL[phase]) : t('humanTest.subtitle')"
151
+ width="3xl"
152
+ @close="close"
153
+ >
154
+ <div class="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
144
155
  <div
145
- class="m-4 flex w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
146
- role="dialog"
147
- aria-modal="true"
156
+ v-if="!ht"
157
+ class="flex flex-col items-center justify-center gap-2 py-10 text-center text-slate-400"
148
158
  >
149
- <!-- Header -->
150
- <header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
151
- <span
152
- class="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-500/15 text-amber-300"
153
- >
154
- <UIcon name="i-lucide-user-check" class="h-4 w-4" />
155
- </span>
156
- <div class="min-w-0 flex-1">
157
- <h2 class="truncate text-sm font-semibold text-slate-100">
158
- {{
159
- block ? t('humanTest.titleWithBlock', { title: block.title }) : t('humanTest.title')
160
- }}
161
- </h2>
162
- <p class="truncate text-[11px] text-slate-400">
163
- {{ phase ? t(PHASE_LABEL[phase]) : t('humanTest.subtitle') }}
159
+ <UIcon name="i-lucide-user-check" class="h-8 w-8 opacity-40" />
160
+ <p class="text-sm">{{ t('humanTest.notStarted') }}</p>
161
+ </div>
162
+
163
+ <template v-else>
164
+ <!-- Environment -->
165
+ <section class="rounded-lg border border-slate-800 bg-slate-900/60 p-4">
166
+ <h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
167
+ {{ t('humanTest.environment.heading') }}
168
+ </h3>
169
+ <div v-if="env" class="space-y-2">
170
+ <div class="flex items-center gap-2 text-[13px]">
171
+ <UIcon
172
+ name="i-lucide-circle-dot"
173
+ class="h-3.5 w-3.5"
174
+ :class="ENV_STATUS_COLOR[env.status]"
175
+ />
176
+ <span :class="ENV_STATUS_COLOR[env.status]">{{
177
+ t(ENV_STATUS_LABEL[env.status])
178
+ }}</span>
179
+ </div>
180
+ <a
181
+ v-if="env.url"
182
+ :href="env.url"
183
+ target="_blank"
184
+ rel="noopener"
185
+ class="inline-flex items-center gap-1.5 break-all text-[13px] text-sky-300 hover:underline"
186
+ >
187
+ <UIcon name="i-lucide-external-link" class="h-3.5 w-3.5 shrink-0" />
188
+ {{ env.url }}
189
+ </a>
190
+ <p v-else class="text-[12px] italic text-slate-500">
191
+ {{ t('humanTest.environment.noUrl') }}
192
+ </p>
193
+ <p v-if="env.expiresAt" class="text-[11px] text-slate-500">
194
+ {{ t('humanTest.environment.expires', { date: d(new Date(env.expiresAt), 'long') }) }}
164
195
  </p>
165
196
  </div>
166
- <button
167
- class="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
168
- @click="close"
169
- >
170
- <UIcon name="i-lucide-x" class="h-4 w-4" />
171
- </button>
172
- </header>
197
+ <p v-else class="text-[12px] text-amber-300/90">
198
+ {{ ht.degradedReason ?? t('humanTest.environment.none') }}
199
+ </p>
200
+ <p v-if="env && ht.degradedReason" class="mt-2 text-[12px] text-amber-300/90">
201
+ {{ ht.degradedReason }}
202
+ </p>
173
203
 
174
- <div class="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
175
- <div
176
- v-if="!ht"
177
- class="flex flex-col items-center justify-center gap-2 py-10 text-center text-slate-400"
178
- >
179
- <UIcon name="i-lucide-user-check" class="h-8 w-8 opacity-40" />
180
- <p class="text-sm">{{ t('humanTest.notStarted') }}</p>
204
+ <!-- Env management -->
205
+ <div class="mt-3 flex flex-wrap gap-2">
206
+ <UButton
207
+ size="xs"
208
+ variant="soft"
209
+ color="neutral"
210
+ icon="i-lucide-refresh-cw"
211
+ :loading="busy"
212
+ :disabled="busy || !canManageEnv || !access.canExecuteRuns.value"
213
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
214
+ @click="recreate"
215
+ >
216
+ {{ t('humanTest.actions.recreate') }}
217
+ </UButton>
218
+ <UButton
219
+ size="xs"
220
+ variant="soft"
221
+ color="neutral"
222
+ icon="i-lucide-trash-2"
223
+ :disabled="busy || !canDestroy || !access.canExecuteRuns.value"
224
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
225
+ @click="destroy"
226
+ >
227
+ {{ t('humanTest.actions.destroy') }}
228
+ </UButton>
229
+ <UButton
230
+ size="xs"
231
+ variant="soft"
232
+ color="neutral"
233
+ icon="i-lucide-git-merge"
234
+ :loading="busy"
235
+ :disabled="busy || !canManageEnv || !access.canExecuteRuns.value"
236
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
237
+ @click="pullMain"
238
+ >
239
+ {{ t('humanTest.actions.pullMain') }}
240
+ </UButton>
181
241
  </div>
242
+ </section>
182
243
 
183
- <template v-else>
184
- <!-- Environment -->
185
- <section class="rounded-lg border border-slate-800 bg-slate-900/60 p-4">
186
- <h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
187
- {{ t('humanTest.environment.heading') }}
188
- </h3>
189
- <div v-if="env" class="space-y-2">
190
- <div class="flex items-center gap-2 text-[13px]">
191
- <UIcon
192
- name="i-lucide-circle-dot"
193
- class="h-3.5 w-3.5"
194
- :class="ENV_STATUS_COLOR[env.status]"
195
- />
196
- <span :class="ENV_STATUS_COLOR[env.status]">{{
197
- t(ENV_STATUS_LABEL[env.status])
198
- }}</span>
199
- </div>
200
- <a
201
- v-if="env.url"
202
- :href="env.url"
203
- target="_blank"
204
- rel="noopener"
205
- class="inline-flex items-center gap-1.5 break-all text-[13px] text-sky-300 hover:underline"
206
- >
207
- <UIcon name="i-lucide-external-link" class="h-3.5 w-3.5 shrink-0" />
208
- {{ env.url }}
209
- </a>
210
- <p v-else class="text-[12px] italic text-slate-500">
211
- {{ t('humanTest.environment.noUrl') }}
212
- </p>
213
- <p v-if="env.expiresAt" class="text-[11px] text-slate-500">
214
- {{
215
- t('humanTest.environment.expires', { date: d(new Date(env.expiresAt), 'long') })
216
- }}
217
- </p>
218
- </div>
219
- <p v-else class="text-[12px] text-amber-300/90">
220
- {{ ht.degradedReason ?? t('humanTest.environment.none') }}
221
- </p>
222
- <p v-if="env && ht.degradedReason" class="mt-2 text-[12px] text-amber-300/90">
223
- {{ ht.degradedReason }}
224
- </p>
225
-
226
- <!-- Env management -->
227
- <div class="mt-3 flex flex-wrap gap-2">
228
- <UButton
229
- size="xs"
230
- variant="soft"
231
- color="neutral"
232
- icon="i-lucide-refresh-cw"
233
- :loading="busy"
234
- :disabled="busy || !canManageEnv || !access.canExecuteRuns.value"
235
- :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
236
- @click="recreate"
237
- >
238
- {{ t('humanTest.actions.recreate') }}
239
- </UButton>
240
- <UButton
241
- size="xs"
242
- variant="soft"
243
- color="neutral"
244
- icon="i-lucide-trash-2"
245
- :disabled="busy || !canDestroy || !access.canExecuteRuns.value"
246
- :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
247
- @click="destroy"
248
- >
249
- {{ t('humanTest.actions.destroy') }}
250
- </UButton>
251
- <UButton
252
- size="xs"
253
- variant="soft"
254
- color="neutral"
255
- icon="i-lucide-git-merge"
256
- :loading="busy"
257
- :disabled="busy || !canManageEnv || !access.canExecuteRuns.value"
258
- :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
259
- @click="pullMain"
260
- >
261
- {{ t('humanTest.actions.pullMain') }}
262
- </UButton>
263
- </div>
264
- </section>
265
-
266
- <!-- Working state -->
267
- <p
268
- v-if="working"
269
- class="flex items-center gap-2 rounded-lg border border-slate-800 bg-slate-950/40 px-3 py-2 text-[12px] text-slate-300"
270
- >
271
- <UIcon name="i-lucide-loader" class="h-3.5 w-3.5 animate-spin text-amber-300" />
272
- {{ phase ? t(PHASE_LABEL[phase]) : '' }}
273
- </p>
244
+ <!-- Working state -->
245
+ <p
246
+ v-if="working"
247
+ class="flex items-center gap-2 rounded-lg border border-slate-800 bg-slate-950/40 px-3 py-2 text-[12px] text-slate-300"
248
+ >
249
+ <UIcon name="i-lucide-loader" class="h-3.5 w-3.5 animate-spin text-amber-300" />
250
+ {{ phase ? t(PHASE_LABEL[phase]) : '' }}
251
+ </p>
274
252
 
275
- <!-- Findings / fix -->
276
- <section
277
- v-if="awaitingHuman"
278
- class="rounded-lg border border-slate-800 bg-slate-900/60 p-4"
253
+ <!-- Findings / fix -->
254
+ <section
255
+ v-if="awaitingHuman"
256
+ class="rounded-lg border border-slate-800 bg-slate-900/60 p-4"
257
+ >
258
+ <div class="flex items-center justify-between">
259
+ <h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
260
+ {{ t('humanTest.fix.heading') }}
261
+ </h3>
262
+ <button
263
+ class="text-[12px] text-slate-400 hover:text-slate-200"
264
+ @click="showFindings = !showFindings"
279
265
  >
280
- <div class="flex items-center justify-between">
281
- <h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
282
- {{ t('humanTest.fix.heading') }}
283
- </h3>
284
- <button
285
- class="text-[12px] text-slate-400 hover:text-slate-200"
286
- @click="showFindings = !showFindings"
287
- >
288
- {{ showFindings ? t('humanTest.fix.cancel') : t('humanTest.fix.requestFix') }}
289
- </button>
290
- </div>
291
- <div v-if="showFindings" class="mt-2 space-y-2">
292
- <textarea
293
- v-model="findings"
294
- rows="4"
295
- :placeholder="t('humanTest.fix.placeholder')"
296
- 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 focus-visible:ring-2 focus-visible:ring-amber-500/60"
297
- />
298
- <UButton
299
- size="sm"
300
- color="warning"
301
- icon="i-lucide-wrench"
302
- :loading="busy"
303
- :disabled="busy || !findings.trim() || !access.canExecuteRuns.value"
304
- :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
305
- @click="submitFix"
306
- >
307
- {{ t('humanTest.fix.send') }}
308
- </UButton>
309
- </div>
310
- </section>
311
-
312
- <!-- Rounds history -->
313
- <section
314
- v-if="ht.rounds && ht.rounds.length"
315
- class="rounded-lg border border-slate-800 bg-slate-900/60 p-4"
266
+ {{ showFindings ? t('humanTest.fix.cancel') : t('humanTest.fix.requestFix') }}
267
+ </button>
268
+ </div>
269
+ <div v-if="showFindings" class="mt-2 space-y-2">
270
+ <textarea
271
+ v-model="findings"
272
+ rows="4"
273
+ :placeholder="t('humanTest.fix.placeholder')"
274
+ 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 focus-visible:ring-2 focus-visible:ring-amber-500/60"
275
+ />
276
+ <UButton
277
+ size="sm"
278
+ color="warning"
279
+ icon="i-lucide-wrench"
280
+ :loading="busy"
281
+ :disabled="busy || !findings.trim() || !access.canExecuteRuns.value"
282
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
283
+ @click="submitFix"
316
284
  >
317
- <h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
318
- {{ t('humanTest.history.heading', { count: ht.attempts }, ht.attempts) }}
319
- </h3>
320
- <ol class="space-y-2">
321
- <li v-for="(r, i) in ht.rounds" :key="i" class="flex items-start gap-2 text-[12px]">
322
- <UIcon
323
- :name="r.kind === 'fix' ? 'i-lucide-wrench' : 'i-lucide-git-merge'"
324
- class="mt-0.5 h-3.5 w-3.5 shrink-0 text-slate-400"
325
- />
326
- <div class="min-w-0 flex-1">
327
- <span class="text-slate-200">{{
328
- r.kind === 'fix'
329
- ? t('humanTest.history.fixRequested')
330
- : t('humanTest.history.pulledMain')
331
- }}</span>
332
- <span
333
- class="ms-1.5 rounded px-1 text-[10px] uppercase"
334
- :class="
335
- r.outcome === 'completed'
336
- ? 'bg-emerald-500/15 text-emerald-300'
337
- : r.outcome === 'failed'
338
- ? 'bg-rose-500/15 text-rose-300'
339
- : 'bg-slate-500/15 text-slate-300'
340
- "
341
- >
342
- {{
343
- r.outcome
344
- ? t(ROUND_OUTCOME_LABEL[r.outcome])
345
- : t('humanTest.roundOutcome.inProgress')
346
- }}
347
- </span>
348
- <p v-if="r.findings" class="leading-snug text-slate-400">{{ r.findings }}</p>
349
- </div>
350
- </li>
351
- </ol>
352
- </section>
353
- </template>
354
- </div>
285
+ {{ t('humanTest.fix.send') }}
286
+ </UButton>
287
+ </div>
288
+ </section>
355
289
 
356
- <!-- Footer: the primary confirm action -->
357
- <footer
358
- v-if="ht"
359
- class="flex items-center justify-between gap-3 border-t border-slate-800 px-5 py-3"
290
+ <!-- Rounds history -->
291
+ <section
292
+ v-if="ht.rounds && ht.rounds.length"
293
+ class="rounded-lg border border-slate-800 bg-slate-900/60 p-4"
360
294
  >
361
- <StepRunMeta
362
- v-if="step"
363
- :step="step"
364
- :instance-id="instanceId ?? undefined"
365
- :step-number="stepIndex === null ? undefined : stepIndex + 1"
366
- :total-steps="instance?.steps.length"
367
- :run-failed="instance?.status === 'failed'"
368
- :failure-at="instance?.failure?.occurredAt"
369
- />
370
- <UButton
371
- color="primary"
372
- icon="i-lucide-circle-check"
373
- :loading="busy"
374
- :disabled="busy || !awaitingHuman || !access.canExecuteRuns.value"
375
- :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
376
- @click="confirm"
377
- >
378
- {{ t('humanTest.confirm') }}
379
- </UButton>
380
- </footer>
381
- </div>
295
+ <h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
296
+ {{ t('humanTest.history.heading', { count: ht.attempts }, ht.attempts) }}
297
+ </h3>
298
+ <ol class="space-y-2">
299
+ <li v-for="(r, i) in ht.rounds" :key="i" class="flex items-start gap-2 text-[12px]">
300
+ <UIcon
301
+ :name="r.kind === 'fix' ? 'i-lucide-wrench' : 'i-lucide-git-merge'"
302
+ class="mt-0.5 h-3.5 w-3.5 shrink-0 text-slate-400"
303
+ />
304
+ <div class="min-w-0 flex-1">
305
+ <span class="text-slate-200">{{
306
+ r.kind === 'fix'
307
+ ? t('humanTest.history.fixRequested')
308
+ : t('humanTest.history.pulledMain')
309
+ }}</span>
310
+ <span
311
+ class="ms-1.5 rounded px-1 text-[10px] uppercase"
312
+ :class="
313
+ r.outcome === 'completed'
314
+ ? 'bg-emerald-500/15 text-emerald-300'
315
+ : r.outcome === 'failed'
316
+ ? 'bg-rose-500/15 text-rose-300'
317
+ : 'bg-slate-500/15 text-slate-300'
318
+ "
319
+ >
320
+ {{
321
+ r.outcome
322
+ ? t(ROUND_OUTCOME_LABEL[r.outcome])
323
+ : t('humanTest.roundOutcome.inProgress')
324
+ }}
325
+ </span>
326
+ <p v-if="r.findings" class="leading-snug text-slate-400">{{ r.findings }}</p>
327
+ </div>
328
+ </li>
329
+ </ol>
330
+ </section>
331
+ </template>
382
332
  </div>
383
- </Teleport>
333
+
334
+ <!-- Footer: the primary confirm action -->
335
+ <footer
336
+ v-if="ht"
337
+ class="flex items-center justify-between gap-3 border-t border-slate-800 px-5 py-3"
338
+ >
339
+ <StepRunMeta
340
+ v-if="step"
341
+ :step="step"
342
+ :instance-id="instanceId ?? undefined"
343
+ :step-number="stepIndex === null ? undefined : stepIndex + 1"
344
+ :total-steps="instance?.steps.length"
345
+ :run-failed="instance?.status === 'failed'"
346
+ :failure-at="instance?.failure?.occurredAt"
347
+ />
348
+ <UButton
349
+ color="primary"
350
+ icon="i-lucide-circle-check"
351
+ :loading="busy"
352
+ :disabled="busy || !awaitingHuman || !access.canExecuteRuns.value"
353
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
354
+ @click="confirm"
355
+ >
356
+ {{ t('humanTest.confirm') }}
357
+ </UButton>
358
+ </footer>
359
+ </ResultWindowShell>
384
360
  </template>
@@ -7,8 +7,8 @@
7
7
  // Zoom is pure CSS `transform` (GPU, no canvas), so even large PNGs stay smooth. Keyboard:
8
8
  // Esc close · ←/→ prev/next · +/- zoom · 0 reset · double-click toggle fit↔2×.
9
9
  import { computed, ref, watch } from 'vue'
10
+ import { useModalBehavior } from '@modular-vue/core'
10
11
  import type { ArtifactBlobs } from '~/composables/useArtifactBlobs'
11
- import { useFocusTrap } from '~/composables/useFocusTrap'
12
12
 
13
13
  interface LightboxItem {
14
14
  artifactId: string
@@ -36,13 +36,19 @@ const scale = ref(1)
36
36
  const tx = ref(0)
37
37
  const ty = ref(0)
38
38
 
39
- // Move focus into the lightbox on open + trap Tab within it (and restore focus on close).
40
- // It's the topmost surface, so its trap stays live even over an owning review window.
41
- const dialogRoot = ref<HTMLElement | null>(null)
42
- useFocusTrap(
43
- dialogRoot,
44
- computed(() => props.open),
45
- )
39
+ // Modal behaviour via the shared overlay stack (`@modular-vue/core`, the slice-5 release):
40
+ // focus-trap + return, body-scroll lock, and because it pushes onto the shared stack while
41
+ // open it becomes the TOP overlay over an owning review window (`ResultWindowShell`), so
42
+ // Escape closes the lightbox first and the owner's trap goes inert until it closes. This is
43
+ // the reconciliation that replaces the old `active: open && !lightboxOpen` guard the windows
44
+ // used to fight over Tab.
45
+ function close() {
46
+ emit('update:open', false)
47
+ }
48
+ const { dialogRef, isTop } = useModalBehavior({
49
+ active: () => props.open,
50
+ onClose: close,
51
+ })
46
52
 
47
53
  const current = computed(() => props.items[props.index] ?? null)
48
54
  const total = computed(() => props.items.length)
@@ -74,9 +80,6 @@ watch(
74
80
  { immediate: true },
75
81
  )
76
82
 
77
- function close() {
78
- emit('update:open', false)
79
- }
80
83
  function go(delta: number) {
81
84
  if (!total.value) return
82
85
  const next = (props.index + delta + total.value) % total.value
@@ -122,13 +125,12 @@ function onPointerUp() {
122
125
  dragging.value = false
123
126
  }
124
127
 
128
+ // Escape / Tab / focus are owned by `useModalBehavior` (the shared stack). This handler owns
129
+ // only the lightbox-specific navigation + zoom keys, gated on being the top overlay so it
130
+ // stays inert if another overlay ever layers above it.
125
131
  function onKey(e: KeyboardEvent) {
126
- if (!props.open) return
132
+ if (!props.open || !isTop.value) return
127
133
  switch (e.key) {
128
- case 'Escape':
129
- e.stopPropagation()
130
- close()
131
- break
132
134
  case 'ArrowLeft':
133
135
  go(-1)
134
136
  break
@@ -148,8 +150,6 @@ function onKey(e: KeyboardEvent) {
148
150
  break
149
151
  }
150
152
  }
151
- // Capture-phase so Esc closes the lightbox BEFORE the underlying window's own Esc handler
152
- // (both use window keydown; the lightbox is the topmost surface so it wins).
153
153
  onMounted(() => window.addEventListener('keydown', onKey, true))
154
154
  onBeforeUnmount(() => window.removeEventListener('keydown', onKey, true))
155
155
  </script>
@@ -158,11 +158,12 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey, true))
158
158
  <Teleport to="body">
159
159
  <div
160
160
  v-if="open"
161
- ref="dialogRoot"
161
+ ref="dialogRef"
162
162
  tabindex="-1"
163
163
  class="fixed inset-0 z-[60] flex flex-col bg-slate-950/95 backdrop-blur-sm focus:outline-none"
164
164
  role="dialog"
165
165
  aria-modal="true"
166
+ data-testid="artifact-lightbox"
166
167
  :aria-label="
167
168
  current
168
169
  ? t('media.lightbox.ariaLabel', { label: current.label })