@cat-factory/app 0.310.2 → 0.311.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +24 -10
  2. package/app/components/documents/DocumentSourceConnectModal.vue +1 -1
  3. package/app/components/foundational/FoundationalServiceCatalogList.vue +2 -0
  4. package/app/components/foundational/FoundationalServiceRegistry.vue +9 -2
  5. package/app/components/foundational/FoundationalServiceSources.vue +2 -0
  6. package/app/components/foundational/FoundationalSuppressions.vue +1 -0
  7. package/app/components/fragments/FragmentLibraryManager.vue +4 -0
  8. package/app/components/github/GitHubOnboarding.vue +9 -1
  9. package/app/components/github/RepoTreeBrowser.vue +3 -0
  10. package/app/components/guidedReview/GuidedReviewDraftCard.vue +16 -4
  11. package/app/components/guidedReview/GuidedReviewModal.vue +1 -0
  12. package/app/components/guidedReview/GuidedReviewOverview.vue +1 -0
  13. package/app/components/humanTest/HumanTestWindow.vue +3 -2
  14. package/app/components/initiative/InitiativePlanningWindow.vue +1 -1
  15. package/app/components/judge/JudgeResultView.vue +1 -0
  16. package/app/components/layout/AccountDeploymentSettings.vue +8 -8
  17. package/app/components/layout/AccountTeamSettings.vue +2 -2
  18. package/app/components/layout/BoardSwitcher.vue +33 -14
  19. package/app/components/layout/DefaultTestEnvBanner.vue +4 -1
  20. package/app/components/layout/InfraSetupBanner.vue +26 -2
  21. package/app/components/layout/InfraSetupNotice.vue +69 -0
  22. package/app/components/layout/ProviderConfigBanner.vue +8 -1
  23. package/app/components/panels/AgentStepDetail.vue +2 -0
  24. package/app/components/panels/InspectorPanel.vue +1 -1
  25. package/app/components/panels/inspector/ServiceReleaseHealthConfig.vue +1 -1
  26. package/app/components/panels/inspector/ServiceTestSecrets.vue +1 -1
  27. package/app/components/panels/inspector/ServiceValidationConfig.vue +1 -1
  28. package/app/components/panels/inspector/TaskExecution.vue +1 -1
  29. package/app/components/pipeline/PipelineBuilder.vue +1 -1
  30. package/app/components/pipeline/PipelineHealthModal.vue +2 -2
  31. package/app/components/providers/AiProviderOnboardingModal.vue +7 -1
  32. package/app/components/providers/ApiKeysSection.vue +21 -2
  33. package/app/components/provisioning/ProvisioningLogsDrawer.vue +1 -0
  34. package/app/components/sandbox/SandboxPanel.vue +1 -0
  35. package/app/components/settings/InfraHandlersConfigurator.vue +1 -1
  36. package/app/components/settings/ProviderConnectionTab.vue +1 -0
  37. package/app/components/settings/ToolServerChecklist.vue +1 -1
  38. package/app/components/skills/SkillLibraryManager.vue +2 -0
  39. package/app/components/slack/SlackPanel.vue +1 -1
  40. package/app/components/tasks/TaskSourceConnectModal.vue +1 -1
  41. package/app/components/testing/TestReportWindow.vue +2 -0
  42. package/app/components/vcs/VcsConnectSurfaces.vue +30 -14
  43. package/app/components/visualConfirm/VisualConfirmationWindow.vue +1 -0
  44. package/app/composables/useSetupActor.ts +25 -0
  45. package/app/composables/useWorkspaceAccess.ts +1 -1
  46. package/app/modular/nav-gates.ts +1 -2
  47. package/app/stores/accounts.ts +8 -0
  48. package/app/stores/workspace/infraSetup.ts +11 -3
  49. package/app/stores/workspace.ts +3 -1
  50. package/app/types/domain.ts +2 -0
  51. package/app/utils/setupAudience.spec.ts +115 -0
  52. package/app/utils/setupAudience.ts +102 -0
  53. package/i18n/locales/de.json +15 -1
  54. package/i18n/locales/en.json +15 -1
  55. package/i18n/locales/es.json +15 -1
  56. package/i18n/locales/fr.json +15 -1
  57. package/i18n/locales/he.json +15 -1
  58. package/i18n/locales/it.json +15 -1
  59. package/i18n/locales/ja.json +15 -1
  60. package/i18n/locales/pl.json +15 -1
  61. package/i18n/locales/tr.json +15 -1
  62. package/i18n/locales/uk.json +15 -1
  63. package/package.json +2 -2
package/README.md CHANGED
@@ -334,16 +334,22 @@ Every CONTROL in the SPA is the Nuxt UI component for it, never the raw HTML ele
334
334
 
