@cat-factory/app 0.315.6 → 0.315.8

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/README.md CHANGED
@@ -388,7 +388,9 @@ A phone's notch and home indicator cover whatever sits at the screen edge, and o
388
388
  - **`FULL_BLEED_TAKEOVER_CLASS`** for a takeover that fills the screen (`AgentStepDetail`, the model configuration takeover, the observability, reports, operator dashboard and Kaizen panels, the artifact lightbox). It pads by the inset alone, so a screen without one keeps the edge-to-edge layout.
389
389
  - **`WINDOWED_OVERLAY_CLASS`** for a window on a backdrop (`ResultWindowShell`). Its gutter is 1rem or the inset, whichever is larger.
390
390
 
391
- `scripts/check-frontend-safe-area.mjs` (CI) refuses any other `fixed` surface that spans a whole side of the viewport, in `.vue`, `.ts` and `.css`. A surface with no edge content (a scrim, a drawing layer) or one that pads its own edges (the sidebar drawer, the inspector's bottom sheet) says why with a `safe-area-ok:` comment, on the line or the one above it, or above the element's opening tag.
391
+ Each class list belongs to ONE shell, and a surface renders inside that shell instead of binding the class itself: a takeover inside [`common/TakeoverShell.vue`](./app/components/common/TakeoverShell.vue), a window inside [`panels/ResultWindowShell.vue`](./app/components/panels/ResultWindowShell.vue). The shell binds the padding AND the modal behaviour (`useModalBehavior`: a focus trap, focus return, a body-scroll lock, and a ticket on the shared overlay stack), so focus stays inside and Escape closes only the top overlay. A takeover gates the shell with its own `v-if` inside its own `Teleport` and `Transition`, passes a `label` (the dialog's accessible name), and handles the shell's `close` event; it never adds an Escape listener of its own. A Nuxt UI menu, select, popover or modal opened inside a shell holds its own ticket on the same stack while it is open ([`utils/foreignLayers.ts`](./app/utils/foreignLayers.ts), wired by a client plugin), so Escape and Tab are its keys until it closes. No site pushes a ticket by hand. Why this is not `UModal fullscreen`, and the rest of the overlay consolidation: [`docs/initiatives/overlay-shells.md`](https://github.com/kibertoad/cat-factory/blob/main/docs/initiatives/overlay-shells.md).
392
+
393
+ `scripts/check-frontend-safe-area.mjs` (CI) refuses any other `fixed` surface that spans a whole side of the viewport, in `.vue`, `.ts` and `.css`. A surface with no edge content (a scrim, a drawing layer) or one that pads its own edges (the sidebar drawer, the inspector's bottom sheet) says why with a `safe-area-ok:` comment, on the line or the one above it, or above the element's opening tag. The same guard refuses either class list named outside its owning shell, with no waiver.
392
394
 
393
395
  ### Modals open at a named width
394
396
 
@@ -2,39 +2,16 @@
2
2
  // The single, app-wide confirmation dialog. Mounted once in `pages/index.vue` and driven
3
3
  // entirely by the `useConfirm()` singleton, so any caller can `await confirm({...})` without
4
4
  // rendering its own modal. UModal already provides the focus trap, Escape-to-close and
5
- // backdrop, so this component adds none of that — it only resolves the pending promise and,
6
- // crucially, resolves `false` whenever the modal closes without an explicit choice.
7
- import { computed, onUnmounted, watch } from 'vue'
8
- import { sharedOverlayStack, type OverlayStackTicket } from '@modular-vue/core'
5
+ // backdrop, so this component adds none of that. It only resolves the pending promise, and
6
+ // resolves `false` whenever the modal closes without an explicit choice. Its place on
7
+ // the shared overlay stack, which makes a takeover or window under it leave Escape to it, comes
8
+ // from `utils/foreignLayers.ts`, as for every Nuxt UI layer.
9
+ import { computed } from 'vue'
9
10
  import AppModal from '~/components/common/AppModal.vue'
10
11
 
11
12
  const { t } = useI18n()
12
13
  const { open, current, accept, cancel, dismissed } = useConfirm()
13
14
 
14
- // Register on the app's ONE overlay stack for as long as the dialog is up.
15
- //
16
- // The stack is what `useModalBehavior` (`ResultWindowShell`, the lightbox, every modular overlay)
17
- // asks before it handles Escape, and this dialog is a Nuxt UI modal, so without a registration it
18
- // is invisible to that question. A confirm opened FROM a result window then lost the race for its
19
- // own Escape key: the shell's capture-phase listener still believed it was topmost, so it
20
- // preventDefault-ed and re-entered its close request, which supersedes the confirm the user was
21
- // trying to cancel. Pushing a ticket makes the shell stand down while this is on top, which is
22
- // exactly the ordering the stack exists to state.
23
- let ticket: OverlayStackTicket | null = null
24
- function release(): void {
25
- ticket?.release()
26
- ticket = null
27
- }
28
- watch(
29
- open,
30
- (isOpen) => {
31
- if (isOpen) ticket ??= sharedOverlayStack.push()
32
- else release()
33
- },
34
- { immediate: true },
35
- )
36
- onUnmounted(release)
37
-
38
15
  const model = computed({
39
16
  get: () => open.value,
40
17
  set: (v: boolean) => {
@@ -0,0 +1,97 @@
1
+ <script setup lang="ts">
2
+ // The one root of every full-bleed takeover: the step reader (`AgentStepDetail`), the model
3
+ // configuration takeover, the observability, reports, operator dashboard and Kaizen panels, and the
4
+ // artifact lightbox. It owns the root element and the modal BEHAVIOUR; each takeover keeps its own
5
+ // layout, header and backdrop inside it.
6
+ //
7
+ // The behaviour is the upstream `useModalBehavior` (`@modular-vue/core`), the same composable
8
+ // `ResultWindowShell` uses: a focus trap, focus return, a body-scroll lock, and a ticket on the
9
+ // shared overlay STACK, so Escape closes only the top overlay. Before this shell, six of the seven
10
+ // takeovers hand-rolled a window Escape listener instead. Tab then walked out to the board behind
11
+ // them, and one Escape closed every overlay that listened, so a result window or the observability
12
+ // panel opened over the step reader took the reader down with it.
13
+ //
14
+ // Why not `UModal fullscreen` (the shape `GuidedReviewModal` uses): a Reka modal makes the body
15
+ // inert while it is up (`pointer-events: none` on `<body>`, a `focusin` trap, and a dismiss on any
16
+ // pointer or focus outside its content). The windows opened ON TOP of a takeover
17
+ // (`ResultWindowShell`, the lightbox, a modular overlay) are not Reka layers, so they would open
18
+ // unclickable and lose focus to the takeover under them. The shared stack is what both sides
19
+ // agree on: every open Nuxt UI menu, select, popover and modal holds a ticket on it
20
+ // (`utils/foreignLayers.ts`), so one opened inside a takeover is the top overlay and keeps its own
21
+ // Escape.
22
+ //
23
+ // Every takeover paints at the SAME z-index (`z-50`, as `ResultWindowShell` does) and is teleported
24
+ // to `<body>` when it opens, so DOM order is open order: the overlay stack's top ticket is also the
25
+ // top layer. A per-site z-index let a later-opened `z-50` takeover take focus and Escape while
26
+ // painting under an earlier `z-[60]` one.
27
+ //
28
+ // The takeover is OPEN while this shell is mounted: each site gates it with its own `v-if` inside
29
+ // its own `Teleport` and `Transition`, so a fade owned by a parent mount (`pages/index.vue` for the
30
+ // step reader) still plays in both directions.
31
+ import { useMounted } from '@vueuse/core'
32
+ import { useModalBehavior } from '@modular-vue/core'
33
+ import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
34
+
35
+ const props = defineProps<{
36
+ /** The accessible name of the dialog. */
37
+ label: string
38
+ /**
39
+ * What the takeover shows now, for a takeover that swaps its content without closing (the step
40
+ * reader moving to another step). A change moves focus back to the top of the dialog.
41
+ */
42
+ contentKey?: string | number | null
43
+ }>()
44
+
45
+ const emit = defineEmits<{ close: [] }>()
46
+
47
+ // The behaviour activates on MOUNT, not in setup. Most takeovers are async views, and on their first
48
+ // open Nuxt's Suspense defers the mount, so focus moved during setup would find no element yet and
49
+ // stay on the board.
50
+ const mounted = useMounted()
51
+
52
+ // Initial focus goes to the dialog root, not to its first control: a takeover is a whole screen of
53
+ // content, so the reader starts at its top and the first Tab reaches the first control.
54
+ const { dialogRef, isTop } = useModalBehavior({
55
+ active: mounted,
56
+ onClose: () => emit('close'),
57
+ initialFocus: () => dialogRef.value,
58
+ contentKey: () => props.contentKey,
59
+ })
60
+
61
+ // The trap wraps Tab only when focus is outside the dialog or on its first or last control, and the
62
+ // root is none of those, so the first Shift+Tab from the root would leave for the board behind
63
+ // (the browser moves to the last tabbable element before the root in DOM order). Wrap it here.
64
+ // Hidden controls (`display: none`, such as the reader's outline below `md`) cannot take focus, so
65
+ // they are skipped.
66
+ const FOCUSABLE =
67
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
68
+
69
+ function wrapShiftTabFromRoot(event: KeyboardEvent): void {
70
+ const root = dialogRef.value
71
+ if (event.key !== 'Tab' || !event.shiftKey || !root || event.target !== root) return
72
+ const controls = [...root.querySelectorAll<HTMLElement>(FOCUSABLE)]
73
+ const last = controls.findLast((el) => el.checkVisibility())
74
+ if (!last) return
75
+ event.preventDefault()
76
+ last.focus()
77
+ }
78
+
79
+ // A takeover with its own keys (the lightbox's arrows and zoom) reads this, so its keys stay inert
80
+ // while another overlay is on top of it.
81
+ defineExpose({ isTop })
82
+ </script>
83
+
84
+ <template>
85
+ <div
86
+ ref="dialogRef"
87
+ tabindex="-1"
88
+ role="dialog"
89
+ aria-modal="true"
90
+ :aria-label="label"
91
+ class="z-50 focus:outline-none"
92
+ :class="FULL_BLEED_TAKEOVER_CLASS"
93
+ @keydown="wrapShiftTabFromRoot"
94
+ >
95
+ <slot />
96
+ </div>
97
+ </template>
@@ -1,5 +1,7 @@
1
1
  <script setup lang="ts">
2
- import { onKeyStroke } from '@vueuse/core'
2
+ import { onBeforeUnmount, onMounted } from 'vue'
3
+ import { useEventListener } from '@vueuse/core'
4
+ import { sharedOverlayStack, type OverlayStackTicket } from '@modular-vue/core'
3
5
  import type { Block } from '~/types/domain'
4
6
  import { blockTypeMeta, STATUS_META } from '~/utils/catalog'
5
7
  import { pipelineAllowedForManualStart } from '~/utils/pipeline'
@@ -79,9 +81,28 @@ function close() {
79
81
  ui.focus(null)
80
82
  }
81
83
 
82
- onKeyStroke('Escape', () => {
83
- if (ui.focusBlockId) close()
84
+ // The focus view is one more layer on the shared overlay stack: it holds a ticket while mounted and
85
+ // takes Escape only when that ticket is the top one. A takeover, window, Nuxt UI menu or modal
86
+ // opened from here is pushed after it, so it is the top overlay and keeps its own Escape. The
87
+ // listener is in the CAPTURE phase and registered at setup, so it runs before the listener of any
88
+ // overlay opened later and reads the stack as it was when the key was pressed, before that
89
+ // overlay's close can release its ticket.
90
+ let ticket: OverlayStackTicket | null = null
91
+ onMounted(() => {
92
+ ticket = sharedOverlayStack.push()
84
93
  })
94
+ onBeforeUnmount(() => {
95
+ ticket?.release()
96
+ ticket = null
97
+ })
98
+ useEventListener(
99
+ window,
100
+ 'keydown',
101
+ (e: KeyboardEvent) => {
102
+ if (e.key === 'Escape' && ticket?.isTop() && ui.focusBlockId) close()
103
+ },
104
+ { capture: true },
105
+ )
85
106
 
86
107
  function openDecisionFor(decisionId: string) {
87
108
  if (instance.value) ui.openDecision(instance.value.id, decisionId)
@@ -1,11 +1,10 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, watch } from 'vue'
3
- import { onKeyStroke } from '@vueuse/core'
4
3
  import type { TableColumn } from '@nuxt/ui'
5
4
  import type { KaizenGrading } from '~/types/domain'
6
5
  import { agentKindMeta } from '~/utils/catalog'
7
6
  import EmptyState from '~/components/common/EmptyState.vue'
8
- import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
7
+ import TakeoverShell from '~/components/common/TakeoverShell.vue'
9
8
 
10
9
  // The Kaizen screen: a full-panel overlay listing the workspace's grading history and
11
10
  // its verified-combo library. Opened via `ui.openKaizen()` from the sidebar. Read-only —
@@ -27,9 +26,6 @@ watch(
27
26
  function close() {
28
27
  ui.closeKaizen()
29
28
  }
30
- onKeyStroke('Escape', () => {
31
- if (open.value) close()
32
- })
33
29
 
34
30
  function meta(kind: string) {
35
31
  return agentKindMeta(kind)
@@ -66,12 +62,11 @@ const historyColumns = computed<TableColumn<KaizenGrading>[]>(() => [
66
62
  <template>
67
63
  <Teleport to="body">
68
64
  <Transition name="kz-fade">
69
- <div
65
+ <TakeoverShell
70
66
  v-if="open"
71
- class="z-[60] flex-col bg-app-950/96 backdrop-blur-sm"
72
- :class="FULL_BLEED_TAKEOVER_CLASS"
73
- role="dialog"
74
- aria-modal="true"
67
+ :label="t('kaizen.title')"
68
+ class="flex-col bg-app-950/96 backdrop-blur-sm"
69
+ @close="close"
75
70
  >
76
71
  <header class="flex items-center gap-3 border-b border-default px-6 py-4">
77
72
  <div
@@ -241,7 +236,7 @@ const historyColumns = computed<TableColumn<KaizenGrading>[]>(() => [
241
236
  </UTable>
242
237
  </section>
243
238
  </div>
244
- </div>
239
+ </TakeoverShell>
245
240
  </Transition>
246
241
  </Teleport>
247
242
  </template>
@@ -7,11 +7,10 @@
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'
11
10
  import type { ArtifactBlobs } from '~/composables/useArtifactBlobs'
12
11
  import IconButton from '~/components/common/IconButton.vue'
13
12
  import Spinner from '~/components/common/Spinner.vue'
14
- import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
13
+ import TakeoverShell from '~/components/common/TakeoverShell.vue'
15
14
 
16
15
  interface LightboxItem {
17
16
  artifactId: string
@@ -39,19 +38,15 @@ const scale = ref(1)
39
38
  const tx = ref(0)
40
39
  const ty = ref(0)
41
40
 
42
- // Modal behaviour via the shared overlay stack (`@modular-vue/core`, the slice-5 release):
43
- // focus-trap + return, body-scroll lock, and — because it pushes onto the shared stack while
44
- // open — it becomes the TOP overlay over an owning review window (`ResultWindowShell`), so
45
- // Escape closes the lightbox first and the owner's trap goes inert until it closes. This is
46
- // the reconciliation that replaces the old `active: open && !lightboxOpen` guard the windows
47
- // used to fight over Tab.
41
+ // Modal behaviour comes from `TakeoverShell` (the shared overlay stack): focus-trap + return,
42
+ // body-scroll lock, and, because it pushes onto the shared stack while open, it becomes the TOP
43
+ // overlay over an owning review window (`ResultWindowShell`), so Escape closes the lightbox first
44
+ // and the owner's trap goes inert until it closes.
48
45
  function close() {
49
46
  emit('update:open', false)
50
47
  }
51
- const { dialogRef, isTop } = useModalBehavior({
52
- active: () => props.open,
53
- onClose: close,
54
- })
48
+ const shell = ref<InstanceType<typeof TakeoverShell> | null>(null)
49
+ const isTop = computed(() => shell.value?.isTop ?? false)
55
50
 
56
51
  const current = computed(() => props.items[props.index] ?? null)
57
52
  const total = computed(() => props.items.length)
@@ -159,20 +154,17 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey, true))
159
154
 
160
155
  <template>
161
156
  <Teleport to="body">
162
- <div
157
+ <TakeoverShell
163
158
  v-if="open"
164
- ref="dialogRef"
165
- tabindex="-1"
166
- class="z-[60] flex-col bg-app-950/95 backdrop-blur-sm focus:outline-none"
167
- :class="FULL_BLEED_TAKEOVER_CLASS"
168
- role="dialog"
169
- aria-modal="true"
159
+ ref="shell"
160
+ class="flex-col bg-app-950/95 backdrop-blur-sm"
170
161
  data-testid="artifact-lightbox"
171
- :aria-label="
162
+ :label="
172
163
  current
173
164
  ? t('media.lightbox.ariaLabel', { label: current.label })
174
165
  : t('media.lightbox.ariaLabelFallback')
175
166
  "
167
+ @close="close"
176
168
  >
177
169
  <!-- Toolbar -->
178
170
  <div class="flex items-center gap-3 border-b border-default/60 px-4 py-2.5">
@@ -307,6 +299,6 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey, true))
307
299
  @click="go(1)"
308
300
  />
309
301
  </div>
310
- </div>
302
+ </TakeoverShell>
311
303
  </Teleport>
312
304
  </template>
@@ -39,6 +39,7 @@ function openObservability() {
39
39
  color="neutral"
40
40
  variant="ghost"
41
41
  v-if="instanceId"
42
+ data-testid="step-view-all-calls"
42
43
  class="p-0 text-2xs text-app-info-400 hover:bg-transparent hover:text-app-info-300"
43
44
  @click="openObservability"
44
45
  >
@@ -1,6 +1,5 @@
1
1
  <script setup lang="ts">
2
2
  import { ref, computed, watch, onMounted } from 'vue'
3
- import { onKeyStroke } from '@vueuse/core'
4
3
  import type { IterationCapChoice } from '~/types/execution'
5
4
  import { agentKindMeta } from '~/utils/catalog'
6
5
  import StepRestartControl from '~/components/panels/StepRestartControl.vue'
@@ -32,7 +31,7 @@ import RunDetailLoadState from '~/components/panels/RunDetailLoadState.vue'
32
31
  import SectionLabel from '~/components/common/SectionLabel.vue'
33
32
  import IconButton from '~/components/common/IconButton.vue'
34
33
  import EmptyState from '~/components/common/EmptyState.vue'
35
- import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
34
+ import TakeoverShell from '~/components/common/TakeoverShell.vue'
36
35
 
37
36
  // Detail overlay for a single pipeline step. Opened by clicking an agent in the
38
37
  // inspector list (TaskExecution) or the focus-view pipeline (PipelineProgress) via
@@ -372,10 +371,6 @@ watch(
372
371
  },
373
372
  )
374
373
 
375
- onKeyStroke('Escape', () => {
376
- if (open.value) close()
377
- })
378
-
379
374
  const { copy } = useCopyToClipboard()
380
375
  async function copyOutput() {
381
376
  if (step.value?.output) await copy(step.value.output)
@@ -391,14 +386,15 @@ async function copyOutput() {
391
386
  <!-- `data-agent-kind` names WHICH step this window is scoped to, as the kind rather than as
392
387
  the heading beside it: the heading is a translated display label, so it is the wrong
393
388
  thing to read for anything that needs to know which agent's step is open. -->
394
- <div
389
+ <!-- `content-key` moves focus back to the top when the reader swaps to another step. -->
390
+ <TakeoverShell
395
391
  v-if="open && step && agent"
392
+ :label="agent.label"
393
+ :content-key="ctx && `${ctx.instanceId}:${ctx.stepIndex}`"
396
394
  data-testid="step-detail"
397
395
  :data-agent-kind="step?.agentKind"
398
- class="z-50 bg-app-950/96 backdrop-blur-sm"
399
- :class="FULL_BLEED_TAKEOVER_CLASS"
400
- role="dialog"
401
- aria-modal="true"
396
+ class="bg-app-950/96 backdrop-blur-sm"
397
+ @close="close"
402
398
  >
403
399
  <!-- ToC sidebar (only meaningful when there are prose headings) -->
404
400
  <aside
@@ -1085,7 +1081,7 @@ async function copyOutput() {
1085
1081
  </p>
1086
1082
  </div>
1087
1083
  </aside>
1088
- </div>
1084
+ </TakeoverShell>
1089
1085
  </template>
1090
1086
 
1091
1087
  <style scoped>
@@ -1,6 +1,5 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, nextTick, reactive, ref, watch } from 'vue'
3
- import { onKeyStroke } from '@vueuse/core'
4
3
  import type { TableColumn } from '@nuxt/ui'
5
4
  import { isLlmWarningFinishReason } from '@cat-factory/contracts'
6
5
  import type {
@@ -33,7 +32,7 @@ import ToolCallList from '~/components/observability/ToolCallList.vue'
33
32
  import SectionLabel from '~/components/common/SectionLabel.vue'
34
33
  import IconButton from '~/components/common/IconButton.vue'
35
34
  import EmptyState from '~/components/common/EmptyState.vue'
36
- import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
35
+ import TakeoverShell from '~/components/common/TakeoverShell.vue'
37
36
 
38
37
  /** No run selected: the same empty, NOT-truncated trajectory the store answers with. */
39
38
  const EMPTY_TRAJECTORY: RunToolCallTrajectory = Object.freeze({
@@ -526,9 +525,6 @@ function headroomOf(m: LlmCallMetric): number | null {
526
525
  function close() {
527
526
  ui.closeObservability()
528
527
  }
529
- onKeyStroke('Escape', () => {
530
- if (open.value) close()
531
- })
532
528
  function exportJson() {
533
529
  if (executionId.value) void observability.downloadExport(executionId.value)
534
530
  }
@@ -537,12 +533,12 @@ function exportJson() {
537
533
  <template>
538
534
  <Teleport to="body">
539
535
  <Transition name="obs-fade">
540
- <div
536
+ <TakeoverShell
541
537
  v-if="open"
542
- class="z-[60] flex-col bg-app-950/96 backdrop-blur-sm"
543
- :class="FULL_BLEED_TAKEOVER_CLASS"
544
- role="dialog"
545
- aria-modal="true"
538
+ :label="t('observability.modelActivity')"
539
+ class="flex-col bg-app-950/96 backdrop-blur-sm"
540
+ data-testid="observability-panel"
541
+ @close="close"
546
542
  >
547
543
  <header class="flex flex-wrap items-center gap-3 border-b border-default px-6 py-4">
548
544
  <div
@@ -1287,7 +1283,7 @@ function exportJson() {
1287
1283
  </div>
1288
1284
  </div>
1289
1285
  </div>
1290
- </div>
1286
+ </TakeoverShell>
1291
1287
  </Transition>
1292
1288
  </Teleport>
1293
1289
  </template>
@@ -1,6 +1,5 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, watch } from 'vue'
3
- import { onKeyStroke } from '@vueuse/core'
4
3
  import type { TableColumn } from '@nuxt/ui'
5
4
  import type { PlatformObservabilityWindow } from '~/types/execution'
6
5
  import { formatMs } from '~/utils/observability'
@@ -8,7 +7,7 @@ import { FAILURE_KIND_KEYS, isAgentFailureKind } from '~/utils/failureKinds'
8
7
  import SectionLabel from '~/components/common/SectionLabel.vue'
9
8
  import IconButton from '~/components/common/IconButton.vue'
10
9
  import EmptyState from '~/components/common/EmptyState.vue'
11
- import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
10
+ import TakeoverShell from '~/components/common/TakeoverShell.vue'
12
11
 
13
12
  // Deployment-level (platform-operator) observability dashboard: the aggregate health of the
14
13
  // active account's runs — outcome totals + success rate, a time-bucketed outcome trend, the
@@ -105,9 +104,6 @@ function refresh() {
105
104
  function close() {
106
105
  ui.closeOperatorDashboard()
107
106
  }
108
- onKeyStroke('Escape', () => {
109
- if (open.value) close()
110
- })
111
107
 
112
108
  // Load (and refresh) whenever the dashboard opens.
113
109
  watch(
@@ -122,13 +118,12 @@ watch(
122
118
  <template>
123
119
  <Teleport to="body">
124
120
  <Transition name="obs-fade">
125
- <div
121
+ <TakeoverShell
126
122
  v-if="open"
127
- class="z-[60] flex-col bg-app-950/96 backdrop-blur-sm"
128
- :class="FULL_BLEED_TAKEOVER_CLASS"
129
- role="dialog"
130
- aria-modal="true"
123
+ :label="t('platformObservability.title')"
124
+ class="flex-col bg-app-950/96 backdrop-blur-sm"
131
125
  data-testid="operator-dashboard"
126
+ @close="close"
132
127
  >
133
128
  <header class="flex flex-wrap items-center gap-3 border-b border-default px-6 py-4">
134
129
  <div
@@ -553,7 +548,7 @@ watch(
553
548
  </div>
554
549
  </div>
555
550
  </div>
556
- </div>
551
+ </TakeoverShell>
557
552
  </Transition>
558
553
  </Teleport>
559
554
  </template>
@@ -1,6 +1,5 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref, watch } from 'vue'
3
- import { onKeyStroke } from '@vueuse/core'
4
3
  import { lastCompleteRollupDay } from '@cat-factory/contracts'
5
4
  import type {
6
5
  ReportActivityDimension,
@@ -22,7 +21,7 @@ import ReportsSpendBreakdown from '~/components/panels/ReportsSpendBreakdown.vue
22
21
  import SectionLabel from '~/components/common/SectionLabel.vue'
23
22
  import IconButton from '~/components/common/IconButton.vue'
24
23
  import EmptyState from '~/components/common/EmptyState.vue'
25
- import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
24
+ import TakeoverShell from '~/components/common/TakeoverShell.vue'
26
25
 
27
26
  // Reports: cross-cutting usage analytics for the active account — where the spend and the
28
27
  // work actually go. Spend per model and agent kind, spend + run activity per workspace /
@@ -179,9 +178,6 @@ function refresh() {
179
178
  function close() {
180
179
  ui.closeReports()
181
180
  }
182
- onKeyStroke('Escape', () => {
183
- if (open.value) close()
184
- })
185
181
 
186
182
  // Load (and refresh) whenever the panel opens.
187
183
  watch(
@@ -196,13 +192,12 @@ watch(
196
192
  <template>
197
193
  <Teleport to="body">
198
194
  <Transition name="reports-fade">
199
- <div
195
+ <TakeoverShell
200
196
  v-if="open"
201
- class="z-[60] flex-col bg-app-950/96 backdrop-blur-sm"
202
- :class="FULL_BLEED_TAKEOVER_CLASS"
203
- role="dialog"
204
- aria-modal="true"
197
+ :label="t('reports.title')"
198
+ class="flex-col bg-app-950/96 backdrop-blur-sm"
205
199
  data-testid="reports-panel"
200
+ @close="close"
206
201
  >
207
202
  <header class="flex flex-wrap items-center gap-3 border-b border-default px-6 py-4">
208
203
  <div
@@ -575,7 +570,7 @@ watch(
575
570
  </section>
576
571
  </div>
577
572
  </div>
578
- </div>
573
+ </TakeoverShell>
579
574
  </Transition>
580
575
  </Teleport>
581
576
  </template>
@@ -11,7 +11,6 @@
11
11
  // preference. The list view shows each preset; the editor view creates/edits one with a
12
12
  // base-model picker plus a filterable per-agent override list.
13
13
  import { computed, ref, watch } from 'vue'
14
- import { onKeyStroke } from '@vueuse/core'
15
14
  import type { ModelFlavor } from '@cat-factory/contracts'
16
15
  import type { AgentKind } from '~/types/domain'
17
16
  import type { ModelPreset } from '~/types/model-presets'
@@ -25,7 +24,7 @@ import { showOverrideField } from '~/utils/uiMode'
25
24
  import SectionLabel from '~/components/common/SectionLabel.vue'
26
25
  import IconButton from '~/components/common/IconButton.vue'
27
26
  import EmptyState from '~/components/common/EmptyState.vue'
28
- import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
27
+ import TakeoverShell from '~/components/common/TakeoverShell.vue'
29
28
 
30
29
  const { t } = useI18n()
31
30
  const ui = useUiStore()
@@ -119,12 +118,11 @@ watch(
119
118
  { immediate: true },
120
119
  )
121
120
 
122
- onKeyStroke('Escape', () => {
123
- if (!open.value) return
124
- // Esc backs out of the editor first, then closes the panel.
121
+ // Esc backs out of the editor first, then closes the panel.
122
+ function backOut() {
125
123
  if (editor.value) editor.value = null
126
124
  else open.value = false
127
- })
125
+ }
128
126
 
129
127
  /** The selectable catalog models, as `{ id, label, suffix }` for a dropdown. */
130
128
  const selectableModels = computed(() => {
@@ -309,13 +307,13 @@ async function save() {
309
307
  <template>
310
308
  <Teleport to="body">
311
309
  <Transition name="reader-fade">
312
- <div
310
+ <TakeoverShell
313
311
  v-if="open"
312
+ :label="t('settings.modelConfiguration.title')"
313
+ :content-key="editor ? 'editor' : 'list'"
314
+ class="flex-col bg-app-950/96 backdrop-blur-sm"
314
315
  data-testid="model-configuration-panel"
315
- class="z-50 flex-col bg-app-950/96 backdrop-blur-sm"
316
- :class="FULL_BLEED_TAKEOVER_CLASS"
317
- role="dialog"
318
- aria-modal="true"
316
+ @close="backOut"
319
317
  >
320
318
  <header class="flex items-center gap-3 border-b border-default px-6 py-4">
321
319
  <div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/15">
@@ -444,6 +442,7 @@ async function save() {
444
442
  variant="ghost"
445
443
  color="error"
446
444
  icon="i-lucide-trash-2"
445
+ data-testid="model-preset-delete"
447
446
  :disabled="p.isDefault"
448
447
  :loading="busy"
449
448
  :label="
@@ -518,6 +517,7 @@ async function save() {
518
517
  variant="subtle"
519
518
  trailing-icon="i-lucide-chevron-down"
520
519
  class="w-full justify-between"
520
+ data-testid="model-preset-base-model"
521
521
  >
522
522
  <span class="truncate">{{ modelLabel(editor.baseModelId) }}</span>
523
523
  </UButton>
@@ -618,7 +618,7 @@ async function save() {
618
618
  </template>
619
619
  </div>
620
620
  </div>
621
- </div>
621
+ </TakeoverShell>
622
622
  </Transition>
623
623
  </Teleport>
624
624
  </template>
@@ -0,0 +1,8 @@
1
+ import { sharedOverlayStack } from '@modular-vue/core'
2
+ import { trackForeignLayers } from '~/utils/foreignLayers'
3
+
4
+ // Put every open Nuxt UI menu, select, popover and modal on the shared overlay stack, so the
5
+ // takeover or window under it leaves Escape and Tab to it. Why: `utils/foreignLayers.ts`.
6
+ export default defineNuxtPlugin(() => {
7
+ trackForeignLayers(document.body, sharedOverlayStack)
8
+ })
@@ -0,0 +1,100 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
2
+ import { createOverlayStack, type OverlayStack } from '@modular-vue/core'
3
+ import { trackForeignLayers } from '~/utils/foreignLayers'
4
+
5
+ /** A Reka-shaped layer as Nuxt UI portals it: a wrapper on `<body>` around the layer element. */
6
+ function openLayer(root: HTMLElement, role: string | null): HTMLElement {
7
+ const wrapper = document.createElement('div')
8
+ const layer = document.createElement('div')
9
+ layer.setAttribute('data-dismissable-layer', '')
10
+ layer.dataset.state = 'open'
11
+ if (role) layer.setAttribute('role', role)
12
+ wrapper.append(layer)
13
+ root.append(wrapper)
14
+ return layer
15
+ }
16
+
17
+ /** MutationObserver callbacks run as a microtask after the DOM change. */
18
+ const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
19
+
20
+ describe('trackForeignLayers', () => {
21
+ let root: HTMLElement
22
+ let stack: OverlayStack
23
+ let stop: () => void
24
+
25
+ beforeEach(() => {
26
+ root = document.createElement('div')
27
+ document.body.append(root)
28
+ stack = createOverlayStack()
29
+ stop = trackForeignLayers(root, stack)
30
+ })
31
+
32
+ afterEach(() => {
33
+ stop()
34
+ root.remove()
35
+ })
36
+
37
+ it('puts an open menu above the overlay under it, and gives the top back when it closes', async () => {
38
+ const takeover = stack.push()
39
+ const menu = openLayer(root, 'menu')
40
+ await settle()
41
+ expect(takeover.isTop()).toBe(false)
42
+
43
+ menu.parentElement!.remove()
44
+ await settle()
45
+ expect(takeover.isTop()).toBe(true)
46
+ expect(stack.size).toBe(1)
47
+ })
48
+
49
+ it('gives the top back when a layer starts its exit animation, before it leaves the DOM', async () => {
50
+ const takeover = stack.push()
51
+ const menu = openLayer(root, 'menu')
52
+ await settle()
53
+
54
+ menu.dataset.state = 'closed'
55
+ await settle()
56
+ expect(takeover.isTop()).toBe(true)
57
+ })
58
+
59
+ it('takes a ticket for each focus-taking role, and none for a tooltip', async () => {
60
+ for (const role of ['menu', 'listbox', 'dialog', 'alertdialog']) openLayer(root, role)
61
+ openLayer(root, null)
62
+ await settle()
63
+ expect(stack.size).toBe(4)
64
+ })
65
+
66
+ it('keeps open order, so a menu opened over a modal is the top layer', async () => {
67
+ const takeover = stack.push()
68
+ const modal = openLayer(root, 'dialog')
69
+ await settle()
70
+ const window = stack.push()
71
+ expect(window.isTop()).toBe(true)
72
+
73
+ // A menu opened inside the window goes above it, and closing the modal under both changes
74
+ // nothing for the window.
75
+ const menu = openLayer(root, 'menu')
76
+ await settle()
77
+ expect(window.isTop()).toBe(false)
78
+ modal.parentElement!.remove()
79
+ menu.parentElement!.remove()
80
+ await settle()
81
+ expect(window.isTop()).toBe(true)
82
+ window.release()
83
+ expect(takeover.isTop()).toBe(true)
84
+ })
85
+
86
+ it('counts a layer that was already open when tracking started', () => {
87
+ stop()
88
+ openLayer(root, 'dialog')
89
+ stop = trackForeignLayers(root, stack)
90
+ expect(stack.size).toBe(1)
91
+ })
92
+
93
+ it('releases every ticket when tracking stops', async () => {
94
+ openLayer(root, 'menu')
95
+ openLayer(root, 'dialog')
96
+ await settle()
97
+ stop()
98
+ expect(stack.size).toBe(0)
99
+ })
100
+ })
@@ -0,0 +1,71 @@
1
+ // Nuxt UI layers on the shared overlay stack.
2
+ //
3
+ // Two layer stacks run in this app. `useModalBehavior` (`TakeoverShell`, `ResultWindowShell`, the
4
+ // modular overlays) keeps `sharedOverlayStack`; Reka, under every Nuxt UI menu, select, popover
5
+ // and modal, keeps its own. Each one only knows its own layers, so a dropdown opened inside a
6
+ // takeover lost Escape: the shell handles the key first, in the capture phase, and calls
7
+ // `preventDefault()`, and Reka then leaves a prevented Escape alone. The takeover closed and the
8
+ // dropdown stayed.
9
+ //
10
+ // The fix is ONE stack. While a Reka layer is open it holds a ticket, pushed in open order, so
11
+ // it is the top overlay and `useModalBehavior` stands down: no Escape, no Tab trap. Reka then
12
+ // handles its own keys. Only the stack's own API is used here (`push`, `release`); what is
13
+ // Reka-specific is the selector.
14
+
15
+ import type { OverlayStack, OverlayStackTicket } from '@modular-vue/core'
16
+
17
+ /**
18
+ * An open Reka layer that takes focus and owns its keys. Reka marks every layer with
19
+ * `data-dismissable-layer`, and the role is on the same element: `menu` (dropdown menu), `listbox`
20
+ * (select), `dialog` (popover, modal), `alertdialog`. A tooltip is a layer too, but it has no
21
+ * role, so it never takes a ticket: focus stays on its trigger, and a ticket would switch off the
22
+ * Tab trap under it. `data-state="closed"` is a layer playing its exit animation.
23
+ */
24
+ export const FOREIGN_LAYER_SELECTOR =
25
+ '[data-dismissable-layer]:is([role="menu"],[role="listbox"],[role="dialog"],[role="alertdialog"]):not([data-state="closed"])'
26
+
27
+ const ANY_LAYER = '[data-dismissable-layer]'
28
+
29
+ /**
30
+ * Hold one `stack` ticket for each open layer under `root`, for as long as it is open. Returns the
31
+ * function that stops tracking and releases every ticket.
32
+ *
33
+ * What is observed is kept narrow, because `root` is `<body>` and the board re-renders under it
34
+ * constantly. Reka teleports each layer to `<body>`, so an opening or a closing is a change to
35
+ * `root`'s DIRECT children, which a non-subtree `childList` observer sees without ever being
36
+ * told about a board patch. A layer's own exit animation is its `data-state` flipping to
37
+ * `closed`, which the attribute observer sees; it is filtered to that one attribute and then to
38
+ * records whose target is a layer. Only those records trigger the one `sync` query.
39
+ */
40
+ export function trackForeignLayers(root: HTMLElement, stack: OverlayStack): () => void {
41
+ const tickets = new Map<Element, OverlayStackTicket>()
42
+
43
+ function sync(): void {
44
+ const open = new Set(root.querySelectorAll(FOREIGN_LAYER_SELECTOR))
45
+ for (const [layer, ticket] of tickets) {
46
+ if (open.has(layer)) continue
47
+ ticket.release()
48
+ tickets.delete(layer)
49
+ }
50
+ // Document order is open order: Reka appends each layer's portal to the end of `<body>`.
51
+ for (const layer of open) {
52
+ if (!tickets.has(layer)) tickets.set(layer, stack.push())
53
+ }
54
+ }
55
+
56
+ const portals = new MutationObserver(sync)
57
+ portals.observe(root, { childList: true })
58
+
59
+ const layerState = new MutationObserver((records) => {
60
+ if (records.some((r) => r.target instanceof Element && r.target.matches(ANY_LAYER))) sync()
61
+ })
62
+ layerState.observe(root, { subtree: true, attributes: true, attributeFilter: ['data-state'] })
63
+ sync()
64
+
65
+ return () => {
66
+ portals.disconnect()
67
+ layerState.disconnect()
68
+ for (const ticket of tickets.values()) ticket.release()
69
+ tickets.clear()
70
+ }
71
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.315.6",
3
+ "version": "0.315.8",
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",
@@ -18,7 +18,7 @@
18
18
  "access": "public"
19
19
  },
20
20
  "dependencies": {
21
- "@cat-factory/contracts": "0.368.0",
21
+ "@cat-factory/contracts": "0.369.0",
22
22
  "@modular-frontend/core": "0.6.0",
23
23
  "@modular-vue/core": "^1.5.0",
24
24
  "@modular-vue/journeys": "^1.4.0",