@cat-factory/app 0.311.0 → 0.311.2

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
@@ -321,6 +321,15 @@ That is the whole mechanism, and it is why there is no rule here about WHICH ste
321
321
 
322
322
  The default `radius` lives on the `Cat Factory` document in `app/utils/theme/builtins.ts`. It equals `THEME_DEFAULTS.radius` and so writes no CSS, which is the point: changing the number there is the whole edit.
323
323
 
324
+ ### Full-screen overlays bind their root class
325
+
326
+ A phone's notch and home indicator cover whatever sits at the screen edge, and on a screen without them nothing looks wrong. So a full-screen overlay never spells `fixed inset-0` itself. Its root binds one of the two class lists in [`utils/safeArea.ts`](./app/utils/safeArea.ts), which carry the positioning, the `100dvh` cap and the safe-area padding, and the site adds only its stacking, direction and backdrop:
327
+
328
+ - **`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.
329
+ - **`WINDOWED_OVERLAY_CLASS`** for a window on a backdrop (`ResultWindowShell`). Its gutter is 1rem or the inset, whichever is larger.
330
+
331
+ `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.
332
+
324
333
  ### A control is its Nuxt UI component
325
334
 
326
335
  Every CONTROL in the SPA is the Nuxt UI component for it, never the raw HTML element. A raw `<button>` beside a `UButton` is the same control rendered twice, and only one of the two follows the theme, the focus ring, the disabled state and the size scale; the SPA carried 152 of them, 24 raw `<input>`, five hand-built tables and six hand-rolled `<details>`, each with its own border, background and hover recipe.
@@ -1485,7 +1494,7 @@ the same list, plus the install-free frontend guards.
1485
1494
  ```bash
1486
1495
  # From the repo root:
1487
1496
  pnpm install
1488
- pnpm dev:frontend # Nuxt dev server via deploy/frontend; set NUXT_PUBLIC_API_BASE to a running Worker
1497
+ pnpm dev:frontend # Nuxt dev server via deploy/frontend; set NUXT_PUBLIC_API_BASE to a running backend
1489
1498
  pnpm exec turbo run typecheck --filter=@cat-factory/app # typecheck goes through Turbo
1490
1499
  pnpm lint # oxlint + oxfmt over the whole tree
1491
1500
 
@@ -1495,6 +1504,10 @@ pnpm i18n:check # catalog is well-formed
1495
1504
  pnpm i18n:parity # locales are in parity
1496
1505
  ```
1497
1506
 