335
335
  What a primary, a secondary or a destructive action looks like is decided once, here, and not per call site. The weights are Nuxt UI's own [variant table](https://github.com/nuxt/ui/blob/v4/skills/nuxt-ui/references/design-system.md) (`solid` highest, then `outline`, `soft`, `subtle`, `ghost`, and `link` for a text link inside content), mapped onto the app's kinds of action:
336
336
 
337
- | Kind | Props |
338
- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------- |
339
- | Primary action, ONE per view | none (the default, `primary solid`) |
340
- | Secondary action in a dialog footer or a window header | `color="neutral" variant="outline"` |
341
- | Inline, navigation and chrome actions | `color="neutral" variant="ghost"`, usually with `size="xs"` |
342
- | Icon-only action | `IconButton` with `color="neutral" variant="ghost"` |
343
- | Destructive action | `color="error" variant="soft"`; `color="error"` alone (solid) only as the confirm of a dialog |
344
- | Status chip | `UBadge` with `variant="subtle" size="sm"`, and a `BadgeColor` map for the colour |
345
-
346
- - **The defaults are Nuxt UI's own**: `primary solid md` for both `UButton` and `UBadge`, stated in `app.config.ts` (`ui.button.defaultVariants`, `ui.badge.defaultVariants`) so the value has one visible home and the guard can read it. A call site states only what DIFFERS. Sibling buttons never mix variants.
337
+ | Kind | Props |
338
+ | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
339
+ | Primary action, ONE per view | none (the default, `primary solid`) |
340
+ | Dismissal in a dialog or form (Cancel, Close) | `color="neutral" variant="ghost"` |
341
+ | Real secondary action (Save draft, Back) | `color="neutral" variant="outline"` |
342
+ | Bulk action in a dialog footer, beside Close (Reseed all, Update all) | `variant="ghost"` (primary colour): the dialog's main action, kept light because each row above it has its own button |
343
+ | Send-back on a gate (bounce, request a fix) | `color="warning" variant="soft"`, so the approve beside it stays the one primary |
344
+ | Inline, navigation and chrome actions | `color="neutral" variant="ghost"`, usually with `size="xs"` |
345
+ | Icon-only action | `IconButton` with `color="neutral" variant="ghost"` |
346
+ | Destructive action, labelled | `color="error" variant="soft"`; `color="error"` alone (solid) only as the confirm step of a destructive action (a dialog's confirm, or an armed inline confirm such as a step's reject) |
347
+ | Destructive action, icon-only (a row's trash icon) | `IconButton` with `color="error" variant="ghost"` |
348
+ | A banner's call to action | solid, in the banner's colour (`color="warning"`, `color="error"`) |
349
+ | Status chip | `UBadge` with `variant="subtle" size="sm"`, and a `BadgeColor` map for the colour |
350
+
351
+ - **The defaults are Nuxt UI's own**: `primary solid md` for both `UButton` and `UBadge`, stated in `app.config.ts` (`ui.button.defaultVariants`, `ui.badge.defaultVariants`) so the value has one visible home and the guard can read it. A call site states only what DIFFERS.
352
+ - **One solid button per view.** Two solid buttons side by side compete for the same attention, so the second action takes a lighter variant from the table. The guard refuses two solid buttons under one parent element; it counts a `v-if` chain as one button and a `v-for` list as one, looks through a plain `<template>` wrapper, and treats a slot `<template #name>` as its own region. A file holding several forms that each end in a Save is several views and passes. A site that genuinely needs two says why with a `solid-ok:` comment on its own line, directly above the second button.
347
353
  - **The unstyled group is the one a theme restyles, so it is the primary tier.** A theme document's `style.defaults` (a size, a button variant, a button colour) REPLACES the default for every control that does not state that prop; it does not scale or shift the others. So whichever group is unstyled is the group a theme moves. With the primary actions unstyled, a theme with `size: 'lg'` or `variants: { buttons: 'outline' }` restyles the actions and leaves the chrome alone. With the quiet chrome unstyled, the same theme would grow or fill every toolbar and icon button past the actions and invert the hierarchy. This is why the dense, quiet chrome carries its props and the primary action carries none, and why Nuxt UI's own internal buttons (a modal's close button, a toast's actions) agree with the app's.
348
354
  - **A restated default is a bug, not noise.** It pins the value against a theme that changes the default, and it hides which props are the reason a control looks the way it does. `scripts/check-frontend-variants.mjs` (CI) refuses a value equal to the default on `UButton` and `UBadge` (either casing, either quote style, or a key of a `v-bind="{ … }"` object) and on every component that spreads its attributes into one (`IconButton`; the list is derived, so a new wrapper is covered), reading the defaults from `app.config.ts` itself. Inside a `UFieldGroup` or `UButtonGroup` it does not check `size`: the group sets the size there, so an explicit `size` overrides the group rather than restating the default. It reads template tags only, so a `ButtonProps` object (a toast's `actions`, a modal's `:close`) is not checked. A site that must keep a literal says why with a `variant-default-ok:` comment on its own line, directly above the tag; it waives that tag only.
349
355
 
@@ -605,6 +611,14 @@ The seams, and what a new feature should use rather than reading the store ad ho
605
611
  - **A surface that narrows inline** reads `useUiRoleStore().fullSurface` (the frame header's bug-hunt
606
612
  button, the palette's per-connection integration commands). Same rule as the tier: what remains
607
613
  must be exactly what the full surface would have shown, only less of it.
614
+ - **A setup prompt** (a "configure this" card over the board) goes through `canActOnSetup`
615
+ (`app/utils/setupAudience.ts`) with the caller from `useSetupActor()`: it combines the role's
616
+ surface with the board and account grants and the owner the server reports for the gap
617
+ (`infraSetupOwners`). A caller who cannot act gets no card. If the gap stops runs they could start,
618
+ it reaches them as one line saying how it gets fixed (`blockingSetupNotice`): the owner to ask, or
619
+ the role switch when they hold the grant and only the designer surface hides the card. A viewer
620
+ gets nothing, and the line honours the card's dismissals. Never a button that opens a screen the
621
+ caller's save is refused on.
608
622
  - **A tutorial tour whose step clicks a non-`intake` nav entry declares
609
623
  `TUTORIAL_REQUIREMENTS.fullSurface`.** Which tours those are is not a judgement call:
610
624
  `tutorial-tours.spec.ts` derives the pairing from `navItemVisible`, so a tour that gains such a
@@ -157,7 +157,7 @@ async function disconnect() {
157
157
  <UButton
158
158
  v-if="connected"
159
159
  color="error"
160
- variant="ghost"
160
+ variant="soft"
161
161
  icon="i-lucide-unplug"
162
162
  @click="disconnect"
163
163
  >
@@ -105,6 +105,7 @@ async function suppress(serviceId: string) {
105
105
 
106
106
  <div class="mt-2 flex items-center gap-2">
107
107
  <UButton
108
+ color="neutral"
108
109
  v-if="s.contracts.length"
109
110
  size="xs"
110
111
  variant="ghost"
@@ -141,6 +142,7 @@ async function suppress(serviceId: string) {
141
142
  deployment registered in code; the board's own row is managed in the registry tab,
142
143
  where deleting it is the honest action. -->
143
144
  <IconButton
145
+ color="neutral"
144
146
  v-if="s.tier !== 'workspace'"
145
147
  icon="i-lucide-eye-off"
146
148
  size="xs"
@@ -205,6 +205,7 @@ async function remove(service: FoundationalService) {
205
205
  </div>
206
206
  <div class="flex shrink-0 gap-1">
207
207
  <IconButton
208
+ color="neutral"
208
209
  v-if="!s.sourceId"
209
210
  icon="i-lucide-pencil"
210
211
  size="xs"
@@ -310,7 +311,13 @@ async function remove(service: FoundationalService) {
310
311
  :placeholder="t('foundational.registry.contractBodyPlaceholder')"
311
312
  />
312
313
  </div>
313
- <UButton icon="i-lucide-plus" size="xs" variant="ghost" @click="addContract">
314
+ <UButton
315
+ color="neutral"
316
+ icon="i-lucide-plus"
317
+ size="xs"
318
+ variant="ghost"
319
+ @click="addContract"
320
+ >
314
321
  {{ t('foundational.registry.addContract') }}
315
322
  </UButton>
316
323
  </div>
@@ -325,7 +332,7 @@ async function remove(service: FoundationalService) {
325
332
  >
326
333
  {{ t('foundational.registry.save') }}
327
334
  </UButton>
328
- <UButton size="sm" variant="ghost" @click="editing = null">
335
+ <UButton color="neutral" size="sm" variant="ghost" @click="editing = null">
329
336
  {{ t('foundational.registry.cancel') }}
330
337
  </UButton>
331
338
  </div>
@@ -297,6 +297,7 @@ async function unlink(id: string) {
297
297
  </UBadge>
298
298
  <div class="ms-auto flex gap-1">
299
299
  <IconButton
300
+ color="neutral"
300
301
  icon="i-lucide-search-check"
301
302
  size="xs"
302
303
  variant="ghost"
@@ -305,6 +306,7 @@ async function unlink(id: string) {
305
306
  @click="check(s.id)"
306
307
  />
307
308
  <IconButton
309
+ color="neutral"
308
310
  icon="i-lucide-refresh-cw"
309
311
  size="xs"
310
312
  variant="ghost"
@@ -81,6 +81,7 @@ async function restore(serviceId: string) {
81
81
  </p>
82
82
  </div>
83
83
  <UButton
84
+ color="neutral"
84
85
  icon="i-lucide-eye"
85
86
  size="xs"
86
87
  variant="ghost"
@@ -752,6 +752,7 @@ async function unlinkSource(id: string) {
752
752
  <!-- Editing a repo-SOURCED fragment locally would be overwritten on the next sync,
753
753
  so only hand-authored fragments are editable here. -->
754
754
  <IconButton
755
+ color="neutral"
755
756
  v-if="!f.source"
756
757
  icon="i-lucide-pencil"
757
758
  size="xs"
@@ -868,6 +869,7 @@ async function unlinkSource(id: string) {
868
869
  </div>
869
870
  <div class="ms-auto flex gap-1">
870
871
  <IconButton
872
+ color="neutral"
871
873
  icon="i-lucide-refresh-cw"
872
874
  size="xs"
873
875
  variant="ghost"
@@ -1026,6 +1028,7 @@ async function unlinkSource(id: string) {
1026
1028
  </UBadge>
1027
1029
  <div class="ms-auto flex gap-1">
1028
1030
  <UButton
1031
+ color="neutral"
1029
1032
  icon="i-lucide-search-check"
1030
1033
  size="xs"
1031
1034
  variant="ghost"
@@ -1033,6 +1036,7 @@ async function unlinkSource(id: string) {
1033
1036
  @click="checkSource(s.id)"
1034
1037
  />
1035
1038
  <UButton
1039
+ color="neutral"
1036
1040
  icon="i-lucide-refresh-cw"
1037
1041
  size="xs"
1038
1042
  variant="ghost"
@@ -3,8 +3,11 @@
3
3
  // has no connection yet. cat-factory's whole flow runs on a connected repository host (agents
4
4
  // open pull/merge requests on the user's repos), so the board is withheld until the workspace
5
5
  // connects one. <VcsConnectSurfaces> renders whichever connect methods the deployment serves; a
6
- // "Sign out" escape hatch avoids trapping a user who needs to switch accounts.
6
+ // "Sign out" escape hatch avoids trapping a user who needs to switch accounts. The board switcher is
7
+ // the other escape: the gate replaces the whole board, sidebar included, so without it a member
8
+ // who landed on a board they cannot connect had no way to reach a board they can.
7
9
  import VcsConnectSurfaces from '~/components/vcs/VcsConnectSurfaces.vue'
10
+ import BoardSwitcher from '~/components/layout/BoardSwitcher.vue'
8
11
  import { VCS_PROVIDER_ICONS, VCS_PROVIDER_LABELS } from '~/utils/vcs'
9
12
 
10
13
  const { t } = useI18n()
@@ -39,6 +42,11 @@ const title = computed(() =>
39
42
 
40
43
  <VcsConnectSurfaces :app-intro="t('github.onboarding.appIntro')" />
41
44
 
45
+ <div class="mt-6 border-t border-default pt-4" data-testid="vcs-onboarding-switch-board">
46
+ <p class="mb-2 text-xs text-muted">{{ t('vcs.onboarding.switchBoard') }}</p>
47
+ <BoardSwitcher standalone />
48
+ </div>
49
+
42
50
  <p
43
51
  v-if="auth.required && auth.user"
44
52
  class="mt-6 border-t border-default pt-4 text-center text-xs text-dimmed"
@@ -207,6 +207,7 @@ watch(
207
207
  class="flex items-center justify-between gap-2 px-3 py-1.5"
208
208
  >
209
209
  <UButton
210
+ color="neutral"
210
211
  variant="ghost"
211
212
  class="flex items-center gap-2 truncate p-0 text-sm text-default hover:bg-transparent hover:text-primary"
212
213
  @click="browseTo(entry.path)"
@@ -257,6 +258,7 @@ watch(
257
258
  @update:model-value="toggleAllFiles"
258
259
  />
259
260
  <UButton
261
+ color="neutral"
260
262
  variant="ghost"
261
263
  class="p-0 text-xs text-muted hover:bg-transparent hover:text-primary"
262
264
  @click="toggleAllFiles"
@@ -283,6 +285,7 @@ watch(
283
285
  @update:model-value="pick(entry.path)"
284
286
  />
285
287
  <UButton
288
+ color="neutral"
286
289
  variant="ghost"
287
290
  class="flex items-center gap-2 truncate p-0 text-sm hover:bg-transparent hover:text-primary"
288
291
  :class="isPicked(entry.path) ? 'text-primary' : 'text-toned'"
@@ -114,7 +114,13 @@ async function discard(): Promise<void> {
114
114
  <UButton size="sm" :loading="saving" @click="save">
115
115
  {{ t('guidedReview.drafts.save') }}
116
116
  </UButton>
117
- <UButton size="sm" variant="ghost" :disabled="saving" @click="editing = false">
117
+ <UButton
118
+ color="neutral"
119
+ size="sm"
120
+ variant="ghost"
121
+ :disabled="saving"
122
+ @click="editing = false"
123
+ >
118
124
  {{ t('guidedReview.drafts.cancel') }}
119
125
  </UButton>
120
126
  </div>
@@ -126,13 +132,19 @@ async function discard(): Promise<void> {
126
132
  {{ t('guidedReview.drafts.postFailed') }}: {{ draft.postError }}
127
133
  </p>
128
134
  <div v-if="editable" class="mt-2 flex gap-2">
129
- <UButton size="xs" variant="ghost" icon="i-lucide-pencil" @click="startEdit">
135
+ <UButton
136
+ color="neutral"
137
+ size="xs"
138
+ variant="ghost"
139
+ icon="i-lucide-pencil"
140
+ @click="startEdit"
141
+ >
130
142
  {{ t('guidedReview.drafts.edit') }}
131
143
  </UButton>
132
144
  <UButton
145
+ color="error"
146
+ variant="soft"
133
147
  size="xs"
134
- variant="ghost"
135
- color="neutral"
136
148
  icon="i-lucide-trash-2"
137
149
  :loading="saving"
138
150
  @click="discard"
@@ -218,6 +218,7 @@ const title = computed(() =>
218
218
  <span class="max-w-48 truncate">{{ threadTitle(tab) }}</span>
219
219
  </UButton>
220
220
  <UButton
221
+ color="neutral"
221
222
  v-if="!draftOpen"
222
223
  size="sm"
223
224
  variant="ghost"
@@ -36,6 +36,7 @@ const SEVERITY_COLOR = { high: 'error', medium: 'warning', low: 'neutral' } as c
36
36
  </UBadge>
37
37
  <span class="flex-1" />
38
38
  <UButton
39
+ color="neutral"
39
40
  size="xs"
40
41
  variant="ghost"
41
42
  icon="i-lucide-refresh-cw"
@@ -223,9 +223,9 @@ const canDestroy = computed(
223
223
  {{ t('humanTest.actions.recreate') }}
224
224
  </UButton>
225
225
  <UButton
226
- size="xs"
226
+ color="error"
227
227
  variant="soft"
228
- color="neutral"
228
+ size="xs"
229
229
  icon="i-lucide-trash-2"
230
230
  :disabled="busy || !canDestroy || !access.canExecuteRuns.value"
231
231
  :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
@@ -281,6 +281,7 @@ const canDestroy = computed(
281
281
  class="w-full"
282
282
  />
283
283
  <UButton
284
+ variant="soft"
284
285
  size="sm"
285
286
  color="warning"
286
287
  icon="i-lucide-wrench"
@@ -343,7 +343,7 @@ async function onDiscard() {
343
343
  <UButton
344
344
  v-if="canDiscard"
345
345
  color="error"
346
- variant="ghost"
346
+ variant="soft"
347
347
  size="sm"
348
348
  icon="i-lucide-trash-2"
349
349
  :loading="resetting"
@@ -280,6 +280,7 @@ async function act(choice: 'proceed' | 'bounce' | 'stop') {
280
280
  {{ t('judge.stop') }}
281
281
  </UButton>
282
282
  <UButton
283
+ variant="soft"
283
284
  size="sm"
284
285
  color="warning"
285
286
  icon="i-lucide-undo-2"
@@ -483,9 +483,9 @@ async function clearWeb() {
483
483
  {{ t('common.save') }}
484
484
  </UButton>
485
485
  <UButton
486
+ color="error"
487
+ variant="soft"
486
488
  v-if="summary?.slackOAuthConfigured"
487
- color="neutral"
488
- variant="ghost"
489
489
  size="xs"
490
490
  :loading="savingSlack"
491
491
  @click="clearSlack"
@@ -538,9 +538,9 @@ async function clearWeb() {
538
538
  {{ t('common.save') }}
539
539
  </UButton>
540
540
  <UButton
541
+ color="error"
542
+ variant="soft"
541
543
  v-if="summary?.linearOAuthConfigured"
542
- color="neutral"
543
- variant="ghost"
544
544
  size="xs"
545
545
  :loading="savingLinear"
546
546
  @click="clearLinear"
@@ -593,9 +593,9 @@ async function clearWeb() {
593
593
  {{ t('common.save') }}
594
594
  </UButton>
595
595
  <UButton
596
+ color="error"
597
+ variant="soft"
596
598
  v-if="summary?.figmaOAuthConfigured"
597
- color="neutral"
598
- variant="ghost"
599
599
  size="xs"
600
600
  :loading="savingFigma"
601
601
  @click="clearFigma"
@@ -644,9 +644,9 @@ async function clearWeb() {
644
644
  {{ t('common.save') }}
645
645
  </UButton>
646
646
  <UButton
647
+ color="error"
648
+ variant="soft"
647
649
  v-if="summary?.webSearch"
648
- color="neutral"
649
- variant="ghost"
650
650
  size="xs"
651
651
  :loading="savingWeb"
652
652
  @click="clearWeb"
@@ -53,7 +53,7 @@ function invitationStatusLabel(status: InvitationStatus): string {
53
53
  }
54
54
 
55
55
  /** Whether the signed-in caller is an admin of this account (drives edit affordances). */
56
- const isAdmin = computed(() => accounts.activeAccount?.roles?.includes('admin') ?? false)
56
+ const isAdmin = computed(() => accounts.isActiveAccountAdmin)
57
57
  /**
58
58
  * Members / roles / invitations are org-scoped — the backend rejects membership on a
59
59
  * personal account. For a personal account we show a "create an organization" CTA in
@@ -328,7 +328,7 @@ async function disconnectEmail() {
328
328
  </template>
329
329
  <template #from>{{ accounts.emailConnection.fromAddress }}</template>
330
330
  </i18n-t>
331
- <UButton size="xs" color="error" variant="ghost" :loading="busy" @click="disconnectEmail">
331
+ <UButton size="xs" color="error" variant="soft" :loading="busy" @click="disconnectEmail">
332
332
  {{ t('layout.accountTeam.email.disconnect') }}
333
333
  </UButton>
334
334
  </div>
@@ -12,7 +12,16 @@ import SectionLabel from '~/components/common/SectionLabel.vue'
12
12
  // account row folds away — it is a label with a menu duplicated inside the board menu's
13
13
  // reach — and the board button keeps only its glyph. Both dropdowns are unchanged, so
14
14
  // switching boards never requires expanding the sidebar first.
15
- withDefaults(defineProps<{ collapsed?: boolean }>(), { collapsed: false })
15
+ //
16
+ // `standalone` is for a host OUTSIDE the board page (the repository-host gate), where the
17
+ // page-level dialogs are not mounted. The items that open one are left out there: "Account
18
+ // settings" opens `AccountSettingsPanel` and "Delete board" awaits `ConfirmDialog`, so on the gate
19
+ // both would do nothing and then fire once a board page mounted, the delete against the board that
20
+ // was active when it was asked.
21
+ const props = withDefaults(defineProps<{ collapsed?: boolean; standalone?: boolean }>(), {
22
+ collapsed: false,
23
+ standalone: false,
24
+ })
16
25
 
17
26
  const { t } = useI18n()
18
27
 
@@ -65,14 +74,19 @@ const accountItems = computed<DropdownMenuItem[][]>(() => [
65
74
  },
66
75
  // Account settings: the unified per-account panel (team + roles + invitations + email
67
76
  // sender + account-tier fragment library). The panel itself handles personal accounts
68
- // (prompting to create an org for the team tab), so this is not gated.
69
- {
70
- label: t('layout.boardSwitcher.account.settings'),
71
- icon: 'i-lucide-settings',
72
- onSelect: () => ui.openAccountSettings(),
73
- },
77
+ // (prompting to create an org for the team tab), so this is not gated by role; it is left out
78
+ // only where the panel is not mounted (`standalone`).
79
+ ...(props.standalone
80
+ ? []
81
+ : [
82
+ {
83
+ label: t('layout.boardSwitcher.account.settings'),
84
+ icon: 'i-lucide-settings',
85
+ onSelect: () => ui.openAccountSettings(),
86
+ },
87
+ ]),
74
88
  // Admins can set the account-wide default provider new services inherit.
75
- ...(accounts.activeAccount?.roles?.includes('admin')
89
+ ...(accounts.isActiveAccountAdmin
76
90
  ? [
77
91
  {
78
92
  label: t('layout.boardSwitcher.account.defaultProvider'),
@@ -122,12 +136,17 @@ const boardItems = computed<DropdownMenuItem[][]>(() => [
122
136
  icon: 'i-lucide-pencil',
123
137
  onSelect: () => openPrompt('rename'),
124
138
  },
125
- {
126
- label: t('layout.boardSwitcher.board.delete'),
127
- icon: 'i-lucide-trash-2',
128
- color: 'error' as const,
129
- onSelect: () => void removeBoard(),
130
- },
139
+ // Delete confirms through the page's `ConfirmDialog`, absent in `standalone`.
140
+ ...(props.standalone
141
+ ? []
142
+ : [
143
+ {
144
+ label: t('layout.boardSwitcher.board.delete'),
145
+ icon: 'i-lucide-trash-2',
146
+ color: 'error' as const,
147
+ onSelect: () => void removeBoard(),
148
+ },
149
+ ]),
131
150
  ]
132
151
  : []),
133
152
  ],
@@ -22,14 +22,17 @@ const ui = useUiStore()
22
22
  const workspace = useWorkspaceStore()
23
23
  const settingsStore = useWorkspaceSettingsStore()
24
24
  const { canManageSettings } = useWorkspaceAccess()
25
+ const uiRole = useUiRoleStore()
25
26
 
26
27
  // Only prompt someone who can actually answer. A member/viewer cannot write workspace settings
27
28
  // (the endpoint is `settings.manage`-gated), so for them this would be an un-actionable nag
28
- // pointing at a screen that would refuse their save.
29
+ // pointing at a screen that would refuse their save. The designer surface never configures the
30
+ // platform, so it is kept off there too, whatever the grant.
29
31
  const show = computed(
30
32
  () =>
31
33
  workspace.ready &&
32
34
  canManageSettings.value &&
35
+ uiRole.fullSurface &&
33
36
  needsDefaultProvisioningChoice(settingsStore.settings) &&
34
37
  !ui.defaultProvisionDismissed,
35
38
  )
@@ -6,6 +6,11 @@
6
6
  // runtime whose account picked no backend — incl. Cloudflare without an ARTIFACT_BUCKET binding;
7
7
  // ephemeral test environments on any runtime that wires the integration).
8
8
  //
9
+ // Audience: a card renders only for a caller who can act on its area, per the server-reported
10
+ // owner (`infraSetupOwners`) and `canActOnSetup`. A caller who cannot (a member, a viewer, the
11
+ // designer surface, or anyone when the operator owns the gap) sees one compact line instead, and
12
+ // only for a gap that stops every run (`InfraSetupNotice`).
13
+ //
9
14
  // Positioning/stacking against the sibling advisory banners (AI-readiness, provider-config) is
10
15
  // owned by the shared, click-through banner column in `pages/index.vue` — so concurrent prompts
11
16
  // stack vertically instead of drawing on top of each other. This component only stacks its OWN
@@ -52,11 +57,14 @@ import {
52
57
  import type { DropdownMenuItem } from '@nuxt/ui'
53
58
  import type { InfraSetupArea, InfraSetupStatus } from '~/types/domain'
54
59
  import { infraSetupDismissalKey, type InfraSetupCardKind } from '~/utils/infraSetup'
60
+ import { blockingSetupNotice, canActOnSetup } from '~/utils/setupAudience'
61
+ import InfraSetupNotice from '~/components/layout/InfraSetupNotice.vue'
55
62
 
56
63
  const { t } = useI18n()
57
64
  const ui = useUiStore()
58
65
  const auth = useAuthStore()
59
66
  const workspace = useWorkspaceStore()
67
+ const { actor } = useSetupActor()
60
68
 
61
69
  // Severity order: no executor blocks EVERY agent, so it leads; a missing test environment blocks
62
70
  // only testing agents; missing storage only degrades the UI-tester's screenshots.
@@ -140,6 +148,9 @@ const visible = computed<AreaCard[]>(() => {
140
148
  return AREAS.filter((area) => {
141
149
  const kind = cardKind(status[area])
142
150
  if (!kind) return false
151
+ // A card is for the people who can act on it. Everyone else hears about a gap only when it
152
+ // stops their own work, through the one line below (`blockingSetupNotice`).
153
+ if (!canActOnSetup(workspace.infraSetupOwners?.[area], actor.value)) return false
143
154
  // Both dismissals are keyed by the CLAIM, not by the area: silencing "you haven't configured
144
155
  // this" must not also silence "you configured it and it is now down". The permanent dismissal
145
156
  // only ever covers a setup gap (`dismissPermanently` is offered nowhere else).
@@ -152,6 +163,18 @@ const visible = computed<AreaCard[]>(() => {
152
163
  }))
153
164
  })
154
165
 
166
+ // The one line for a caller who cannot act on a gap that stops every run. It honours BOTH
167
+ // dismissals of the card it stands in for, keyed by the same claim: someone who said "don't
168
+ // notify me again" on the card (before their role changed, say) is not nagged again by the line.
169
+ const notice = computed(() => {
170
+ const found = blockingSetupNotice(workspace.infraSetup, workspace.infraSetupOwners, actor.value)
171
+ if (!found) return null
172
+ if (ui.infraSetupSessionDismissed.includes(infraSetupDismissalKey(found.area, found.kind))) {
173
+ return null
174
+ }
175
+ return found.kind === 'setup' && dismissedForUser.value.includes(found.area) ? null : found
176
+ })
177
+
155
178
  /** Which card an area's status raises, or null when it raises none (`configured`/`not_applicable`). */
156
179
  function cardKind(status: InfraSetupStatus): InfraSetupCardKind | null {
157
180
  if (isInfraSetupHealthStatus(status)) return 'outage'
@@ -169,7 +192,7 @@ function titleKey(card: AreaCard): string {
169
192
  * The dismiss dropdown: the product wants the user asked WHICH kind of dismissal on close. An
170
193
  * outage offers the session option ONLY — see the fork note at the top of this file.
171
194
  */
172
- function dismissMenu(card: AreaCard): DropdownMenuItem[][] {
195
+ function dismissMenu(card: Pick<AreaCard, 'area' | 'kind'>): DropdownMenuItem[][] {
173
196
  const session = {
174
197
  label: t('layout.infraSetupBanner.dismiss.session'),
175
198
  icon: 'i-lucide-clock',
@@ -195,11 +218,12 @@ function dismissMenu(card: AreaCard): DropdownMenuItem[][] {
195
218
  than an assertive `role="alert"` per card — an advisory setup nag shouldn't interrupt a
196
219
  screen reader, and up to three stacked alerts would spam it. -->
197
220
  <div
198
- v-if="visible.length > 0"
221
+ v-if="visible.length > 0 || notice"
199
222
  class="flex w-full flex-col items-center gap-2"
200
223
  role="status"
201
224
  aria-live="polite"
202
225
  >
226
+ <InfraSetupNotice v-if="notice" :notice="notice" :dismiss-menu="dismissMenu(notice)" />
203
227
  <!-- An OUTAGE reads red, a setup gap amber: one is something breaking now, the other is
204
228
  something never switched on, and a reader has to be able to tell at a glance. -->
205
229
  <div
@@ -0,0 +1,69 @@
1
+ <script setup lang="ts">
2
+ // The one line a caller sees for a setup gap they cannot fix from where they are, when it stops
3
+ // their runs. It replaces the full card (and its "Configure" button that would open a screen their
4
+ // save is refused on) with two facts: what does not work, and how it gets fixed. Compact on
5
+ // purpose: the caller has nothing to do here, so it must not cover the board the way an actionable
6
+ // card may.
7
+ import type { DropdownMenuItem } from '@nuxt/ui'
8
+ import type { BlockingInfraArea, SetupNotice, SetupRemedy } from '~/utils/setupAudience'
9
+
10
+ defineProps<{
11
+ notice: SetupNotice
12
+ /** The card's own session / permanent choice, built by the banner so both close the same claim. */
13
+ dismissMenu: DropdownMenuItem[][]
14
+ }>()
15
+
16
+ const { t } = useI18n()
17
+
18
+ // Exhaustive over the blocking areas and the remedies, so a new member of either fails typecheck
19
+ // here rather than rendering a raw key.
20
+ const STATE_KEYS: Record<BlockingInfraArea, Record<SetupNotice['kind'], string>> = {
21
+ agentExecutor: {
22
+ setup: 'layout.infraSetupNotice.agentExecutor.setup',
23
+ outage: 'layout.infraSetupNotice.agentExecutor.outage',
24
+ },
25
+ }
26
+ const REMEDY_KEYS: Record<SetupRemedy, string> = {
27
+ workspace_admin: 'layout.infraSetupNotice.owner.workspace_admin',
28
+ account_admin: 'layout.infraSetupNotice.owner.account_admin',
29
+ operator: 'layout.infraSetupNotice.owner.operator',
30
+ full_surface: 'layout.infraSetupNotice.fullSurface',
31
+ }
32
+ </script>
33
+
34
+ <template>
35
+ <div
36
+ class="pointer-events-auto flex w-full max-w-3xl items-center gap-3 rounded-xl border px-4 py-2 shadow-lg backdrop-blur"
37
+ :class="
38
+ notice.kind === 'outage'
39
+ ? 'border-app-error-500/50 bg-app-error-950/90'
40
+ : 'border-app-warning-500/50 bg-app-warning-950/90'
41
+ "
42
+ :data-testid="`infra-setup-notice-${notice.area}`"
43
+ :data-infra-remedy="notice.remedy"
44
+ >
45
+ <UIcon
46
+ :name="notice.kind === 'outage' ? 'i-lucide-plug-zap' : 'i-lucide-server-off'"
47
+ class="h-5 w-5 shrink-0"
48
+ :class="notice.kind === 'outage' ? 'text-app-error-400' : 'text-app-warning-400'"
49
+ />
50
+ <p
51
+ class="min-w-0 flex-1 text-sm"
52
+ :class="notice.kind === 'outage' ? 'text-app-error-100' : 'text-app-warning-100'"
53
+ >
54
+ {{ t(STATE_KEYS[notice.area][notice.kind]) }}
55
+ <span class="opacity-80">{{ t(REMEDY_KEYS[notice.remedy]) }}</span>
56
+ </p>
57
+ <UDropdownMenu :items="dismissMenu" :content="{ align: 'end' }">
58
+ <UButton
59
+ color="neutral"
60
+ variant="ghost"
61
+ size="xs"
62
+ icon="i-lucide-x"
63
+ class="shrink-0"
64
+ :aria-label="t('common.close')"
65
+ :data-testid="`infra-setup-notice-dismiss-${notice.area}`"
66
+ />
67
+ </UDropdownMenu>
68
+ </div>
69
+ </template>