@cat-factory/app 0.138.1 → 0.139.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.
@@ -12,7 +12,7 @@ import type {
12
12
  RequirementPriority,
13
13
  SpecModule,
14
14
  } from '~/types/spec'
15
- import IconButton from '~/components/common/IconButton.vue'
15
+ import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
16
16
 
17
17
  const { t } = useI18n()
18
18
  const board = useBoardStore()
@@ -121,291 +121,260 @@ function kindLabel(item: RequirementItem): string {
121
121
  </script>
122
122
 
123
123
  <template>
124
- <Teleport to="body">
124
+ <ResultWindowShell
125
+ :open="open"
126
+ icon="i-lucide-scroll-text"
127
+ icon-class="bg-indigo-500/15 text-indigo-300"
128
+ :title="t('spec.title')"
129
+ :subtitle="block ? spec?.service || block.title : undefined"
130
+ variant="centered"
131
+ width="5xl"
132
+ @close="close"
133
+ >
134
+ <!-- view toggle: Gherkin only when the spec (and its feature files) are on main -->
135
+ <template v-if="present" #header-extras>
136
+ <div class="flex items-center rounded-lg border border-slate-700 p-0.5">
137
+ <UButton
138
+ :color="mode === 'structured' ? 'primary' : 'neutral'"
139
+ :variant="mode === 'structured' ? 'soft' : 'ghost'"
140
+ size="xs"
141
+ icon="i-lucide-list-tree"
142
+ @click="
143
+ () => {
144
+ mode = 'structured'
145
+ }
146
+ "
147
+ >
148
+ {{ t('spec.mode.structured') }}
149
+ </UButton>
150
+ <UButton
151
+ :color="mode === 'gherkin' ? 'primary' : 'neutral'"
152
+ :variant="mode === 'gherkin' ? 'soft' : 'ghost'"
153
+ size="xs"
154
+ icon="i-lucide-square-check-big"
155
+ :disabled="!hasGherkin"
156
+ :title="hasGherkin ? t('spec.mode.gherkinTooltip') : t('spec.mode.gherkinNone')"
157
+ @click="
158
+ () => {
159
+ mode = 'gherkin'
160
+ }
161
+ "
162
+ >
163
+ {{ t('spec.mode.gherkin') }}
164
+ </UButton>
165
+ </div>
166
+ </template>
167
+
168
+ <!-- loading -->
125
169
  <div
126
- v-if="open"
127
- class="fixed inset-0 z-50 flex max-h-[100dvh] items-center justify-center bg-slate-950/70 p-4 backdrop-blur-sm"
128
- @click.self="close"
170
+ v-if="loading && !view"
171
+ class="flex flex-1 items-center justify-center gap-2 text-sm text-slate-400"
129
172
  >
130
- <div
131
- class="flex max-h-[90dvh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
132
- role="dialog"
133
- aria-modal="true"
173
+ <UIcon name="i-lucide-loader-circle" class="h-4 w-4 animate-spin" />
174
+ {{ t('spec.loading') }}
175
+ </div>
176
+
177
+ <!-- error -->
178
+ <div
179
+ v-else-if="errored"
180
+ class="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center text-sm text-slate-400"
181
+ >
182
+ <UIcon name="i-lucide-triangle-alert" class="h-6 w-6 text-amber-400" />
183
+ {{ t('spec.error') }}
184
+ <UButton
185
+ icon="i-lucide-rotate-cw"
186
+ color="neutral"
187
+ variant="soft"
188
+ size="xs"
189
+ :loading="loading"
190
+ @click="retry"
134
191
  >
135
- <!-- header -->
136
- <header class="flex items-center gap-3 border-b border-slate-800 px-6 py-4">
137
- <div
138
- class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-indigo-500/15"
139
- >
140
- <UIcon name="i-lucide-scroll-text" class="h-5 w-5 text-indigo-300" />
141
- </div>
142
- <div class="min-w-0">
143
- <h1 class="truncate text-base font-semibold text-white">{{ t('spec.title') }}</h1>
144
- <p v-if="block" class="truncate text-xs text-slate-500">
145
- {{ spec?.service || block.title }}
146
- </p>
192
+ {{ t('common.retry') }}
193
+ </UButton>
194
+ </div>
195
+
196
+ <!-- empty: no spec on the repo's default branch yet -->
197
+ <div
198
+ v-else-if="!present"
199
+ class="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center"
200
+ >
201
+ <UIcon name="i-lucide-scroll-text" class="h-8 w-8 text-slate-600" />
202
+ <div>
203
+ <p class="text-sm font-medium text-slate-300">{{ t('spec.empty.title') }}</p>
204
+ <p class="mx-auto mt-1 max-w-md text-xs text-slate-500">
205
+ {{ t('spec.empty.description') }}
206
+ </p>
207
+ </div>
208
+ </div>
209
+
210
+ <!-- spec body: navigable tree + detail -->
211
+ <div v-else class="flex min-h-0 flex-1">
212
+ <!-- nav: modules → feature groups -->
213
+ <nav class="w-64 shrink-0 overflow-y-auto border-e border-slate-800 px-3 py-4">
214
+ <UButton
215
+ block
216
+ class="mb-2 justify-start"
217
+ :color="selected === null ? 'primary' : 'neutral'"
218
+ :variant="selected === null ? 'soft' : 'ghost'"
219
+ size="xs"
220
+ icon="i-lucide-info"
221
+ @click="
222
+ () => {
223
+ selected = null
224
+ }
225
+ "
226
+ >
227
+ {{ t('spec.overview') }}
228
+ </UButton>
229
+ <div v-for="(mod, mi) in modules" :key="mi" class="mb-3">
230
+ <div class="px-2 pb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
231
+ {{ mod.name }}
147
232
  </div>
148
- <div class="ms-auto flex items-center gap-1.5">
149
- <!-- view toggle: Gherkin only when the spec (and its feature files) are on main -->
150
- <div v-if="present" class="flex items-center rounded-lg border border-slate-700 p-0.5">
151
- <UButton
152
- :color="mode === 'structured' ? 'primary' : 'neutral'"
153
- :variant="mode === 'structured' ? 'soft' : 'ghost'"
154
- size="xs"
155
- icon="i-lucide-list-tree"
156
- @click="
157
- () => {
158
- mode = 'structured'
159
- }
233
+ <ul class="space-y-0.5">
234
+ <li v-for="(group, gi) in mod.groups ?? []" :key="gi">
235
+ <button
236
+ type="button"
237
+ class="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-start text-[13px] transition"
238
+ :class="
239
+ selected?.m === mi && selected?.g === gi
240
+ ? 'bg-indigo-500/15 text-indigo-200'
241
+ : 'text-slate-300 hover:bg-slate-800'
160
242
  "
243
+ @click="selectGroup(mi, gi)"
161
244
  >
162
- {{ t('spec.mode.structured') }}
163
- </UButton>
164
- <UButton
165
- :color="mode === 'gherkin' ? 'primary' : 'neutral'"
166
- :variant="mode === 'gherkin' ? 'soft' : 'ghost'"
167
- size="xs"
168
- icon="i-lucide-square-check-big"
169
- :disabled="!hasGherkin"
170
- :title="hasGherkin ? t('spec.mode.gherkinTooltip') : t('spec.mode.gherkinNone')"
171
- @click="
172
- () => {
173
- mode = 'gherkin'
174
- }
175
- "
176
- >
177
- {{ t('spec.mode.gherkin') }}
178
- </UButton>
179
- </div>
180
- <IconButton
181
- icon="i-lucide-x"
182
- color="neutral"
183
- variant="ghost"
184
- size="sm"
185
- :label="t('common.close')"
186
- @click="close"
187
- />
188
- </div>
189
- </header>
190
-
191
- <!-- loading -->
192
- <div
193
- v-if="loading && !view"
194
- class="flex flex-1 items-center justify-center gap-2 text-sm text-slate-400"
195
- >
196
- <UIcon name="i-lucide-loader-circle" class="h-4 w-4 animate-spin" />
197
- {{ t('spec.loading') }}
245
+ <span class="truncate">{{ group.name }}</span>
246
+ <span class="shrink-0 text-[10px] text-slate-500">{{ reqCount(group) }}</span>
247
+ </button>
248
+ </li>
249
+ <li
250
+ v-if="(mod.groups?.length ?? 0) === 0"
251
+ class="px-2 py-1 text-[11px] italic text-slate-600"
252
+ >
253
+ {{ t('spec.noFeatureGroups') }}
254
+ </li>
255
+ </ul>
198
256
  </div>
257
+ </nav>
199
258
 
200
- <!-- error -->
201
- <div
202
- v-else-if="errored"
203
- class="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center text-sm text-slate-400"
204
- >
205
- <UIcon name="i-lucide-triangle-alert" class="h-6 w-6 text-amber-400" />
206
- {{ t('spec.error') }}
207
- <UButton
208
- icon="i-lucide-rotate-cw"
209
- color="neutral"
210
- variant="soft"
211
- size="xs"
212
- :loading="loading"
213
- @click="retry"
214
- >
215
- {{ t('common.retry') }}
216
- </UButton>
217
- </div>
259
+ <!-- detail -->
260
+ <div class="min-w-0 flex-1 overflow-y-auto px-6 py-5">
261
+ <!-- service overview -->
262
+ <template v-if="selected === null">
263
+ <h2 class="text-lg font-semibold text-white">{{ spec?.service }}</h2>
264
+ <p v-if="spec?.summary" class="mt-2 whitespace-pre-line text-sm text-slate-300">
265
+ {{ spec.summary }}
266
+ </p>
267
+ <p v-else class="mt-2 text-sm text-slate-500">{{ t('spec.noSummary') }}</p>
268
+ <p class="mt-4 text-xs text-slate-500">
269
+ {{
270
+ hasGherkin
271
+ ? t('spec.moduleHintGherkin', { count: modules.length }, modules.length)
272
+ : t('spec.moduleHint', { count: modules.length }, modules.length)
273
+ }}
274
+ </p>
275
+ </template>
218
276
 
219
- <!-- empty: no spec on the repo's default branch yet -->
220
- <div
221
- v-else-if="!present"
222
- class="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center"
223
- >
224
- <UIcon name="i-lucide-scroll-text" class="h-8 w-8 text-slate-600" />
225
- <div>
226
- <p class="text-sm font-medium text-slate-300">{{ t('spec.empty.title') }}</p>
227
- <p class="mx-auto mt-1 max-w-md text-xs text-slate-500">
228
- {{ t('spec.empty.description') }}
229
- </p>
277
+ <!-- selected feature group -->
278
+ <template v-else-if="selectedGroup">
279
+ <div class="mb-1 text-[11px] uppercase tracking-wide text-slate-500">
280
+ {{ selectedModule?.name }}
230
281
  </div>
231
- </div>
282
+ <h2 class="text-lg font-semibold text-white">{{ selectedGroup.name }}</h2>
283
+ <p v-if="selectedGroup.summary" class="mt-1 text-sm text-slate-400">
284
+ {{ selectedGroup.summary }}
285
+ </p>
232
286
 
233
- <!-- spec body: navigable tree + detail -->
234
- <div v-else class="flex min-h-0 flex-1">
235
- <!-- nav: modules → feature groups -->
236
- <nav class="w-64 shrink-0 overflow-y-auto border-e border-slate-800 px-3 py-4">
237
- <UButton
238
- block
239
- class="mb-2 justify-start"
240
- :color="selected === null ? 'primary' : 'neutral'"
241
- :variant="selected === null ? 'soft' : 'ghost'"
242
- size="xs"
243
- icon="i-lucide-info"
244
- @click="
245
- () => {
246
- selected = null
247
- }
248
- "
287
+ <!-- GHERKIN view: the rendered .feature file for this group -->
288
+ <template v-if="mode === 'gherkin'">
289
+ <pre
290
+ v-if="selectedFeature"
291
+ class="mt-4 overflow-x-auto rounded-lg border border-slate-800 bg-slate-950/60 p-4 text-[12.5px] leading-relaxed text-slate-200"
292
+ ><code>{{ selectedFeature.content }}</code></pre>
293
+ <div
294
+ v-else
295
+ class="mt-4 rounded-lg border border-dashed border-slate-700 p-6 text-center text-sm text-slate-500"
249
296
  >
250
- {{ t('spec.overview') }}
251
- </UButton>
252
- <div v-for="(mod, mi) in modules" :key="mi" class="mb-3">
297
+ {{ t('spec.noGherkinForGroup') }}
298
+ </div>
299
+ </template>
300
+
301
+ <!-- STRUCTURED view: requirements + acceptance + domain rules -->
302
+ <template v-else>
303
+ <div v-if="reqCount(selectedGroup) === 0" class="mt-4 text-sm text-slate-500">
304
+ {{ t('spec.noRequirements') }}
305
+ </div>
306
+ <ul class="mt-4 space-y-4">
307
+ <li
308
+ v-for="req in selectedGroup.requirements ?? []"
309
+ :key="req.id"
310
+ class="rounded-lg border border-slate-800 bg-slate-900/60 p-4"
311
+ >
312
+ <div class="flex items-start justify-between gap-3">
313
+ <h3 class="text-sm font-semibold text-slate-100">{{ req.title }}</h3>
314
+ <div class="flex shrink-0 items-center gap-1.5">
315
+ <UBadge :color="priorityMeta(req).chip as any" variant="subtle" size="sm">
316
+ {{ priorityMeta(req).label }}
317
+ </UBadge>
318
+ <UBadge color="neutral" variant="subtle" size="sm">{{ kindLabel(req) }}</UBadge>
319
+ </div>
320
+ </div>
321
+ <p class="mt-1.5 whitespace-pre-line text-[13px] leading-relaxed text-slate-300">
322
+ {{ req.statement }}
323
+ </p>
324
+ <!-- acceptance criteria (Given/When/Then) -->
325
+ <div v-if="(req.acceptance?.length ?? 0) > 0" class="mt-3 space-y-1.5">
326
+ <div
327
+ v-for="ac in req.acceptance ?? []"
328
+ :key="ac.id"
329
+ class="rounded-md border border-slate-800 bg-slate-950/50 px-3 py-2 text-[12.5px] leading-relaxed"
330
+ >
331
+ <p class="text-slate-300">
332
+ <span class="font-semibold text-emerald-400">{{
333
+ t('spec.acceptance.given')
334
+ }}</span>
335
+ {{ ac.given }}
336
+ </p>
337
+ <p class="text-slate-300">
338
+ <span class="font-semibold text-sky-400">{{
339
+ t('spec.acceptance.when')
340
+ }}</span>
341
+ {{ ac.when }}
342
+ </p>
343
+ <p class="text-slate-300">
344
+ <span class="font-semibold text-violet-400">{{
345
+ t('spec.acceptance.then')
346
+ }}</span>
347
+ {{ ac.outcome }}
348
+ </p>
349
+ </div>
350
+ </div>
351
+ </li>
352
+ </ul>
353
+
354
+ <!-- domain rules / invariants scoped to this group -->
355
+ <div v-if="(selectedGroup.rules?.length ?? 0) > 0" class="mt-6">
253
356
  <div
254
- class="px-2 pb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500"
357
+ class="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-400"
255
358
  >
256
- {{ mod.name }}
359
+ <UIcon name="i-lucide-shield-check" class="h-3.5 w-3.5" />
360
+ {{ t('spec.domainRules') }}
257
361
  </div>
258
- <ul class="space-y-0.5">
259
- <li v-for="(group, gi) in mod.groups ?? []" :key="gi">
260
- <button
261
- type="button"
262
- class="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-start text-[13px] transition"
263
- :class="
264
- selected?.m === mi && selected?.g === gi
265
- ? 'bg-indigo-500/15 text-indigo-200'
266
- : 'text-slate-300 hover:bg-slate-800'
267
- "
268
- @click="selectGroup(mi, gi)"
269
- >
270
- <span class="truncate">{{ group.name }}</span>
271
- <span class="shrink-0 text-[10px] text-slate-500">{{ reqCount(group) }}</span>
272
- </button>
273
- </li>
362
+ <ul class="space-y-1.5">
274
363
  <li
275
- v-if="(mod.groups?.length ?? 0) === 0"
276
- class="px-2 py-1 text-[11px] italic text-slate-600"
364
+ v-for="rule in selectedGroup.rules ?? []"
365
+ :key="rule.id"
366
+ class="rounded-md border border-slate-800 bg-slate-900/60 px-3 py-2 text-[13px] text-slate-300"
277
367
  >
278
- {{ t('spec.noFeatureGroups') }}
368
+ {{ rule.rule }}
369
+ <span v-if="rule.rationale" class="text-slate-500">{{
370
+ t('spec.ruleRationale', { rationale: rule.rationale })
371
+ }}</span>
279
372
  </li>
280
373
  </ul>
281
374
  </div>
282
- </nav>
283
-
284
- <!-- detail -->
285
- <div class="min-w-0 flex-1 overflow-y-auto px-6 py-5">
286
- <!-- service overview -->
287
- <template v-if="selected === null">
288
- <h2 class="text-lg font-semibold text-white">{{ spec?.service }}</h2>
289
- <p v-if="spec?.summary" class="mt-2 whitespace-pre-line text-sm text-slate-300">
290
- {{ spec.summary }}
291
- </p>
292
- <p v-else class="mt-2 text-sm text-slate-500">{{ t('spec.noSummary') }}</p>
293
- <p class="mt-4 text-xs text-slate-500">
294
- {{
295
- hasGherkin
296
- ? t('spec.moduleHintGherkin', { count: modules.length }, modules.length)
297
- : t('spec.moduleHint', { count: modules.length }, modules.length)
298
- }}
299
- </p>
300
- </template>
301
-
302
- <!-- selected feature group -->
303
- <template v-else-if="selectedGroup">
304
- <div class="mb-1 text-[11px] uppercase tracking-wide text-slate-500">
305
- {{ selectedModule?.name }}
306
- </div>
307
- <h2 class="text-lg font-semibold text-white">{{ selectedGroup.name }}</h2>
308
- <p v-if="selectedGroup.summary" class="mt-1 text-sm text-slate-400">
309
- {{ selectedGroup.summary }}
310
- </p>
311
-
312
- <!-- GHERKIN view: the rendered .feature file for this group -->
313
- <template v-if="mode === 'gherkin'">
314
- <pre
315
- v-if="selectedFeature"
316
- class="mt-4 overflow-x-auto rounded-lg border border-slate-800 bg-slate-950/60 p-4 text-[12.5px] leading-relaxed text-slate-200"
317
- ><code>{{ selectedFeature.content }}</code></pre>
318
- <div
319
- v-else
320
- class="mt-4 rounded-lg border border-dashed border-slate-700 p-6 text-center text-sm text-slate-500"
321
- >
322
- {{ t('spec.noGherkinForGroup') }}
323
- </div>
324
- </template>
325
-
326
- <!-- STRUCTURED view: requirements + acceptance + domain rules -->
327
- <template v-else>
328
- <div v-if="reqCount(selectedGroup) === 0" class="mt-4 text-sm text-slate-500">
329
- {{ t('spec.noRequirements') }}
330
- </div>
331
- <ul class="mt-4 space-y-4">
332
- <li
333
- v-for="req in selectedGroup.requirements ?? []"
334
- :key="req.id"
335
- class="rounded-lg border border-slate-800 bg-slate-900/60 p-4"
336
- >
337
- <div class="flex items-start justify-between gap-3">
338
- <h3 class="text-sm font-semibold text-slate-100">{{ req.title }}</h3>
339
- <div class="flex shrink-0 items-center gap-1.5">
340
- <UBadge :color="priorityMeta(req).chip as any" variant="subtle" size="sm">
341
- {{ priorityMeta(req).label }}
342
- </UBadge>
343
- <UBadge color="neutral" variant="subtle" size="sm">{{
344
- kindLabel(req)
345
- }}</UBadge>
346
- </div>
347
- </div>
348
- <p
349
- class="mt-1.5 whitespace-pre-line text-[13px] leading-relaxed text-slate-300"
350
- >
351
- {{ req.statement }}
352
- </p>
353
- <!-- acceptance criteria (Given/When/Then) -->
354
- <div v-if="(req.acceptance?.length ?? 0) > 0" class="mt-3 space-y-1.5">
355
- <div
356
- v-for="ac in req.acceptance ?? []"
357
- :key="ac.id"
358
- class="rounded-md border border-slate-800 bg-slate-950/50 px-3 py-2 text-[12.5px] leading-relaxed"
359
- >
360
- <p class="text-slate-300">
361
- <span class="font-semibold text-emerald-400">{{
362
- t('spec.acceptance.given')
363
- }}</span>
364
- {{ ac.given }}
365
- </p>
366
- <p class="text-slate-300">
367
- <span class="font-semibold text-sky-400">{{
368
- t('spec.acceptance.when')
369
- }}</span>
370
- {{ ac.when }}
371
- </p>
372
- <p class="text-slate-300">
373
- <span class="font-semibold text-violet-400">{{
374
- t('spec.acceptance.then')
375
- }}</span>
376
- {{ ac.outcome }}
377
- </p>
378
- </div>
379
- </div>
380
- </li>
381
- </ul>
382
-
383
- <!-- domain rules / invariants scoped to this group -->
384
- <div v-if="(selectedGroup.rules?.length ?? 0) > 0" class="mt-6">
385
- <div
386
- class="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-400"
387
- >
388
- <UIcon name="i-lucide-shield-check" class="h-3.5 w-3.5" />
389
- {{ t('spec.domainRules') }}
390
- </div>
391
- <ul class="space-y-1.5">
392
- <li
393
- v-for="rule in selectedGroup.rules ?? []"
394
- :key="rule.id"
395
- class="rounded-md border border-slate-800 bg-slate-900/60 px-3 py-2 text-[13px] text-slate-300"
396
- >
397
- {{ rule.rule }}
398
- <span v-if="rule.rationale" class="text-slate-500">{{
399
- t('spec.ruleRationale', { rationale: rule.rationale })
400
- }}</span>
401
- </li>
402
- </ul>
403
- </div>
404
- </template>
405
- </template>
406
- </div>
407
- </div>
375
+ </template>
376
+ </template>
408
377
  </div>
409
378
  </div>
410
- </Teleport>
379
+ </ResultWindowShell>
411
380
  </template>
@@ -30,12 +30,10 @@ const blobs = useArtifactBlobs()
30
30
  onUnmounted(() => blobs.revokeAll())
31
31
 
32
32
  // Shared seam contract (open/blockId/close). No `onOpen` loader: this window reads its report
33
- // straight off the execution step, so there's nothing to fetch on open. `manageEscape: false`
34
- // — `ResultWindowShell` owns Escape (and the focus trap + scroll lock + stacking) via the
35
- // shared overlay behaviour; the nested lightbox layers above it on the same stack.
36
- const { open, blockId, instanceId, stepIndex, close } = useResultView('tester', {
37
- manageEscape: false,
38
- })
33
+ // straight off the execution step, so there's nothing to fetch on open. `ResultWindowShell`
34
+ // owns Escape (and the focus trap + scroll lock + stacking) via the shared overlay behaviour;
35
+ // the nested lightbox layers above it on the same stack.
36
+ const { open, blockId, instanceId, stepIndex, close } = useResultView('tester')
39
37
  const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
40
38
  const headerTitle = computed(
41
39
  () => `${t('testing.title')}${block.value ? ` — ${block.value.title}` : ''}`,
@@ -26,11 +26,9 @@ const access = useWorkspaceAccess()
26
26
  const blobs = useArtifactBlobs()
27
27
  onUnmounted(() => blobs.revokeAll())
28
28
 
29
- // `manageEscape: false` — `ResultWindowShell` owns Escape (and the focus trap + scroll lock +
30
- // stacking); the nested lightbox layers above it on the same shared overlay stack.
31
- const { open, blockId, instanceId, stepIndex, close } = useResultView('visual-confirm', {
32
- manageEscape: false,
33
- })
29
+ // `ResultWindowShell` owns Escape (and the focus trap + scroll lock + stacking); the nested
30
+ // lightbox layers above it on the same shared overlay stack.
31
+ const { open, blockId, instanceId, stepIndex, close } = useResultView('visual-confirm')
34
32
  const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
35
33
  const headerTitle = computed(() =>
36
34
  block.value
@@ -13,23 +13,20 @@
13
13
  * A synchronous window (one that reads its data straight off the execution step, like the
14
14
  * test report) simply omits `onOpen`.
15
15
  *
16
- * `onClose` runs on EVERY close path — the X button, backdrop click, and the Escape key
17
- * handled here — BEFORE the view is torn down, so a window with unsaved draft input (the
18
- * review windows) can flush it in one place instead of every caller having to remember to.
19
- * It runs synchronously; if it kicks off async work it must capture whatever it needs first,
20
- * because `blockId`/the derived state go null the moment the view closes.
16
+ * `onClose` runs on EVERY close path — the X button, backdrop click, and the Escape key
17
+ * BEFORE the view is torn down, so a window with unsaved draft input (the review windows) can
18
+ * flush it in one place instead of every caller having to remember to. It runs synchronously;
19
+ * if it kicks off async work it must capture whatever it needs first, because `blockId`/the
20
+ * derived state go null the moment the view closes.
21
21
  *
22
- * `manageEscape` (default `true`) owns the global Escape-to-close listener. A window that
23
- * renders through `ResultWindowShell` (slice 5 of the modular-vue adoption) passes `false`:
24
- * the shell's `useModalBehavior` owns Escape via the shared overlay stack (so the top
25
- * overlay closes first, and focus/scroll are managed too), and a second listener here would
26
- * double-fire `close`. Un-converted windows keep the default so they still close on Escape.
27
- * Remove this option once every result window is on the shell (the listener moves out
28
- * entirely).
22
+ * Escape-to-close is NOT owned here: every result window renders through `ResultWindowShell`
23
+ * (slice 5 of the modular-vue adoption), whose `useModalBehavior` owns Escape via the shared
24
+ * overlay stack (top overlay closes first, focus/scroll managed too). A listener here would
25
+ * double-fire `close`, so it was removed once the last window converted onto the shell.
29
26
  */
30
27
  export function useResultView(
31
28
  viewId: string,
32
- opts?: { onOpen?: (blockId: string) => void; onClose?: () => void; manageEscape?: boolean },
29
+ opts?: { onOpen?: (blockId: string) => void; onClose?: () => void },
33
30
  ) {
34
31
  const ui = useUiStore()
35
32
 
@@ -47,14 +44,6 @@ export function useResultView(
47
44
  ui.closeResultView()
48
45
  }
49
46
 
50
- function onKey(e: KeyboardEvent) {
51
- if (e.key === 'Escape' && open.value) close()
52
- }
53
- if (opts?.manageEscape !== false) {
54
- onMounted(() => window.addEventListener('keydown', onKey))
55
- onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
56
- }
57
-
58
47
  // The load-on-open contract: fire immediately on mount and on any later block switch.
59
48
  if (opts?.onOpen) {
60
49
  watch(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.138.1",
3
+ "version": "0.139.0",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",