@cat-factory/app 0.45.1 → 0.45.3

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.
@@ -18,6 +18,10 @@ const connection = computed(() =>
18
18
  source.value ? documents.connectionFor(source.value) : undefined,
19
19
  )
20
20
  const connected = computed(() => connection.value !== undefined)
21
+ // A `credentialScope: 'user'` source (e.g. Claude Design) stores a PERSONAL credential
22
+ // keyed to the signed-in user and never shared with the workspace — surface that so a
23
+ // member understands they're connecting their own account, not the team's.
24
+ const isPersonal = computed(() => descriptor.value?.credentialScope === 'user')
21
25
 
22
26
  const open = computed({
23
27
  get: () => ui.documentConnect !== null,
@@ -90,6 +94,18 @@ async function disconnect() {
90
94
  structure or attach them to tasks as agent context.
91
95
  </p>
92
96
 
97
+ <p
98
+ v-if="isPersonal"
99
+ class="flex items-start gap-1.5 text-xs text-amber-400/90"
100
+ data-testid="document-source-personal-note"
101
+ >
102
+ <UIcon name="i-lucide-user" class="mt-0.5 size-3.5 shrink-0" />
103
+ <span>
104
+ Personal connection — this credential authenticates as you and is stored only for your
105
+ account, never shared with the rest of the workspace.
106
+ </span>
107
+ </p>
108
+
93
109
  <div class="space-y-3">
94
110
  <UFormField
95
111
  v-for="field in descriptor.credentialFields"
@@ -81,7 +81,7 @@ const decisionItems = computed(() =>
81
81
 
82
82
  <template>
83
83
  <div
84
- class="absolute left-1/2 top-4 z-20 flex -translate-x-1/2 items-center gap-1 rounded-full border border-slate-700 bg-slate-900/90 px-2 py-1.5 shadow-xl backdrop-blur"
84
+ class="absolute left-1/2 top-3 z-20 flex max-w-[calc(100vw-1rem)] -translate-x-1/2 items-center gap-1 overflow-x-auto rounded-full border border-slate-700 bg-slate-900/90 px-2 py-1.5 shadow-xl backdrop-blur"
85
85
  >
86
86
  <!-- zoom controls -->
87
87
  <UButton
@@ -91,7 +91,8 @@ const decisionItems = computed(() =>
91
91
  size="sm"
92
92
  @click="zoomOut()"
93
93
  />
94
- <div class="w-20 text-center text-xs tabular-nums text-slate-300">
94
+ <!-- The zoom %/LOD readout is the first thing to drop on narrow viewports. -->
95
+ <div class="hidden w-20 text-center text-xs tabular-nums text-slate-300 sm:block">
95
96
  {{ zoomPct }}%
96
97
  <div class="text-[9px] uppercase tracking-wide text-slate-500">{{ lodLabel }}</div>
97
98
  </div>
@@ -115,16 +116,17 @@ const decisionItems = computed(() =>
115
116
  icon="i-lucide-circle-help"
116
117
  data-testid="decision-badge"
117
118
  >
118
- {{ execution.pendingDecisionCount }} decision{{
119
- execution.pendingDecisionCount === 1 ? '' : 's'
120
- }}
119
+ {{ execution.pendingDecisionCount
120
+ }}<span class="hidden sm:inline"
121
+ >&nbsp;{{ $t('board.toolbar.decisionWord', execution.pendingDecisionCount) }}</span
122
+ >
121
123
  </UButton>
122
124
  </UDropdownMenu>
123
125
 
124
126
  <!-- in-org sharing: add an existing org service to this board -->
125
127
  <UDropdownMenu v-if="mountableItems.length" :items="mountableItems">
126
128
  <UButton color="neutral" variant="ghost" size="sm" icon="i-lucide-plus-circle">
127
- Add service
129
+ <span class="hidden sm:inline">{{ $t('board.toolbar.addService') }}</span>
128
130
  </UButton>
129
131
  </UDropdownMenu>
130
132
 
@@ -140,7 +142,7 @@ const decisionItems = computed(() =>
140
142
  icon="i-lucide-wallet"
141
143
  :title="spend?.exceeded ? 'Spend limit reached — runs paused' : 'Token spend this month'"
142
144
  >
143
- {{ spendLabel }}
145
+ <span class="hidden sm:inline">{{ spendLabel }}</span>
144
146
  </UButton>
145
147
  </div>
146
148
  </template>
@@ -174,7 +174,7 @@ function revealDecision(n: Notification) {
174
174
  </UButton>
175
175
 
176
176
  <template #content>
177
- <div class="max-h-[28rem] w-96 overflow-y-auto p-2">
177
+ <div class="max-h-[28rem] w-[min(24rem,92vw)] overflow-y-auto p-2">
178
178
  <div class="px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
179
179
  Needs your attention
180
180
  </div>
@@ -5,8 +5,10 @@
5
5
  // actions, repository management, integration management, the workspace-wide
6
6
  // context-fragment library, and workspace configuration (merge thresholds +
7
7
  // default models).
8
+ import { useEventListener, useScrollLock } from '@vueuse/core'
8
9
  import BoardSwitcher from '~/components/layout/BoardSwitcher.vue'
9
10
  import UserMenu from '~/components/auth/UserMenu.vue'
11
+ import { useViewport } from '~/composables/useViewport'
10
12
 
11
13
  const documents = useDocumentsStore()
12
14
  const tasks = useTasksStore()
@@ -17,6 +19,60 @@ const workspace = useWorkspaceStore()
17
19
  const accounts = useAccountsStore()
18
20
  const ui = useUiStore()
19
21
 
22
+ // `isCompact` (< lg) is the breakpoint at which the navbar is an off-canvas drawer;
23
+ // above it the aside is static and the drawer flag is inert.
24
+ const { isCompact } = useViewport()
25
+
26
+ // The off-canvas drawer is a modal surface on compact viewports, so give it the
27
+ // expected affordances:
28
+ // • Escape closes it (keyboard parity with the backdrop tap),
29
+ // • body scroll is locked while it's open (defensive — the shell root is already
30
+ // `overflow-hidden`, so this just guards any future scrollable ancestor),
31
+ // • crossing back to lg+ clears the flag so it can't linger as stale open state,
32
+ // • focus moves into the drawer on open, and back to the hamburger on close,
33
+ // • when closed (off-screen) on compact the whole aside is `inert`, so its nav
34
+ // controls aren't reachable by keyboard / assistive tech behind the board.
35
+ const aside = ref<HTMLElement>()
36
+ const drawerOpen = computed(() => isCompact.value && ui.mobileNavOpen)
37
+
38
+ const bodyLocked = useScrollLock(import.meta.client ? document.body : null)
39
+ watchEffect(() => {
40
+ bodyLocked.value = drawerOpen.value
41
+ })
42
+
43
+ useEventListener('keydown', (e: KeyboardEvent) => {
44
+ if (e.key === 'Escape' && ui.mobileNavOpen) ui.closeMobileNav()
45
+ })
46
+
47
+ watch(isCompact, (compact) => {
48
+ if (!compact) ui.closeMobileNav()
49
+ })
50
+
51
+ // Closing via a nav action immediately opens a board-covering panel/modal that claims
52
+ // focus itself, so don't yank focus back to the hamburger in that case.
53
+ let suppressFocusRestore = false
54
+ watch(drawerOpen, (open) => {
55
+ if (open) {
56
+ suppressFocusRestore = false
57
+ void nextTick(() => aside.value?.focus())
58
+ } else if (isCompact.value && !suppressFocusRestore) {
59
+ void nextTick(() =>
60
+ document.querySelector<HTMLElement>('[data-testid="mobile-nav-toggle"]')?.focus(),
61
+ )
62
+ }
63
+ })
64
+
65
+ // On compact (< lg) viewports the navbar is an off-canvas drawer. Activating any
66
+ // nav control reveals a board-covering panel/modal, so close the drawer on the way
67
+ // out — otherwise it lingers in front of (or behind) whatever just opened. Scoped to
68
+ // the action sections (not the BoardSwitcher / UserMenu dropdowns at the ends).
69
+ function onNavAction(e: MouseEvent) {
70
+ if ((e.target as HTMLElement).closest('button, a')) {
71
+ suppressFocusRestore = true
72
+ ui.closeMobileNav()
73
+ }
74
+ }
75
+
20
76
  // Resolve whether the document-source / task-source / GitHub integrations are
21
77
  // enabled on the backend, so each section is hidden entirely when it is off
22
78
  // (mirrors how auth gates its UI). A 503 from a probe flips its `available` to
@@ -36,190 +92,219 @@ watch(
36
92
  </script>
37
93
 
38
94
  <template>
95
+ <!-- On < lg the navbar slides in over the board; this backdrop dims the board and
96
+ closes the drawer on tap. Hidden on lg+ where the navbar is a static aside. -->
97
+ <Transition
98
+ enter-active-class="transition-opacity duration-200"
99
+ leave-active-class="transition-opacity duration-200"
100
+ enter-from-class="opacity-0"
101
+ leave-to-class="opacity-0"
102
+ >
103
+ <div
104
+ v-if="ui.mobileNavOpen"
105
+ class="fixed inset-0 z-30 bg-slate-950/60 backdrop-blur-sm lg:hidden"
106
+ data-testid="sidebar-backdrop"
107
+ role="button"
108
+ tabindex="-1"
109
+ :aria-label="$t('common.close')"
110
+ @click="ui.closeMobileNav()"
111
+ />
112
+ </Transition>
113
+
39
114
  <aside
40
- class="flex h-full w-64 shrink-0 flex-col gap-4 overflow-y-auto border-r border-slate-800 bg-slate-900/80 p-3 backdrop-blur"
115
+ ref="aside"
116
+ data-testid="sidebar"
117
+ tabindex="-1"
118
+ :role="drawerOpen ? 'dialog' : undefined"
119
+ :aria-modal="drawerOpen ? 'true' : undefined"
120
+ :aria-label="isCompact ? $t('nav.menu') : undefined"
121
+ :inert="isCompact && !ui.mobileNavOpen"
122
+ class="fixed inset-y-0 left-0 z-40 flex h-full w-64 shrink-0 flex-col gap-4 overflow-y-auto border-r border-slate-800 bg-slate-900/95 p-3 backdrop-blur transition-transform duration-200 focus:outline-none lg:static lg:z-auto lg:translate-x-0 lg:bg-slate-900/80"
123
+ :class="ui.mobileNavOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'"
41
124
  >
42
125
  <BoardSwitcher />
43
126
 
44
- <!-- Command bar launcher (⌘K) — the primary way to create blocks / pipelines
127
+ <div class="contents" @click="onNavAction">
128
+ <!-- Command bar launcher (⌘K) — the primary way to create blocks / pipelines
45
129
  and reach every action below. -->
46
- <button
47
- type="button"
48
- class="flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-800/60 px-2.5 py-2 text-left text-sm text-slate-400 transition hover:border-slate-500 hover:bg-slate-800"
49
- @click="ui.openCommandBar()"
50
- >
51
- <UIcon name="i-lucide-search" class="h-4 w-4 shrink-0" />
52
- <span class="flex-1 truncate">Search or run a command…</span>
53
- <UKbd value="⌘K" />
54
- </button>
55
-
56
- <section>
57
- <h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
58
- Create
59
- </h2>
60
- <div class="space-y-1.5">
61
- <UButton
62
- block
63
- color="primary"
64
- variant="soft"
65
- size="sm"
66
- icon="i-lucide-workflow"
67
- class="justify-start"
68
- @click="ui.openBuilder()"
69
- >
70
- Build a pipeline
71
- </UButton>
72
- </div>
73
- </section>
74
-
75
- <USeparator />
76
-
77
- <section>
78
- <h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
79
- Repositories
80
- </h2>
81
- <div class="space-y-1.5">
82
- <UButton
83
- v-if="github.available"
84
- block
85
- color="primary"
86
- variant="soft"
87
- size="sm"
88
- icon="i-lucide-folder-git-2"
89
- class="justify-start"
90
- @click="ui.openAddService()"
91
- >
92
- Add from existing repo
93
- </UButton>
94
- <UButton
95
- block
96
- color="primary"
97
- variant="soft"
98
- size="sm"
99
- icon="i-lucide-git-branch-plus"
100
- class="justify-start"
101
- @click="ui.openBootstrap()"
102
- >
103
- Bootstrap repo
104
- </UButton>
105
- </div>
106
- </section>
107
-
108
- <USeparator />
109
-
110
- <section>
111
- <h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
112
- Integrations
113
- </h2>
114
- <div class="space-y-1.5">
115
- <!-- Every external system the workspace can enable/link now lives behind
130
+ <button
131
+ type="button"
132
+ class="flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-800/60 px-2.5 py-2 text-left text-sm text-slate-400 transition hover:border-slate-500 hover:bg-slate-800"
133
+ @click="ui.openCommandBar()"
134
+ >
135
+ <UIcon name="i-lucide-search" class="h-4 w-4 shrink-0" />
136
+ <span class="flex-1 truncate">{{ $t('nav.commandBar') }}</span>
137
+ <UKbd value="⌘K" />
138
+ </button>
139
+
140
+ <section>
141
+ <h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
142
+ {{ $t('nav.create') }}
143
+ </h2>
144
+ <div class="space-y-1.5">
145
+ <UButton
146
+ block
147
+ color="primary"
148
+ variant="soft"
149
+ size="sm"
150
+ icon="i-lucide-workflow"
151
+ class="justify-start"
152
+ @click="ui.openBuilder()"
153
+ >
154
+ {{ $t('nav.buildPipeline') }}
155
+ </UButton>
156
+ </div>
157
+ </section>
158
+
159
+ <USeparator />
160
+
161
+ <section>
162
+ <h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
163
+ {{ $t('nav.repositories') }}
164
+ </h2>
165
+ <div class="space-y-1.5">
166
+ <UButton
167
+ v-if="github.available"
168
+ block
169
+ color="primary"
170
+ variant="soft"
171
+ size="sm"
172
+ icon="i-lucide-folder-git-2"
173
+ class="justify-start"
174
+ @click="ui.openAddService()"
175
+ >
176
+ {{ $t('nav.addFromRepo') }}
177
+ </UButton>
178
+ <UButton
179
+ block
180
+ color="primary"
181
+ variant="soft"
182
+ size="sm"
183
+ icon="i-lucide-git-branch-plus"
184
+ class="justify-start"
185
+ @click="ui.openBootstrap()"
186
+ >
187
+ {{ $t('nav.bootstrapRepo') }}
188
+ </UButton>
189
+ </div>
190
+ </section>
191
+
192
+ <USeparator />
193
+
194
+ <section>
195
+ <h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
196
+ {{ $t('nav.integrations') }}
197
+ </h2>
198
+ <div class="space-y-1.5">
199
+ <!-- Every external system the workspace can enable/link now lives behind
116
200
  this single button — the hub modal lists them grouped (source control,
117
201
  communication, documents, trackers, observability, model providers). -->
118
- <UButton
119
- block
120
- color="primary"
121
- variant="soft"
122
- size="sm"
123
- icon="i-lucide-blocks"
124
- class="justify-start"
125
- @click="ui.openIntegrations()"
126
- >
127
- Integrations
128
- </UButton>
129
- <!-- The Sandbox: try prompt versions/models against graded fixtures, off to the
202
+ <UButton
203
+ block
204
+ color="primary"
205
+ variant="soft"
206
+ size="sm"
207
+ icon="i-lucide-blocks"
208
+ class="justify-start"
209
+ @click="ui.openIntegrations()"
210
+ >
211
+ {{ $t('nav.integrations') }}
212
+ </UButton>
213
+ <!-- The Sandbox: try prompt versions/models against graded fixtures, off to the
130
214
  side of the board. Opens the on-demand testing window. -->
131
- <UButton
132
- block
133
- color="primary"
134
- variant="soft"
135
- size="sm"
136
- icon="i-lucide-flask-conical"
137
- class="justify-start"
138
- @click="ui.openSandbox()"
139
- >
140
- Sandbox
141
- </UButton>
142
- <!-- The Kaizen screen: grading history + verified prompt/agent/model combos. -->
143
- <UButton
144
- block
145
- color="primary"
146
- variant="soft"
147
- size="sm"
148
- icon="i-lucide-sparkles"
149
- class="justify-start"
150
- @click="ui.openKaizen()"
151
- >
152
- Kaizen
153
- </UButton>
154
- </div>
155
- </section>
156
-
157
- <template v-if="library.available">
215
+ <UButton
216
+ block
217
+ color="primary"
218
+ variant="soft"
219
+ size="sm"
220
+ icon="i-lucide-flask-conical"
221
+ class="justify-start"
222
+ @click="ui.openSandbox()"
223
+ >
224
+ {{ $t('nav.sandbox') }}
225
+ </UButton>
226
+ <!-- The Kaizen screen: grading history + verified prompt/agent/model combos. -->
227
+ <UButton
228
+ block
229
+ color="primary"
230
+ variant="soft"
231
+ size="sm"
232
+ icon="i-lucide-sparkles"
233
+ class="justify-start"
234
+ @click="ui.openKaizen()"
235
+ >
236
+ {{ $t('nav.kaizen') }}
237
+ </UButton>
238
+ </div>
239
+ </section>
240
+
241
+ <template v-if="library.available">
242
+ <USeparator />
243
+ <section>
244
+ <h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
245
+ {{ $t('nav.workspaceContext') }}
246
+ </h2>
247
+ <UButton
248
+ block
249
+ color="primary"
250
+ variant="soft"
251
+ size="sm"
252
+ icon="i-lucide-book-marked"
253
+ class="justify-start"
254
+ @click="ui.openFragmentLibrary()"
255
+ >
256
+ {{ $t('nav.contextFragments') }}
257
+ </UButton>
258
+ </section>
259
+ </template>
260
+
158
261
  <USeparator />
159
262
  <section>
160
263
  <h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
161
- Workspace context
264
+ {{ $t('nav.configuration') }}
162
265
  </h2>
163
- <UButton
164
- block
165
- color="primary"
166
- variant="soft"
167
- size="sm"
168
- icon="i-lucide-book-marked"
169
- class="justify-start"
170
- @click="ui.openFragmentLibrary()"
171
- >
172
- Context fragments
173
- </UButton>
174
- </section>
175
- </template>
176
-
177
- <USeparator />
178
- <section>
179
- <h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
180
- Configuration
181
- </h2>
182
- <div class="space-y-1.5">
183
- <!-- Merge thresholds, issue writeback and default service best practices are
266
+ <div class="space-y-1.5">
267
+ <!-- Merge thresholds, issue writeback and default service best practices are
184
268
  now tabs inside Workspace settings. -->
185
- <UButton
186
- block
187
- color="primary"
188
- variant="soft"
189
- size="sm"
190
- icon="i-lucide-sliders-horizontal"
191
- class="justify-start"
192
- @click="ui.openWorkspaceSettings()"
193
- >
194
- Workspace settings
195
- </UButton>
196
- <UButton
197
- block
198
- color="primary"
199
- variant="soft"
200
- size="sm"
201
- icon="i-lucide-cpu"
202
- class="justify-start"
203
- @click="ui.openModelConfig()"
204
- >
205
- Model Configuration
206
- </UButton>
207
- <!-- Account & team: members + roles, invitations, email sender, account API keys.
269
+ <UButton
270
+ block
271
+ color="primary"
272
+ variant="soft"
273
+ size="sm"
274
+ icon="i-lucide-sliders-horizontal"
275
+ class="justify-start"
276
+ @click="ui.openWorkspaceSettings()"
277
+ >
278
+ {{ $t('nav.workspaceSettings') }}
279
+ </UButton>
280
+ <UButton
281
+ block
282
+ color="primary"
283
+ variant="soft"
284
+ size="sm"
285
+ icon="i-lucide-cpu"
286
+ class="justify-start"
287
+ @click="ui.openModelConfig()"
288
+ >
289
+ {{ $t('nav.modelConfiguration') }}
290
+ </UButton>
291
+ <!-- Account & team: members + roles, invitations, email sender, account API keys.
208
292
  Shown once accounts (auth) are enabled. -->
209
- <UButton
210
- v-if="accounts.enabled"
211
- block
212
- color="primary"
213
- variant="soft"
214
- size="sm"
215
- icon="i-lucide-users"
216
- class="justify-start"
217
- @click="ui.openAccountSettings()"
218
- >
219
- Account settings
220
- </UButton>
221
- </div>
222
- </section>
293
+ <UButton
294
+ v-if="accounts.enabled"
295
+ block
296
+ color="primary"
297
+ variant="soft"
298
+ size="sm"
299
+ icon="i-lucide-users"
300
+ class="justify-start"
301
+ @click="ui.openAccountSettings()"
302
+ >
303
+ {{ $t('nav.accountSettings') }}
304
+ </UButton>
305
+ </div>
306
+ </section>
307
+ </div>
223
308
 
224
309
  <UserMenu class="mt-auto" />
225
310
  </aside>
@@ -204,13 +204,16 @@ const showOriginalDescription = ref(false)
204
204
  <template>
205
205
  <div
206
206
  v-if="block && statusMeta && typeMeta"
207
- class="absolute right-4 top-4 z-20 w-80 overflow-hidden rounded-2xl border border-slate-700 bg-slate-900/95 shadow-2xl backdrop-blur"
207
+ data-testid="inspector-panel"
208
+ class="fixed inset-x-0 bottom-0 z-20 overflow-hidden rounded-t-2xl border border-slate-700 bg-slate-900/95 shadow-2xl backdrop-blur lg:absolute lg:inset-x-auto lg:bottom-auto lg:right-4 lg:top-4 lg:w-80 lg:rounded-2xl"
208
209
  >
209
210
  <div class="h-1.5 w-full" :style="{ backgroundColor: statusMeta.color }" />
210
211
  <!-- A tall task (execution steps + scenarios + docs) can overflow the
211
212
  viewport; cap the body height and let it scroll so the lower controls
212
- (Run / Focus / Delete) stay reachable. The status bar above stays put. -->
213
- <div class="max-h-[calc(100vh-5rem)] space-y-4 overflow-y-auto p-4">
213
+ (Run / Focus / Delete) stay reachable. The status bar above stays put.
214
+ On compact viewports the panel is a bottom sheet capped to the visible
215
+ height (dvh excludes mobile browser chrome). -->
216
+ <div class="max-h-[80dvh] space-y-4 overflow-y-auto p-4 lg:max-h-[calc(100vh-5rem)]">
214
217
  <!-- header -->
215
218
  <div class="flex items-start justify-between gap-2">
216
219
  <div class="flex items-center gap-2">
@@ -0,0 +1,19 @@
1
+ import { useBreakpoints, breakpointsTailwind, useMediaQuery } from '@vueuse/core'
2
+
3
+ /**
4
+ * Single source of truth for responsive / input-modality decisions across the SPA.
5
+ *
6
+ * `isCompact` is the app-wide "mobile/compact" flag: true below Tailwind's `lg`
7
+ * (1024px), matching the breakpoint the already-responsive surfaces use
8
+ * (`AgentStepDetail`, the review windows) so the whole shell stays consistent.
9
+ *
10
+ * `isTouch` reports a coarse pointer (phones/tablets) so a component can enlarge
11
+ * hit targets without affecting precise-pointer (mouse) desktops — orthogonal to
12
+ * width, since a small window on a desktop is compact but not touch.
13
+ */
14
+ export function useViewport() {
15
+ const breakpoints = useBreakpoints(breakpointsTailwind)
16
+ const isCompact = breakpoints.smaller('lg')
17
+ const isTouch = useMediaQuery('(pointer: coarse)')
18
+ return { isCompact, isTouch }
19
+ }
@@ -231,6 +231,20 @@ watch(
231
231
  <SideBar />
232
232
  <main class="relative min-w-0 flex-1">
233
233
  <BoardCanvas />
234
+ <!-- Compact-viewport navbar trigger: the SideBar is an off-canvas drawer below
235
+ lg, so surface a hamburger to open it. Hidden on lg+ (static sidebar). -->
236
+ <!-- z-30 keeps the trigger above the centered BoardToolbar (z-20), whose
237
+ max-width can otherwise reach this corner on the narrowest viewports. -->
238
+ <UButton
239
+ class="absolute left-3 top-3 z-30 lg:hidden"
240
+ icon="i-lucide-menu"
241
+ color="neutral"
242
+ variant="soft"
243
+ size="sm"
244
+ :aria-label="ui.mobileNavOpen ? $t('nav.closeMenu') : $t('nav.openMenu')"
245
+ data-testid="mobile-nav-toggle"
246
+ @click="ui.toggleMobileNav()"
247
+ />
234
248
  <BoardToolbar />
235
249
  <SpendWarningBanner />
236
250
  <InspectorPanel />
package/app/stores/ui.ts CHANGED
@@ -85,6 +85,11 @@ export const useUiStore = defineStore('ui', () => {
85
85
  // Command bar (⌘K) — searchable launcher for every navbar action.
86
86
  const commandBarOpen = ref(false)
87
87
 
88
+ // Mobile navigation drawer: on compact (< lg) viewports the SideBar is an
89
+ // off-canvas drawer toggled by a hamburger; on lg+ it is a static aside and this
90
+ // flag is ignored. Closed on any nav action so the board is revealed immediately.
91
+ const mobileNavOpen = ref(false)
92
+
88
93
  // Integrations hub: a single modal listing every external system the workspace
89
94
  // can enable/link (GitHub, Slack, document + task sources, Datadog, LLM vendors,
90
95
  // local runners, OpenRouter). Replaces the per-integration navbar buttons; each
@@ -393,6 +398,15 @@ export const useUiStore = defineStore('ui', () => {
393
398
  function toggleCommandBar() {
394
399
  commandBarOpen.value = !commandBarOpen.value
395
400
  }
401
+ function openMobileNav() {
402
+ mobileNavOpen.value = true
403
+ }
404
+ function closeMobileNav() {
405
+ mobileNavOpen.value = false
406
+ }
407
+ function toggleMobileNav() {
408
+ mobileNavOpen.value = !mobileNavOpen.value
409
+ }
396
410
  // Clear BOTH hub came-from markers. Every direct `open*` below calls this so that a
397
411
  // panel opened outside the hubs never grows a dead Back control, and so switching from
398
412
  // one hub's panel to the other's clears the stale marker.
@@ -638,6 +652,7 @@ export const useUiStore = defineStore('ui', () => {
638
652
  slackOpen,
639
653
  fragmentLibraryOpen,
640
654
  commandBarOpen,
655
+ mobileNavOpen,
641
656
  integrationsOpen,
642
657
  cameFromIntegrations,
643
658
  personalSetupOpen,
@@ -707,6 +722,9 @@ export const useUiStore = defineStore('ui', () => {
707
722
  openCommandBar,
708
723
  closeCommandBar,
709
724
  toggleCommandBar,
725
+ openMobileNav,
726
+ closeMobileNav,
727
+ toggleMobileNav,
710
728
  openIntegrations,
711
729
  closeIntegrations,
712
730
  openFromIntegrations,
@@ -3,7 +3,34 @@
3
3
  "save": "Save",
4
4
  "cancel": "Cancel",
5
5
  "retry": "Retry",
6
- "actionFailed": "Action failed"
6
+ "actionFailed": "Action failed",
7
+ "close": "Close"
8
+ },
9
+ "nav": {
10
+ "menu": "Navigation menu",
11
+ "openMenu": "Open menu",
12
+ "closeMenu": "Close menu",
13
+ "commandBar": "Search or run a command…",
14
+ "create": "Create",
15
+ "buildPipeline": "Build a pipeline",
16
+ "repositories": "Repositories",
17
+ "addFromRepo": "Add from existing repo",
18
+ "bootstrapRepo": "Bootstrap repo",
19
+ "integrations": "Integrations",
20
+ "sandbox": "Sandbox",
21
+ "kaizen": "Kaizen",
22
+ "workspaceContext": "Workspace context",
23
+ "contextFragments": "Context fragments",
24
+ "configuration": "Configuration",
25
+ "workspaceSettings": "Workspace settings",
26
+ "modelConfiguration": "Model Configuration",
27
+ "accountSettings": "Account settings"
28
+ },
29
+ "board": {
30
+ "toolbar": {
31
+ "addService": "Add service",
32
+ "decisionWord": "decision | decisions"
33
+ }
7
34
  },
8
35
  "errors": {
9
36
  "action": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.45.1",
3
+ "version": "0.45.3",
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",
@@ -34,7 +34,7 @@
34
34
  "pinia-plugin-persistedstate": "^4.7.1",
35
35
  "vue": "^3.5.38",
36
36
  "wretch": "^3.0.9",
37
- "@cat-factory/contracts": "0.42.0"
37
+ "@cat-factory/contracts": "0.43.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@toad-contracts/testing": "0.3.1",