1507
+ To see the SPA with data while you work on it, run it against the e2e backend: it needs only a
1508
+ Postgres, fakes the LLM, the agent runner and GitHub, and can seed boards. See
1509
+ [Running the SPA against the e2e backend](https://github.com/kibertoad/cat-factory/blob/main/backend/internal/e2e/README.md#running-the-spa-against-the-e2e-backend-ui-development).
1510
+
1498
1511
  > Building/deploying the static site is covered in the deployment docs: see the
1499
1512
  > [top-level README → Deployment](https://github.com/kibertoad/cat-factory/blob/main/README.md#deployment) and
1500
1513
  > [`deploy/frontend/README.md`](https://github.com/kibertoad/cat-factory/blob/main/deploy/frontend/README.md).
package/app/app.config.ts CHANGED
@@ -23,25 +23,6 @@ export default defineAppConfig({
23
23
  badge: {
24
24
  defaultVariants: { color: 'primary', variant: 'solid', size: 'md' },
25
25
  },
26
- // Give every overlay the same layered surface the agent-run-details reader uses: the deep
27
- // `app-950` surface (below `bg-default`) so the `bg-default` panels/cards inside pop, with
28
- // `border-default` chrome. All tokens are theme role tokens (`app-950` is the hand-defined
29
- // flipping token for the deep shade), so overlays track dark/light. Applies to all
30
- // UModal/USlideover instances so they stay consistent without per-instance `:ui` overrides.
31
- modal: {
32
- slots: {
33
- content: 'bg-app-950 ring-default divide-default',
34
- header: 'border-b border-default',
35
- title: 'text-highlighted',
36
- },
37
- },
38
- slideover: {
39
- slots: {
40
- content: 'bg-app-950 ring-default divide-default',
41
- header: 'border-b border-default',
42
- title: 'text-highlighted',
43
- },
44
- },
45
26
  // The toaster's scroll region is the ONE `[data-slot='viewport']` the app's own
46
27
  // CSS may target: Nuxt UI reuses that attribute for the item list of every menu
47
28
  // component too. This marker class is what `main.css` hangs the safe-area offset
@@ -84,6 +84,16 @@ body,
84
84
  height: 100%;
85
85
  }
86
86
 
87
+ /* No pull-to-refresh and no scroll chaining out of the app. The SPA is a full-screen canvas whose
88
+ * body never scrolls, so a vertical overscroll that reaches the viewport (from a modal's header,
89
+ * the end of a list, a gutter) has nothing to scroll and on a phone would reload the app instead,
90
+ * dropping whatever was open. Setting it on the root once covers every overlay, and inner scrollers
91
+ * still chain to the scroller around them. */
92
+ html,
93
+ body {
94
+ overscroll-behavior-y: none;
95
+ }
96
+
87
97
  body {
88
98
  margin: 0;
89
99
  overflow: hidden;
@@ -7,6 +7,7 @@ const { connecting } = useDependencyConnect()
7
7
  </script>
8
8
 
9
9
  <template>
10
+ <!-- safe-area-ok: a pointer-events-none drawing layer with no controls or text -->
10
11
  <svg
11
12
  v-if="connecting"
12
13
  class="pointer-events-none fixed inset-0 z-50 h-full w-full overflow-visible"
@@ -309,7 +309,7 @@ watch(open, (isOpen) => {
309
309
  </div>
310
310
 
311
311
  <div class="grid gap-2 text-xs sm:grid-cols-2">
312
- <div class="rounded-sm bg-default/60 p-2">
312
+ <div class="rounded-sm bg-elevated/60 p-2">
313
313
  <p class="mb-1 font-semibold text-toned">
314
314
  {{ t('bootstrap.adoption.side.monorepo') }}
315
315
  </p>
@@ -317,7 +317,7 @@ watch(open, (isOpen) => {
317
317
  {{ decision.monorepoPractice ?? t('bootstrap.adoption.side.nothing') }}
318
318
  </p>
319
319
  </div>
320
- <div class="rounded-sm bg-default/60 p-2">
320
+ <div class="rounded-sm bg-elevated/60 p-2">
321
321
  <p class="mb-1 font-semibold text-toned">
322
322
  {{ t('bootstrap.adoption.side.template') }}
323
323
  </p>
@@ -9,9 +9,10 @@ function reload() {
9
9
  </script>
10
10
 
11
11
  <template>
12
+ <!-- safe-area-ok: a scrim with one card centred on it, so no content reaches an edge -->
12
13
  <div
13
14
  data-testid="async-view-error"
14
- class="fixed inset-0 z-50 grid place-items-center bg-app-950/80 p-6 backdrop-blur-sm"
15
+ class="fixed inset-0 z-50 grid place-items-center bg-elevated/75 p-6 backdrop-blur-sm"
15
16
  role="alert"
16
17
  >
17
18
  <div class="max-w-sm rounded-2xl border border-muted bg-default p-5 shadow-2xl">
@@ -136,7 +136,7 @@ async function unlink(doc: SourceDocument) {
136
136
  </p>
137
137
  <div
138
138
  v-if="template"
139
- class="mt-2 flex items-center justify-between gap-2 rounded-md bg-default/70 px-3 py-2"
139
+ class="mt-2 flex items-center justify-between gap-2 rounded-md bg-elevated/70 px-3 py-2"
140
140
  >
141
141
  <DocumentOriginLink
142
142
  :url="template.url"
@@ -173,7 +173,7 @@ async function unlink(doc: SourceDocument) {
173
173
  <div
174
174
  v-for="doc in exemplars"
175
175
  :key="`${doc.source}:${doc.externalId}`"
176
- class="flex items-center justify-between gap-2 rounded-md bg-default/70 px-3 py-2"
176
+ class="flex items-center justify-between gap-2 rounded-md bg-elevated/70 px-3 py-2"
177
177
  >
178
178
  <DocumentOriginLink
179
179
  :url="doc.url"
@@ -4,6 +4,7 @@ import { onKeyStroke } from '@vueuse/core'
4
4
  import type { TableColumn } from '@nuxt/ui'
5
5
  import type { KaizenGrading } from '~/types/domain'
6
6
  import { agentKindMeta } from '~/utils/catalog'
7
+ import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
7
8
 
8
9
  // The Kaizen screen: a full-panel overlay listing the workspace's grading history and
9
10
  // its verified-combo library. Opened via `ui.openKaizen()` from the sidebar. Read-only —
@@ -66,7 +67,8 @@ const historyColumns = computed<TableColumn<KaizenGrading>[]>(() => [
66
67
  <Transition name="kz-fade">
67
68
  <div
68
69
  v-if="open"
69
- class="fixed inset-0 z-[60] flex flex-col bg-app-950/96 backdrop-blur-sm"
70
+ class="z-[60] flex-col bg-app-950/96 backdrop-blur-sm"
71
+ :class="FULL_BLEED_TAKEOVER_CLASS"
70
72
  role="dialog"
71
73
  aria-modal="true"
72
74
  >
@@ -154,9 +154,10 @@ watch(
154
154
  enter-from-class="opacity-0"
155
155
  leave-to-class="opacity-0"
156
156
  >
157
+ <!-- safe-area-ok: a scrim with no content; the drawer below pads its own edges -->
157
158
  <div
158
159
  v-if="ui.mobileNavOpen"
159
- class="fixed inset-0 z-30 bg-app-950/60 backdrop-blur-sm lg:hidden"
160
+ class="fixed inset-0 z-30 bg-elevated/75 backdrop-blur-sm lg:hidden"
160
161
  data-testid="sidebar-backdrop"
161
162
  role="button"
162
163
  tabindex="-1"
@@ -165,6 +166,8 @@ watch(
165
166
  />
166
167
  </Transition>
167
168
 
169
+ <!-- safe-area-ok: below lg, an edge drawer that pads the top, bottom and inline-start insets
170
+ itself. The start side is physical left in LTR and right in RTL, hence the `rtl:` override. -->
168
171
  <aside
169
172
  ref="aside"
170
173
  data-testid="sidebar"
@@ -173,7 +176,7 @@ watch(
173
176
  :aria-modal="drawerOpen ? 'true' : undefined"
174
177
  :aria-label="isCompact ? t('nav.menu') : undefined"
175
178
  :inert="isCompact && !ui.mobileNavOpen"
176
- class="fixed inset-y-0 start-0 z-40 flex h-full w-64 shrink-0 flex-col gap-4 overflow-y-auto border-e border-default bg-default/95 px-3 pt-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] backdrop-blur transition-transform duration-200 focus:outline-none lg:static lg:z-auto lg:translate-x-0 lg:bg-default/80"
179
+ class="fixed inset-y-0 start-0 z-40 flex h-full w-64 shrink-0 flex-col gap-4 overflow-y-auto border-e border-default bg-default/95 px-3 pt-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] max-lg:ps-[calc(0.75rem+env(safe-area-inset-left))] max-lg:pt-[calc(0.75rem+env(safe-area-inset-top))] max-lg:rtl:ps-[calc(0.75rem+env(safe-area-inset-right))] backdrop-blur transition-transform duration-200 focus:outline-none lg:static lg:z-auto lg:translate-x-0 lg:bg-default/80"
177
180
  :class="[
178
181
  ui.mobileNavOpen
179
182
  ? 'translate-x-0'
@@ -10,6 +10,7 @@ import { computed, ref, watch } from 'vue'
10
10
  import { useModalBehavior } from '@modular-vue/core'
11
11
  import type { ArtifactBlobs } from '~/composables/useArtifactBlobs'
12
12
  import IconButton from '~/components/common/IconButton.vue'
13
+ import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
13
14
 
14
15
  interface LightboxItem {
15
16
  artifactId: string
@@ -161,7 +162,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey, true))
161
162
  v-if="open"
162
163
  ref="dialogRef"
163
164
  tabindex="-1"
164
- class="fixed inset-0 z-[60] flex flex-col bg-app-950/95 backdrop-blur-sm focus:outline-none"
165
+ class="z-[60] flex-col bg-app-950/95 backdrop-blur-sm focus:outline-none"
166
+ :class="FULL_BLEED_TAKEOVER_CLASS"
165
167
  role="dialog"
166
168
  aria-modal="true"
167
169
  data-testid="artifact-lightbox"
@@ -170,7 +172,6 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey, true))
170
172
  ? t('media.lightbox.ariaLabel', { label: current.label })
171
173
  : t('media.lightbox.ariaLabelFallback')
172
174
  "
173
- @click.self="close"
174
175
  >
175
176
  <!-- Toolbar -->
176
177
  <div class="flex items-center gap-3 border-b border-default/60 px-4 py-2.5">
@@ -31,6 +31,7 @@ import InputGateNotice from '~/components/inputGate/InputGateNotice.vue'
31
31
  import RunDetailLoadState from '~/components/panels/RunDetailLoadState.vue'
32
32
  import SectionLabel from '~/components/common/SectionLabel.vue'
33
33
  import IconButton from '~/components/common/IconButton.vue'
34
+ import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
34
35
 
35
36
  // Detail overlay for a single pipeline step. Opened by clicking an agent in the
36
37
  // inspector list (TaskExecution) or the focus-view pipeline (PipelineProgress) via
@@ -393,7 +394,8 @@ async function copyOutput() {
393
394
  v-if="open && step && agent"
394
395
  data-testid="step-detail"
395
396
  :data-agent-kind="step?.agentKind"
396
- class="fixed inset-0 z-50 flex max-h-[100dvh] bg-app-950/96 backdrop-blur-sm"
397
+ class="z-50 bg-app-950/96 backdrop-blur-sm"
398
+ :class="FULL_BLEED_TAKEOVER_CLASS"
397
399
  role="dialog"
398
400
  aria-modal="true"
399
401
  >
@@ -959,7 +961,7 @@ async function copyOutput() {
959
961
  <!-- edit-then-approve actions -->
960
962
  <div
961
963
  v-if="editing"
962
- class="space-y-2 border-t border-default px-4 pt-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))]"
964
+ class="space-y-2 border-t border-default px-4 pt-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] lg:pb-3"
963
965
  >
964
966
  <UButton
965
967
  size="sm"
@@ -984,7 +986,7 @@ async function copyOutput() {
984
986
 
985
987
  <div
986
988
  v-else
987
- class="space-y-2 border-t border-default px-4 pt-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))]"
989
+ class="space-y-2 border-t border-default px-4 pt-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] lg:pb-3"
988
990
  >
989
991
  <UButton
990
992
  data-testid="step-approve"
@@ -339,6 +339,7 @@ const showOriginalDescription = ref(false)
339
339
  NOT `data-block-id`: that attribute is the board's card selector, which the canvas drivers
340
340
  measure geometry through (`utils/blockRects.ts`), and a panel answering to it would offer
341
341
  the arrows a rect that is not on the canvas at all. -->
342
+ <!-- safe-area-ok: a bottom sheet below lg; its scroller pads the bottom and side insets -->
342
343
  <div
343
344
  v-if="block && statusMeta && typeMeta"
344
345
  data-testid="inspector-panel"
@@ -352,7 +353,7 @@ const showOriginalDescription = ref(false)
352
353
  On compact viewports the panel is a bottom sheet capped to the visible
353
354
  height (dvh excludes mobile browser chrome). -->
354
355
  <div
355
- class="max-h-[80dvh] space-y-4 overflow-y-auto overscroll-contain px-4 pt-4 pb-[calc(1rem+env(safe-area-inset-bottom))] lg:max-h-[calc(100vh-7rem)]"
356
+ class="max-h-[80dvh] space-y-4 overflow-y-auto overscroll-contain pt-4 pr-[max(1rem,env(safe-area-inset-right))] pb-[calc(1rem+env(safe-area-inset-bottom))] pl-[max(1rem,env(safe-area-inset-left))] lg:max-h-[calc(100vh-7rem)]"
356
357
  >
357
358
  <!-- header -->
358
359
  <div class="flex items-start justify-between gap-2">
@@ -32,6 +32,7 @@ import RunFailureSummary from '~/components/observability/RunFailureSummary.vue'
32
32
  import ToolCallList from '~/components/observability/ToolCallList.vue'
33
33
  import SectionLabel from '~/components/common/SectionLabel.vue'
34
34
  import IconButton from '~/components/common/IconButton.vue'
35
+ import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
35
36
 
36
37
  /** No run selected: the same empty, NOT-truncated trajectory the store answers with. */
37
38
  const EMPTY_TRAJECTORY: RunToolCallTrajectory = Object.freeze({
@@ -537,17 +538,18 @@ function exportJson() {
537
538
  <Transition name="obs-fade">
538
539
  <div
539
540
  v-if="open"
540
- class="fixed inset-0 z-[60] flex flex-col bg-app-950/96 backdrop-blur-sm"
541
+ class="z-[60] flex-col bg-app-950/96 backdrop-blur-sm"
542
+ :class="FULL_BLEED_TAKEOVER_CLASS"
541
543
  role="dialog"
542
544
  aria-modal="true"
543
545
  >
544
- <header class="flex items-center gap-3 border-b border-default px-6 py-4">
546
+ <header class="flex flex-wrap items-center gap-3 border-b border-default px-6 py-4">
545
547
  <div
546
548
  class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-app-info-500/15"
547
549
  >
548
550
  <UIcon name="i-lucide-activity" class="h-5 w-5 text-app-info-400" />
549
551
  </div>
550
- <div class="min-w-0">
552
+ <div class="min-w-0 flex-1">
551
553
  <h1 class="truncate text-base font-semibold text-highlighted">
552
554
  {{ t('observability.modelActivity') }}
553
555
  </h1>
@@ -555,7 +557,11 @@ function exportJson() {
555
557
  {{ block.title }} · {{ runSubtitle }}
556
558
  </p>
557
559
  </div>
558
- <div class="ms-auto flex items-center gap-1.5">
560
+ <!-- Below lg the controls take their own row, so the close button stays on screen.
561
+ The row scrolls sideways when the tabs are wider than the phone. -->
562
+ <div
563
+ class="order-last flex w-full min-w-0 items-center gap-1.5 overflow-x-auto lg:order-none lg:w-auto"
564
+ >
559
565
  <!-- Four VIEWS of the same run, which is what tabs mean. The trajectory is loaded
560
566
  lazily the first time the tools view is opened, so the change is watched rather
561
567
  than bound straight to `view`. -->
@@ -580,15 +586,16 @@ function exportJson() {
580
586
  >
581
587
  {{ t('observability.exportJson') }}
582
588
  </UButton>
583
- <IconButton
584
- icon="i-lucide-x"
585
- color="neutral"
586
- variant="ghost"
587
- size="sm"
588
- :label="t('observability.closeEsc')"
589
- @click="close"
590
- />
591
589
  </div>
590
+ <IconButton
591
+ class="lg:-ms-1.5"
592
+ icon="i-lucide-x"
593
+ color="neutral"
594
+ variant="ghost"
595
+ size="sm"
596
+ :label="t('observability.closeEsc')"
597
+ @click="close"
598
+ />
592
599
  </header>
593
600
 
594
601
  <div class="flex-1 overflow-auto px-6 py-6">
@@ -7,6 +7,7 @@ import { formatMs } from '~/utils/observability'
7
7
  import { FAILURE_KIND_KEYS, isAgentFailureKind } from '~/utils/failureKinds'
8
8
  import SectionLabel from '~/components/common/SectionLabel.vue'
9
9
  import IconButton from '~/components/common/IconButton.vue'
10
+ import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
10
11
 
11
12
  // Deployment-level (platform-operator) observability dashboard: the aggregate health of the
12
13
  // active account's runs — outcome totals + success rate, a time-bucketed outcome trend, the
@@ -122,24 +123,29 @@ watch(
122
123
  <Transition name="obs-fade">
123
124
  <div
124
125
  v-if="open"
125
- class="fixed inset-0 z-[60] flex flex-col bg-app-950/96 backdrop-blur-sm"
126
+ class="z-[60] flex-col bg-app-950/96 backdrop-blur-sm"
127
+ :class="FULL_BLEED_TAKEOVER_CLASS"
126
128
  role="dialog"
127
129
  aria-modal="true"
128
130
  data-testid="operator-dashboard"
129
131
  >
130
- <header class="flex items-center gap-3 border-b border-default px-6 py-4">
132
+ <header class="flex flex-wrap items-center gap-3 border-b border-default px-6 py-4">
131
133
  <div
132
134
  class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-app-info-500/15"
133
135
  >
134
136
  <UIcon name="i-lucide-gauge" class="h-5 w-5 text-app-info-400" />
135
137
  </div>
136
- <div class="min-w-0">
138
+ <div class="min-w-0 flex-1">
137
139
  <h1 class="truncate text-base font-semibold text-highlighted">
138
140
  {{ t('platformObservability.title') }}
139
141
  </h1>
140
142
  <p v-if="accountName" class="truncate text-xs text-dimmed">{{ accountName }}</p>
141
143
  </div>
142
- <div class="ms-auto flex items-center gap-1.5">
144
+ <!-- Below lg the window tabs take their own row, so refresh and close stay on screen.
145
+ The row scrolls sideways when the tabs are wider than the phone. -->
146
+ <div
147
+ class="order-last flex w-full min-w-0 items-center gap-1.5 overflow-x-auto lg:order-none lg:w-auto"
148
+ >
143
149
  <UTabs
144
150
  :model-value="platform.window"
145
151
  :items="WINDOWS"
@@ -154,31 +160,33 @@ watch(
154
160
  <span :data-testid="`operator-window-${item.value}`">{{ item.label }}</span>
155
161
  </template>
156
162
  </UTabs>
157
- <IconButton
158
- color="neutral"
159
- variant="ghost"
160
- icon="i-lucide-refresh-cw"
161
- :label="t('platformObservability.refresh')"
162
- :ui="{
163
- base: 'rounded-lg border border-default p-1.5 text-muted transition hover:text-default',
164
- leadingIcon: loading ? 'h-4 w-4 animate-spin' : 'h-4 w-4',
165
- }"
166
- data-testid="operator-refresh"
167
- @click="refresh"
168
- />
169
- <IconButton
170
- color="neutral"
171
- variant="ghost"
172
- icon="i-lucide-x"
173
- :label="t('platformObservability.close')"
174
- :ui="{
175
- base: 'rounded-lg border border-default p-1.5 text-muted transition hover:text-default',
176
- leadingIcon: 'h-4 w-4',
177
- }"
178
- data-testid="operator-close"
179
- @click="close"
180
- />
181
163
  </div>
164
+ <IconButton
165
+ class="lg:-ms-1.5"
166
+ color="neutral"
167
+ variant="ghost"
168
+ icon="i-lucide-refresh-cw"
169
+ :label="t('platformObservability.refresh')"
170
+ :ui="{
171
+ base: 'rounded-lg border border-default p-1.5 text-muted transition hover:text-default',
172
+ leadingIcon: loading ? 'h-4 w-4 animate-spin' : 'h-4 w-4',
173
+ }"
174
+ data-testid="operator-refresh"
175
+ @click="refresh"
176
+ />
177
+ <IconButton
178
+ class="-ms-1.5"
179
+ color="neutral"
180
+ variant="ghost"
181
+ icon="i-lucide-x"
182
+ :label="t('platformObservability.close')"
183
+ :ui="{
184
+ base: 'rounded-lg border border-default p-1.5 text-muted transition hover:text-default',
185
+ leadingIcon: 'h-4 w-4',
186
+ }"
187
+ data-testid="operator-close"
188
+ @click="close"
189
+ />
182
190
  </header>
183
191
 
184
192
  <div class="flex-1 overflow-y-auto px-6 py-5">
@@ -21,6 +21,7 @@ import {
21
21
  import ReportsSpendBreakdown from '~/components/panels/ReportsSpendBreakdown.vue'
22
22
  import SectionLabel from '~/components/common/SectionLabel.vue'
23
23
  import IconButton from '~/components/common/IconButton.vue'
24
+ import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
24
25
 
25
26
  // Reports: cross-cutting usage analytics for the active account — where the spend and the
26
27
  // work actually go. Spend per model and agent kind, spend + run activity per workspace /
@@ -196,7 +197,8 @@ watch(
196
197
  <Transition name="reports-fade">
197
198
  <div
198
199
  v-if="open"
199
- class="fixed inset-0 z-[60] flex flex-col bg-app-950/96 backdrop-blur-sm"
200
+ class="z-[60] flex-col bg-app-950/96 backdrop-blur-sm"
201
+ :class="FULL_BLEED_TAKEOVER_CLASS"
200
202
  role="dialog"
201
203
  aria-modal="true"
202
204
  data-testid="reports-panel"
@@ -11,10 +11,10 @@ export type ResultWindowWidth = '3xl' | '4xl' | '5xl' | 'full'
11
11
 
12
12
  /**
13
13
  * The bucket → cap mapping. `full` is deliberately `max-w-none` rather than a bigger number:
14
- * the panel's `w-full` then spans the backdrop, which the variant insets by one gutter (`m-4`
15
- * stretched, `p-4` centered), so the window fills the screen and still reads as a window rather
16
- * than a repaint of the app. A `Record` over the union, so a new bucket fails to compile until
17
- * it is mapped.
14
+ * the panel's `w-full` then spans the backdrop, which insets it by one gutter
15
+ * (`WINDOWED_OVERLAY_CLASS`, `utils/safeArea.ts`), so the window fills the screen and still reads
16
+ * as a window rather than a repaint of the app. A `Record` over the union, so a new bucket fails to
17
+ * compile until it is mapped.
18
18
  */
19
19
  export const RESULT_WINDOW_WIDTH_CLASS: Record<ResultWindowWidth, string> = {
20
20
  '3xl': 'max-w-3xl',
@@ -40,6 +40,7 @@ import {
40
40
  RESULT_WINDOW_WIDTH_CLASS,
41
41
  type ResultWindowWidth,
42
42
  } from '~/components/panels/ResultWindowShell.logic'
43
+ import { WINDOWED_OVERLAY_CLASS } from '~/utils/safeArea'
43
44
  import SectionLabel from '~/components/common/SectionLabel.vue'
44
45
 
45
46
  /** A pipeline step reference — passed by step-result windows to surface the shared
@@ -255,13 +256,17 @@ const chipClass = computed(() =>
255
256
  )
256
257
 
257
258
  const backdropClass = computed(() => [
258
- 'fixed inset-0 z-50 flex max-h-[100dvh] justify-center bg-app-950/70 backdrop-blur-sm',
259
- props.variant === 'centered' ? 'items-center p-4' : 'items-stretch',
259
+ WINDOWED_OVERLAY_CLASS,
260
+ 'z-50 justify-center bg-elevated/75 backdrop-blur-sm',
261
+ props.variant === 'centered' ? 'items-center' : 'items-stretch',
260
262
  ])
263
+ // The centered cap is the smaller of 90dvh and the backdrop's padded box: with a large inset (a
264
+ // Dynamic Island in standalone mode) the gutter alone can exceed 10% of the height, and a window
265
+ // sized to 90dvh would then push its top edge back under the status bar.
261
266
  const panelClass = computed(() => [
262
267
  'flex w-full flex-col overflow-hidden rounded-2xl border border-default bg-default shadow-2xl',
263
268
  RESULT_WINDOW_WIDTH_CLASS[props.width],
264
- props.variant === 'centered' ? 'max-h-[90dvh]' : 'm-4',
269
+ props.variant === 'centered' && 'max-h-[min(90dvh,100%)]',
265
270
  ])
266
271
  </script>
267
272
 
@@ -24,6 +24,7 @@ import ProviderPreferenceEditor from '~/components/settings/ProviderPreferenceEd
24
24
  import { showOverrideField } from '~/utils/uiMode'
25
25
  import SectionLabel from '~/components/common/SectionLabel.vue'
26
26
  import IconButton from '~/components/common/IconButton.vue'
27
+ import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
27
28
 
28
29
  const { t } = useI18n()
29
30
  const ui = useUiStore()
@@ -300,7 +301,8 @@ async function save() {
300
301
  <Transition name="reader-fade">
301
302
  <div
302
303
  v-if="open"
303
- class="fixed inset-0 z-50 flex max-h-[100dvh] flex-col bg-app-950/96 backdrop-blur-sm"
304
+ class="z-50 flex-col bg-app-950/96 backdrop-blur-sm"
305
+ :class="FULL_BLEED_TAKEOVER_CLASS"
304
306
  role="dialog"
305
307
  aria-modal="true"
306
308
  >
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { FULL_BLEED_TAKEOVER_CLASS, WINDOWED_OVERLAY_CLASS } from '~/utils/safeArea'
3
+
4
+ // A missing or misspelled side is silent on every screen without a notch, so the exact spellings
5
+ // are pinned here. That every full-screen surface binds one of these is
6
+ // `scripts/check-frontend-safe-area.mjs`'s job.
7
+ describe('full-screen overlay root classes', () => {
8
+ it('gives a takeover only the inset, so it stays edge to edge without one', () => {
9
+ expect(FULL_BLEED_TAKEOVER_CLASS.split(' ')).toEqual([
10
+ 'fixed',
11
+ 'inset-0',
12
+ 'flex',
13
+ 'max-h-[100dvh]',
14
+ 'pt-[env(safe-area-inset-top)]',
15
+ 'pr-[env(safe-area-inset-right)]',
16
+ 'pb-[env(safe-area-inset-bottom)]',
17
+ 'pl-[env(safe-area-inset-left)]',
18
+ ])
19
+ })
20
+
21
+ it('gives a windowed backdrop 1rem or the inset, whichever is larger, on every side', () => {
22
+ expect(WINDOWED_OVERLAY_CLASS.split(' ')).toEqual([
23
+ 'fixed',
24
+ 'inset-0',
25
+ 'flex',
26
+ 'max-h-[100dvh]',
27
+ 'pt-[max(1rem,env(safe-area-inset-top))]',
28
+ 'pr-[max(1rem,env(safe-area-inset-right))]',
29
+ 'pb-[max(1rem,env(safe-area-inset-bottom))]',
30
+ 'pl-[max(1rem,env(safe-area-inset-left))]',
31
+ ])
32
+ })
33
+ })
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The root classes of the full-screen overlays, with the safe-area padding built in, so a phone's
3
+ * notch, rounded corners and home indicator never cover a header, a close button or an edge. The
4
+ * insets are physical, so the sides are too. They resolve to 0 unless the viewport meta carries
5
+ * `viewport-fit=cover` (`nuxt.config.ts`).
6
+ *
7
+ * A full-screen overlay binds one of these two constants on its root instead of spelling
8
+ * `fixed inset-0` itself, so it cannot drop the padding or the `100dvh` cap. The site adds only its
9
+ * stacking, direction and backdrop. `scripts/check-frontend-safe-area.mjs` refuses a full-screen
10
+ * `fixed` surface written anywhere else.
11
+ *
12
+ * Every class is spelled out literally: Tailwind generates only the class names it finds in the
13
+ * source text, so a name assembled at runtime would compile to nothing.
14
+ */
15
+
16
+ const FULL_SCREEN = 'fixed inset-0 flex max-h-[100dvh]'
17
+
18
+ /**
19
+ * A full-bleed takeover (`AgentStepDetail`, the model configuration takeover, the panels and the
20
+ * lightbox): only the inset, so on a screen without one the layout is exactly the edge-to-edge one
21
+ * it was designed as.
22
+ */
23
+ export const FULL_BLEED_TAKEOVER_CLASS = [
24
+ FULL_SCREEN,
25
+ 'pt-[env(safe-area-inset-top)]',
26
+ 'pr-[env(safe-area-inset-right)]',
27
+ 'pb-[env(safe-area-inset-bottom)]',
28
+ 'pl-[env(safe-area-inset-left)]',
29
+ ].join(' ')
30
+
31
+ /**
32
+ * A windowed overlay's backdrop (`ResultWindowShell`): 1rem, or the inset on that side when it is
33
+ * larger, so the window always floats clear of the screen edge and of the notch.
34
+ */
35
+ export const WINDOWED_OVERLAY_CLASS = [
36
+ FULL_SCREEN,
37
+ 'pt-[max(1rem,env(safe-area-inset-top))]',
38
+ 'pr-[max(1rem,env(safe-area-inset-right))]',
39
+ 'pb-[max(1rem,env(safe-area-inset-bottom))]',
40
+ 'pl-[max(1rem,env(safe-area-inset-left))]',
41
+ ].join(' ')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.311.0",
3
+ "version": "0.311.2",
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",
@@ -47,14 +47,14 @@
47
47
  "@toad-contracts/testing": "1.0.0",
48
48
  "happy-dom": "^20.14.5",
49
49
  "msw": "^2.15.0",
50
- "nuxt": "^4.6.0",
50
+ "nuxt": "^4.6.1",
51
51
  "typescript": "^6.0.3",
52
52
  "vitest": "^4.1.11",
53
53
  "vue-i18n-extract": "^2.0.7",
54
54
  "vue-tsc": "^3.3.12"
55
55
  },
56
56
  "peerDependencies": {
57
- "nuxt": "^4.5.2"
57
+ "nuxt": "^4.5.2 <4.6.0 || ^4.6.1"
58
58
  },
59
59
  "scripts": {
60
60
  "postinstall": "nuxt prepare",