@cat-factory/app 0.96.2 → 0.96.4

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 (42) hide show
  1. package/app/assets/css/main.css +15 -0
  2. package/app/components/auth/LoginScreen.vue +3 -4
  3. package/app/components/auth/ResetPasswordScreen.vue +3 -4
  4. package/app/components/board/nodes/TaskPipelineMini.vue +4 -3
  5. package/app/components/brainstorm/BrainstormWindow.vue +9 -1
  6. package/app/components/clarity/ClarityReviewWindow.vue +9 -1
  7. package/app/components/common/IconButton.vue +19 -0
  8. package/app/components/common/SecretInput.vue +49 -0
  9. package/app/components/documents/DocumentSourceConnectModal.vue +3 -2
  10. package/app/components/focus/BlockFocusView.vue +8 -1
  11. package/app/components/followUp/FollowUpWindow.vue +1 -1
  12. package/app/components/gates/GateResultView.vue +1 -1
  13. package/app/components/humanTest/HumanTestWindow.vue +1 -1
  14. package/app/components/layout/AccountDeploymentSettings.vue +7 -12
  15. package/app/components/layout/AccountTeamSettings.vue +2 -2
  16. package/app/components/panels/InspectorPanel.vue +3 -1
  17. package/app/components/pipeline/PipelineProgress.vue +8 -0
  18. package/app/components/providers/ApiKeysSection.vue +3 -3
  19. package/app/components/providers/PersonalCredentialModal.vue +2 -2
  20. package/app/components/providers/PersonalSubscriptionSection.vue +4 -5
  21. package/app/components/providers/VendorCredentialsModal.vue +3 -3
  22. package/app/components/requirements/RequirementsReviewWindow.vue +9 -1
  23. package/app/components/settings/KubernetesEngineForm.vue +3 -3
  24. package/app/components/settings/KubernetesEnvironmentForm.vue +2 -1
  25. package/app/components/settings/LocalModelEndpointsPanel.vue +2 -2
  26. package/app/components/settings/ObservabilityConnectionPanel.vue +5 -10
  27. package/app/components/settings/OpenRouterCatalogPanel.vue +3 -3
  28. package/app/components/settings/PackageRegistriesPanel.vue +2 -6
  29. package/app/components/settings/ProviderManifestEditor.vue +2 -2
  30. package/app/components/settings/UserSecretsSection.vue +3 -2
  31. package/app/components/slack/SlackPanel.vue +2 -7
  32. package/app/components/spec/ServiceSpecWindow.vue +9 -1
  33. package/app/components/visualConfirm/VisualConfirmationWindow.vue +2 -2
  34. package/i18n/locales/en.json +2 -0
  35. package/i18n/locales/es.json +2 -0
  36. package/i18n/locales/fr.json +2 -0
  37. package/i18n/locales/he.json +2 -0
  38. package/i18n/locales/ja.json +2 -0
  39. package/i18n/locales/pl.json +2 -0
  40. package/i18n/locales/tr.json +2 -0
  41. package/i18n/locales/uk.json +2 -0
  42. package/package.json +2 -2
@@ -94,6 +94,21 @@ body {
94
94
  }
95
95
  }
96
96
 
97
+ /* Respect prefers-reduced-motion: the decorative, infinite attention pulses (the
98
+ * blocked / decision-needed halo, the PR-ready halo) and the marching-ants edge
99
+ * animation convey nothing the static state doesn't already, so we disable them for
100
+ * users who ask for less motion. Loading spinners are deliberately left alone — a
101
+ * spinner's motion IS its meaning ("work in progress"). */
102
+ @media (prefers-reduced-motion: reduce) {
103
+ .board-pulse,
104
+ .board-pulse-green {
105
+ animation: none;
106
+ }
107
+ .vue-flow__edge.is-live .vue-flow__edge-path {
108
+ animation: none;
109
+ }
110
+ }
111
+
97
112
  /* Custom block node should not show the default node chrome */
98
113
  .vue-flow__node-block {
99
114
  padding: 0;
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref, watch } from 'vue'
3
3
  import { apiErrorEnvelope } from '~/composables/api/errors'
4
+ import SecretInput from '~/components/common/SecretInput.vue'
4
5
 
5
6
  const auth = useAuthStore()
6
7
  const { t } = useI18n()
@@ -320,9 +321,8 @@ const noSignInMethod = computed(
320
321
  size="lg"
321
322
  class="w-full"
322
323
  />
323
- <UInput
324
+ <SecretInput
324
325
  v-model="password"
325
- type="password"
326
326
  required
327
327
  :placeholder="t('auth.login.passwordPlaceholder')"
328
328
  icon="i-lucide-lock"
@@ -395,9 +395,8 @@ const noSignInMethod = computed(
395
395
  {{ PROVIDER_LABELS[p] }}
396
396
  </UButton>
397
397
  </div>
398
- <UInput
398
+ <SecretInput
399
399
  v-model="remotePatToken"
400
- type="password"
401
400
  required
402
401
  :placeholder="
403
402
  t('auth.login.patPlaceholder', { provider: PROVIDER_LABELS[remotePatProvider] })
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref } from 'vue'
3
+ import SecretInput from '~/components/common/SecretInput.vue'
3
4
 
4
5
  // Standalone full-screen reset form reached from the emailed link
5
6
  // (`/reset-password?token=…`). It is a public route (see AuthGate), so a recipient who
@@ -76,18 +77,16 @@ function goToLogin() {
76
77
  </template>
77
78
 
78
79
  <form v-else class="space-y-3" @submit.prevent="submit">
79
- <UInput
80
+ <SecretInput
80
81
  v-model="password"
81
- type="password"
82
82
  required
83
83
  :placeholder="t('auth.resetPassword.newPasswordPlaceholder')"
84
84
  icon="i-lucide-lock"
85
85
  size="lg"
86
86
  class="w-full"
87
87
  />
88
- <UInput
88
+ <SecretInput
89
89
  v-model="confirm"
90
- type="password"
91
90
  required
92
91
  :placeholder="t('auth.resetPassword.confirmPasswordPlaceholder')"
93
92
  icon="i-lucide-lock"
@@ -79,8 +79,9 @@ const ITEM_ICON: Record<string, string> = {
79
79
  {{ t('board.task.buildSteps') }}
80
80
  </div>
81
81
  <div v-for="(s, i) in steps" :key="i" class="rounded bg-slate-900/60 px-1.5 py-1">
82
- <div
83
- class="flex cursor-pointer items-center gap-1"
82
+ <button
83
+ type="button"
84
+ class="flex w-full cursor-pointer items-center gap-1 rounded text-start focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/60"
84
85
  :title="`${agentKindMeta(s.agentKind).label} — ${agentKindMeta(s.agentKind).description}\n${t('board.task.clickToViewStep')}`"
85
86
  @click.stop="openStep(i)"
86
87
  >
@@ -116,7 +117,7 @@ const ITEM_ICON: Record<string, string> = {
116
117
  : STATE_META[s.state].color,
117
118
  }"
118
119
  />
119
- </div>
120
+ </button>
120
121
 
121
122
  <!-- pending approval gate: jump straight to the conclusions reader. Suppressed
122
123
  while a reviewer gate is folding/re-reviewing in the background (no human needed). -->
@@ -8,6 +8,7 @@
8
8
  // options or hits the iteration cap. The converged direction — not the original description — is
9
9
  // what the downstream stage (the requirements review / the architect) consumes.
10
10
  import IterationCapPrompt from '~/components/pipeline/IterationCapPrompt.vue'
11
+ import IconButton from '~/components/common/IconButton.vue'
11
12
  import { parseOutputOutline } from '~/utils/agentOutput'
12
13
  import type {
13
14
  BrainstormItem,
@@ -281,7 +282,14 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
281
282
  <UBadge v-if="session" color="neutral" variant="subtle" size="sm">
282
283
  {{ t('brainstorm.iteration', { current: iteration, max: maxIterations }) }}
283
284
  </UBadge>
284
- <UButton icon="i-lucide-x" color="neutral" variant="ghost" size="sm" @click="close" />
285
+ <IconButton
286
+ icon="i-lucide-x"
287
+ color="neutral"
288
+ variant="ghost"
289
+ size="sm"
290
+ :label="t('common.close')"
291
+ @click="close"
292
+ />
285
293
  </div>
286
294
  </header>
287
295
 
@@ -9,6 +9,7 @@
9
9
  // description — is what every downstream agent step (the bug investigator, the coder)
10
10
  // consumes.
11
11
  import IterationCapPrompt from '~/components/pipeline/IterationCapPrompt.vue'
12
+ import IconButton from '~/components/common/IconButton.vue'
12
13
  import { parseOutputOutline } from '~/utils/agentOutput'
13
14
  import type {
14
15
  ClarityItemStatus,
@@ -327,7 +328,14 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
327
328
  <UBadge v-if="review" color="neutral" variant="subtle" size="sm">
328
329
  {{ t('clarity.iteration', { current: iteration, max: maxIterations }) }}
329
330
  </UBadge>
330
- <UButton icon="i-lucide-x" color="neutral" variant="ghost" size="sm" @click="close" />
331
+ <IconButton
332
+ icon="i-lucide-x"
333
+ color="neutral"
334
+ variant="ghost"
335
+ size="sm"
336
+ :label="t('common.close')"
337
+ @click="close"
338
+ />
331
339
  </div>
332
340
  </header>
333
341
 
@@ -0,0 +1,19 @@
1
+ <script setup lang="ts">
2
+ // Shared icon-only button primitive that ENFORCES the accessible-name convention (UX-62/63):
3
+ // every icon button must carry a `label`, applied as BOTH `title` (pointer tooltip) and
4
+ // `aria-label` (screen readers) — `title` alone never fires on touch and is invisible to
5
+ // assistive tech, so an unlabeled icon button is a bug this component makes unrepresentable.
6
+ // All other UButton props/listeners (icon, color, variant, size, @click, :loading, :disabled)
7
+ // pass straight through via `$attrs`; `label` is declared as a prop so it strips off before
8
+ // reaching UButton (whose own `label` prop would otherwise render visible text). Mirrors the
9
+ // shape of `common/CopyButton.vue`.
10
+ defineProps<{
11
+ /** Accessible name + tooltip. Required — the whole point of the primitive. */
12
+ label: string
13
+ }>()
14
+ defineOptions({ inheritAttrs: false })
15
+ </script>
16
+
17
+ <template>
18
+ <UButton v-bind="$attrs" :title="label" :aria-label="label" />
19
+ </template>
@@ -0,0 +1,49 @@
1
+ <script setup lang="ts">
2
+ // Shared single-line secret/password input with a reveal (eye) toggle (UX-19/UX-20).
3
+ // Masks the value by default (`type="password"`) and lets the user verify a pasted token via
4
+ // the trailing eye button — without one, a mistyped/expired key looks identical to a good one,
5
+ // the leading cause of invalid-credential retries. Replaces both the bare `type="password"`
6
+ // UInputs (UX-19) and the fully-plaintext secret `UTextarea`s (UX-20), which rendered live
7
+ // vendor keys in cleartext (shoulder-surf / screen-share leakage).
8
+ //
9
+ // When `secret` is false it degrades to a plain text input with no toggle, so descriptor-driven
10
+ // fields whose secrecy is data-dependent (`field.secret ? … : …`) can bind it directly.
11
+ // All other UInput props/listeners (icon, size, placeholder, disabled, autofocus, class, …)
12
+ // pass straight through via `$attrs`; `secret` is a declared prop so it strips off first.
13
+ // `$attrs` is bound BEFORE `type` so the mask/reveal control stays authoritative — a caller
14
+ // that (out of old habit) also passes `type="password"` can't clobber the toggle.
15
+ // Mirrors the shape of `common/CopyButton.vue` / `common/IconButton.vue`.
16
+ const props = withDefaults(
17
+ defineProps<{
18
+ /** Whether the field holds a secret (masked + reveal toggle). When false, a plain text input. */
19
+ secret?: boolean
20
+ }>(),
21
+ { secret: true },
22
+ )
23
+
24
+ const model = defineModel<string>()
25
+ const { t } = useI18n()
26
+ const revealed = ref(false)
27
+ const toggle = () => {
28
+ revealed.value = !revealed.value
29
+ }
30
+ defineOptions({ inheritAttrs: false })
31
+ </script>
32
+
33
+ <template>
34
+ <UInput v-if="!props.secret" v-model="model" v-bind="$attrs" type="text" />
35
+ <UInput v-else v-model="model" v-bind="$attrs" :type="revealed ? 'text' : 'password'">
36
+ <template #trailing>
37
+ <UButton
38
+ color="neutral"
39
+ variant="link"
40
+ size="xs"
41
+ :icon="revealed ? 'i-lucide-eye-off' : 'i-lucide-eye'"
42
+ :title="revealed ? t('common.hide') : t('common.reveal')"
43
+ :aria-label="revealed ? t('common.hide') : t('common.reveal')"
44
+ :aria-pressed="revealed"
45
+ @click.stop="toggle"
46
+ />
47
+ </template>
48
+ </UInput>
49
+ </template>
@@ -5,6 +5,7 @@
5
5
  // are write-only — the backend never returns them, so on reload we show
6
6
  // "Connected" with empty fields.
7
7
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
8
+ import SecretInput from '~/components/common/SecretInput.vue'
8
9
 
9
10
  const { t } = useI18n()
10
11
  const ui = useUiStore()
@@ -103,9 +104,9 @@ async function disconnect() {
103
104
  :label="field.label"
104
105
  :help="field.help"
105
106
  >
106
- <UInput
107
+ <SecretInput
107
108
  v-model="values[field.key]"
108
- :type="field.secret ? 'password' : 'text'"
109
+ :secret="!!field.secret"
109
110
  :placeholder="field.placeholder"
110
111
  class="w-full"
111
112
  />
@@ -4,6 +4,7 @@ import type { Block } from '~/types/domain'
4
4
  import { blockTypeMeta, STATUS_META } from '~/utils/catalog'
5
5
  import { pipelineAllowedForManualStart } from '~/utils/pipeline'
6
6
  import PipelineProgress from '~/components/pipeline/PipelineProgress.vue'
7
+ import IconButton from '~/components/common/IconButton.vue'
7
8
 
8
9
  const board = useBoardStore()
9
10
  const pipelines = usePipelinesStore()
@@ -106,7 +107,13 @@ function openApprovalFor(approvalId: string) {
106
107
  {{ instance ? t('focus.rerunPipeline') : t('focus.runPipeline') }}
107
108
  </UButton>
108
109
  </UDropdownMenu>
109
- <UButton icon="i-lucide-x" color="neutral" variant="ghost" @click="close" />
110
+ <IconButton
111
+ icon="i-lucide-x"
112
+ color="neutral"
113
+ variant="ghost"
114
+ :label="t('common.close')"
115
+ @click="close"
116
+ />
110
117
  </div>
111
118
  </header>
112
119
 
@@ -200,7 +200,7 @@ const STATUS_META: Record<
200
200
  v-model="drafts[item.id]"
201
201
  rows="2"
202
202
  :placeholder="t('followUp.answerPlaceholder')"
203
- class="w-full resize-y rounded-md border border-slate-700 bg-slate-950/60 px-2.5 py-1.5 text-[12px] text-slate-100 placeholder:text-slate-600 focus:border-sky-500 focus:outline-none"
203
+ class="w-full resize-y rounded-md border border-slate-700 bg-slate-950/60 px-2.5 py-1.5 text-[12px] text-slate-100 placeholder:text-slate-600 focus:border-sky-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/60"
204
204
  />
205
205
  <div class="flex items-center gap-2">
206
206
  <UButton
@@ -297,7 +297,7 @@ const conflictVerdict = computed(() => {
297
297
  rows="3"
298
298
  :disabled="fixBusy"
299
299
  :placeholder="t('gates.humanReview.requestFixPlaceholder')"
300
- class="w-full resize-y rounded-md border border-slate-800 bg-slate-950/60 px-3 py-2 text-[13px] text-slate-200 placeholder:text-slate-600 focus:border-violet-500/60 focus:outline-none"
300
+ class="w-full resize-y rounded-md border border-slate-800 bg-slate-950/60 px-3 py-2 text-[13px] text-slate-200 placeholder:text-slate-600 focus:border-violet-500/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-500/60"
301
301
  />
302
302
  <div class="mt-2 flex justify-end">
303
303
  <UButton
@@ -289,7 +289,7 @@ const canDestroy = computed(
289
289
  v-model="findings"
290
290
  rows="4"
291
291
  :placeholder="t('humanTest.fix.placeholder')"
292
- class="w-full rounded-md border border-slate-700 bg-slate-950 px-3 py-2 text-[13px] text-slate-200 placeholder:text-slate-600 focus:border-amber-500 focus:outline-none"
292
+ class="w-full rounded-md border border-slate-700 bg-slate-950 px-3 py-2 text-[13px] text-slate-200 placeholder:text-slate-600 focus:border-amber-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/60"
293
293
  />
294
294
  <UButton
295
295
  size="sm"
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
3
3
  import type { ContentStorageBackend, ContentStorageConfig } from '~/types/accountSettings'
4
+ import SecretInput from '~/components/common/SecretInput.vue'
4
5
 
5
6
  // Deployment integration secrets for an account (admin only): the Slack app OAuth
6
7
  // credentials, the container web-search upstream keys, and the binary-artifact (screenshot)
@@ -379,9 +380,8 @@ async function clearWeb() {
379
380
  :placeholder="t('layout.accountDeployment.slack.clientId')"
380
381
  size="sm"
381
382
  />
382
- <UInput
383
+ <SecretInput
383
384
  v-model="slack.clientSecret"
384
- type="password"
385
385
  :placeholder="t('layout.accountDeployment.slack.clientSecret')"
386
386
  size="sm"
387
387
  />
@@ -441,9 +441,8 @@ async function clearWeb() {
441
441
  :placeholder="t('layout.accountDeployment.linear.clientId')"
442
442
  size="sm"
443
443
  />
444
- <UInput
444
+ <SecretInput
445
445
  v-model="linear.clientSecret"
446
- type="password"
447
446
  :placeholder="t('layout.accountDeployment.linear.clientSecret')"
448
447
  size="sm"
449
448
  />
@@ -494,9 +493,8 @@ async function clearWeb() {
494
493
  {{ t('layout.accountDeployment.web.description') }}
495
494
  </p>
496
495
  <div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
497
- <UInput
496
+ <SecretInput
498
497
  v-model="web.braveApiKey"
499
- type="password"
500
498
  :placeholder="t('layout.accountDeployment.web.braveKey')"
501
499
  size="sm"
502
500
  />
@@ -505,9 +503,8 @@ async function clearWeb() {
505
503
  :placeholder="t('layout.accountDeployment.web.searxngUrl')"
506
504
  size="sm"
507
505
  />
508
- <UInput
506
+ <SecretInput
509
507
  v-model="web.searxngApiKey"
510
- type="password"
511
508
  :placeholder="t('layout.accountDeployment.web.searxngKey')"
512
509
  size="sm"
513
510
  />
@@ -624,15 +621,13 @@ async function clearWeb() {
624
621
  </UBadge>
625
622
  </div>
626
623
  <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
627
- <UInput
624
+ <SecretInput
628
625
  v-model="cs.accessKeyId"
629
- type="password"
630
626
  :placeholder="t('layout.accountDeployment.contentStorage.accessKeyId')"
631
627
  size="sm"
632
628
  />
633
- <UInput
629
+ <SecretInput
634
630
  v-model="cs.secretAccessKey"
635
- type="password"
636
631
  :placeholder="t('layout.accountDeployment.contentStorage.secretAccessKey')"
637
632
  size="sm"
638
633
  />
@@ -4,6 +4,7 @@ import { apiErrorEnvelope } from '~/composables/api/errors'
4
4
  import type { AccountRole } from '~/types/domain'
5
5
  import type { InvitationStatus } from '@cat-factory/contracts'
6
6
  import AccountDeploymentSettings from '~/components/layout/AccountDeploymentSettings.vue'
7
+ import SecretInput from '~/components/common/SecretInput.vue'
7
8
 
8
9
  // Team settings for an org account: the member roster (with combinable admin /
9
10
  // developer / product roles), pending email invitations, and the per-account
@@ -300,9 +301,8 @@ async function disconnectEmail() {
300
301
  :placeholder="t('layout.accountTeam.email.fromPlaceholder')"
301
302
  class="w-full"
302
303
  />
303
- <UInput
304
+ <SecretInput
304
305
  v-model="emailApiKey"
305
- type="password"
306
306
  :placeholder="t('layout.accountTeam.email.apiKeyPlaceholder')"
307
307
  class="w-full"
308
308
  />
@@ -18,6 +18,7 @@ import TaskExecution from '~/components/panels/inspector/TaskExecution.vue'
18
18
  import TaskEstimateBadge from '~/components/panels/inspector/TaskEstimateBadge.vue'
19
19
  import EpicChildren from '~/components/panels/inspector/EpicChildren.vue'
20
20
  import InitiativeInspector from '~/components/panels/inspector/InitiativeInspector.vue'
21
+ import IconButton from '~/components/common/IconButton.vue'
21
22
  import RecurringScheduleSettings from '~/components/panels/inspector/RecurringScheduleSettings.vue'
22
23
  import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
23
24
  import AgentStopButton from '~/components/board/AgentStopButton.vue'
@@ -265,11 +266,12 @@ const showOriginalDescription = ref(false)
265
266
  </div>
266
267
  </div>
267
268
  </div>
268
- <UButton
269
+ <IconButton
269
270
  icon="i-lucide-x"
270
271
  color="neutral"
271
272
  variant="ghost"
272
273
  size="xs"
274
+ :label="t('common.close')"
273
275
  @click="ui.select(null)"
274
276
  />
275
277
  </div>
@@ -607,4 +607,12 @@ const ITEM_ICON: Record<string, string> = {
607
607
  .followup-blink {
608
608
  animation: followup-blink 1.4s ease-in-out infinite;
609
609
  }
610
+ /* These decorative attention halos carry no information the static state lacks, so
611
+ silence them under prefers-reduced-motion (matches the board-pulse rule in main.css). */
612
+ @media (prefers-reduced-motion: reduce) {
613
+ .step-active,
614
+ .followup-blink {
615
+ animation: none;
616
+ }
617
+ }
610
618
  </style>
@@ -16,6 +16,7 @@
16
16
  // account); admin-only, enforced server-side. Surfaced from account/team settings.
17
17
  import { computed, ref, watch } from 'vue'
18
18
  import type { ApiKey, ApiKeyProvider } from '~/types/domain'
19
+ import SecretInput from '~/components/common/SecretInput.vue'
19
20
 
20
21
  const props = withDefaults(defineProps<{ accountId?: string; category?: 'direct' | 'proxy' }>(), {
21
22
  category: 'direct',
@@ -323,12 +324,11 @@ async function remove(k: ApiKey) {
323
324
  />
324
325
  </UFormField>
325
326
  <UFormField :label="t('providers.apiKeys.keyField')">
326
- <UTextarea
327
+ <SecretInput
327
328
  v-model="key"
328
- :rows="2"
329
329
  :disabled="needsSignIn"
330
330
  :placeholder="t('providers.apiKeys.keyPlaceholder')"
331
- class="font-mono"
331
+ class="w-full font-mono"
332
332
  />
333
333
  </UFormField>
334
334
  <div class="flex justify-end">
@@ -5,6 +5,7 @@
5
5
  // credential_required. On submit it transparently retries the gated action and caches the
6
6
  // password. The copy follows the pending vendor (Claude / GLM / ChatGPT-Codex).
7
7
  import { computed, ref, watch } from 'vue'
8
+ import SecretInput from '~/components/common/SecretInput.vue'
8
9
 
9
10
  const { t } = useI18n()
10
11
  const personal = usePersonalSubscriptionsStore()
@@ -119,9 +120,8 @@ function goConnect() {
119
120
  {{ t('providers.personalCredential.passwordBody', { vendor: vendorLabel }) }}
120
121
  </p>
121
122
  <UFormField :label="t('providers.personalCredential.passwordField')">
122
- <UInput
123
+ <SecretInput
123
124
  v-model="password"
124
- type="password"
125
125
  autofocus
126
126
  :placeholder="t('providers.personalCredential.passwordPlaceholder')"
127
127
  @keydown.enter="submit()"
@@ -6,6 +6,7 @@
6
6
  // such a run (cached locally so it's usually transparent). Recurring schedules can't use them.
7
7
  import { computed, onMounted, ref } from 'vue'
8
8
  import type { SubscriptionVendor } from '~/types/domain'
9
+ import SecretInput from '~/components/common/SecretInput.vue'
9
10
 
10
11
  const personal = usePersonalSubscriptionsStore()
11
12
  const auth = useAuthStore()
@@ -233,19 +234,17 @@ async function disconnect(v: SubscriptionVendor) {
233
234
  />
234
235
  </UFormField>
235
236
  <UFormField :label="selectedMeta.tokenLabel">
236
- <UTextarea
237
+ <SecretInput
237
238
  v-model="token"
238
- :rows="2"
239
239
  :disabled="needsSignIn"
240
240
  :placeholder="selectedMeta.tokenPlaceholder"
241
- class="font-mono"
241
+ class="w-full font-mono"
242
242
  />
243
243
  </UFormField>
244
244
  <div class="flex flex-wrap gap-3">
245
245
  <UFormField :label="t('personalSubscriptions.passwordField')" class="flex-1">
246
- <UInput
246
+ <SecretInput
247
247
  v-model="password"
248
- type="password"
249
248
  :disabled="needsSignIn"
250
249
  :placeholder="t('personalSubscriptions.passwordPlaceholder')"
251
250
  />
@@ -8,6 +8,7 @@
8
8
  import { computed, ref, watch } from 'vue'
9
9
  import type { SubscriptionVendor } from '~/types/domain'
10
10
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
11
+ import SecretInput from '~/components/common/SecretInput.vue'
11
12
 
12
13
  const { t, n } = useI18n()
13
14
  const ui = useUiStore()
@@ -215,11 +216,10 @@ function vendorLabel(v: SubscriptionVendor): string {
215
216
  />
216
217
  </UFormField>
217
218
  <UFormField :label="t('providers.vendorCredentials.tokenField')">
218
- <UTextarea
219
+ <SecretInput
219
220
  v-model="token"
220
- :rows="3"
221
221
  :placeholder="tokenPlaceholder"
222
- class="font-mono"
222
+ class="w-full font-mono"
223
223
  />
224
224
  </UFormField>
225
225
  <div class="flex justify-end">
@@ -10,6 +10,7 @@
10
10
  import { parseOutputOutline } from '~/utils/agentOutput'
11
11
  import StepRestartControl from '~/components/panels/StepRestartControl.vue'
12
12
  import IterationCapPrompt from '~/components/pipeline/IterationCapPrompt.vue'
13
+ import IconButton from '~/components/common/IconButton.vue'
13
14
  import type {
14
15
  RequirementRecommendation,
15
16
  RequirementReview,
@@ -489,7 +490,14 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
489
490
  :step-index="stepIndex"
490
491
  @restarted="close"
491
492
  />
492
- <UButton icon="i-lucide-x" color="neutral" variant="ghost" size="sm" @click="close" />
493
+ <IconButton
494
+ icon="i-lucide-x"
495
+ color="neutral"
496
+ variant="ghost"
497
+ size="sm"
498
+ :label="t('common.close')"
499
+ @click="close"
500
+ />
493
501
  </div>
494
502
  </header>
495
503
 
@@ -8,6 +8,7 @@
8
8
  // single-connection backend, which still carries the manifest source inline).
9
9
  import { computed, reactive, ref, watch } from 'vue'
10
10
  import { KUBERNETES_ENV_TOKEN_SECRET_KEY } from '@cat-factory/contracts'
11
+ import SecretInput from '~/components/common/SecretInput.vue'
11
12
  import type {
12
13
  EnvironmentHandlerView,
13
14
  InfraEngine,
@@ -351,10 +352,9 @@ async function copyAutoSetupCommand() {
351
352
  {{ t('settings.infrastructure.kubernetesEngine.tokenSaved') }}
352
353
  </span>
353
354
  </template>
354
- <UInput
355
+ <SecretInput
355
356
  v-model="apiToken"
356
- type="password"
357
- class="font-mono"
357
+ class="w-full font-mono"
358
358
  autocomplete="off"
359
359
  :placeholder="
360
360
  tokenStored
@@ -10,6 +10,7 @@
10
10
  // driven (see docs/initiatives/descriptor-driven-infra-forms.md).
11
11
  import { computed, reactive, ref, watch } from 'vue'
12
12
  import { KUBERNETES_ENV_TOKEN_SECRET_KEY } from '@cat-factory/contracts'
13
+ import SecretInput from '~/components/common/SecretInput.vue'
13
14
  import type { ProviderConnection } from '~/types/providerConnections'
14
15
 
15
16
  const props = defineProps<{
@@ -253,7 +254,7 @@ function optional(label: string): string {
253
254
  :label="t('settings.providerConnection.kubernetesEnv.apiToken')"
254
255
  :help="t('settings.providerConnection.kubernetesEnv.apiTokenHelp')"
255
256
  >
256
- <UInput v-model="apiToken" type="password" class="font-mono" />
257
+ <SecretInput v-model="apiToken" class="w-full font-mono" />
257
258
  </UFormField>
258
259
 
259
260
  <!-- Manifest source: where the per-PR resources are read from. -->
@@ -8,6 +8,7 @@
8
8
  import { computed, ref, watch } from 'vue'
9
9
  import { LOCAL_RUNNER_DEFAULTS, LOCAL_RUNNER_LABELS, type LocalRunner } from '~/types/localModels'
10
10
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
11
+ import SecretInput from '~/components/common/SecretInput.vue'
11
12
 
12
13
  const { t } = useI18n()
13
14
  const ui = useUiStore()
@@ -276,9 +277,8 @@ async function remove(p: LocalRunner) {
276
277
  </UFormField>
277
278
 
278
279
  <UFormField :label="t('settings.localModelEndpoints.apiKeyOptional')">
279
- <UInput
280
+ <SecretInput
280
281
  v-model="apiKey"
281
- type="password"
282
282
  class="font-mono"
283
283
  :placeholder="
284
284
  existing?.hasApiKey
@@ -7,6 +7,7 @@
7
7
  import { computed, reactive, ref, watch } from 'vue'
8
8
  import type { ObservabilityProviderKind } from '~/types/releaseHealth'
9
9
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
10
+ import SecretInput from '~/components/common/SecretInput.vue'
10
11
 
11
12
  const { t } = useI18n()
12
13
  const ui = useUiStore()
@@ -194,17 +195,11 @@ const connectedLabel = computed(() => {
194
195
  <UInput v-model="datadog.site" placeholder="datadoghq.com" class="w-full" />
195
196
  </UFormField>
196
197
  <UFormField :label="t('settings.observabilityConnection.datadog.apiKey')">
197
- <UInput
198
- v-model="datadog.apiKey"
199
- type="password"
200
- placeholder="DD-API-KEY"
201
- class="w-full"
202
- />
198
+ <SecretInput v-model="datadog.apiKey" placeholder="DD-API-KEY" class="w-full" />
203
199
  </UFormField>
204
200
  <UFormField :label="t('settings.observabilityConnection.datadog.appKey')">
205
- <UInput
201
+ <SecretInput
206
202
  v-model="datadog.appKey"
207
- type="password"
208
203
  placeholder="DD-APPLICATION-KEY"
209
204
  class="w-full"
210
205
  />
@@ -254,7 +249,7 @@ const connectedLabel = computed(() => {
254
249
  </p>
255
250
 
256
251
  <UFormField :label="t('settings.observabilityConnection.incident.pagerDutyToken')">
257
- <UInput v-model="pagerDuty.apiToken" type="password" class="w-full" />
252
+ <SecretInput v-model="pagerDuty.apiToken" class="w-full" />
258
253
  </UFormField>
259
254
  <UFormField :label="t('settings.observabilityConnection.incident.pagerDutyFromEmail')">
260
255
  <UInput
@@ -265,7 +260,7 @@ const connectedLabel = computed(() => {
265
260
  />
266
261
  </UFormField>
267
262
  <UFormField :label="t('settings.observabilityConnection.incident.incidentIoKey')">
268
- <UInput v-model="incidentIo.apiKey" type="password" class="w-full" />
263
+ <SecretInput v-model="incidentIo.apiKey" class="w-full" />
269
264
  </UFormField>
270
265
 
271
266
  <div class="flex gap-2">
@@ -10,6 +10,7 @@
10
10
  import { computed, ref, watch } from 'vue'
11
11
  import type { OpenRouterModelMeta } from '~/types/openrouter'
12
12
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
13
+ import SecretInput from '~/components/common/SecretInput.vue'
13
14
 
14
15
  const { t } = useI18n()
15
16
  const ui = useUiStore()
@@ -276,11 +277,10 @@ function manageKeys() {
276
277
  </UFormField>
277
278
  </div>
278
279
  <UFormField :label="t('settings.openRouterCatalog.apiKey')">
279
- <UTextarea
280
+ <SecretInput
280
281
  v-model="keyValue"
281
- :rows="2"
282
282
  placeholder="paste your OpenRouter key (sk-or-…)"
283
- class="font-mono"
283
+ class="w-full font-mono"
284
284
  />
285
285
  </UFormField>
286
286
  <div class="flex justify-end">
@@ -7,6 +7,7 @@
7
7
  import { computed, reactive, ref, watch } from 'vue'
8
8
  import type { PackageRegistryVendor } from '~/types/packageRegistries'
9
9
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
10
+ import SecretInput from '~/components/common/SecretInput.vue'
10
11
 
11
12
  const { t } = useI18n()
12
13
  const ui = useUiStore()
@@ -199,12 +200,7 @@ async function removeEntry(entryId: string) {
199
200
  </UFormField>
200
201
 
201
202
  <UFormField :label="t('settings.packageRegistries.add.token')">
202
- <UInput
203
- v-model="form.token"
204
- type="password"
205
- class="w-full"
206
- data-testid="package-registry-token"
207
- />
203
+ <SecretInput v-model="form.token" class="w-full" data-testid="package-registry-token" />
208
204
  </UFormField>
209
205
 
210
206
  <UButton
@@ -18,6 +18,7 @@ import { computed, ref, watch } from 'vue'
18
18
  import * as v from 'valibot'
19
19
  import { environmentManifestSchema, runnerPoolManifestSchema } from '@cat-factory/contracts'
20
20
  import type { ProviderConnectionKind } from '~/types/providerConnections'
21
+ import SecretInput from '~/components/common/SecretInput.vue'
21
22
 
22
23
  const props = defineProps<{
23
24
  kind: ProviderConnectionKind
@@ -255,9 +256,8 @@ function onSave() {
255
256
  </p>
256
257
  </template>
257
258
  <UFormField v-for="key in secretKeys" :key="key" :label="key">
258
- <UInput
259
+ <SecretInput
259
260
  v-model="secrets[key]"
260
- type="password"
261
261
  class="w-full font-mono"
262
262
  autocomplete="off"
263
263
  :data-testid="`manifest-editor-secret-${key}`"
@@ -7,6 +7,7 @@
7
7
  import { computed, ref, watch } from 'vue'
8
8
  import type { ProviderConfigField, UserSecretKind } from '~/types/userSecrets'
9
9
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
10
+ import SecretInput from '~/components/common/SecretInput.vue'
10
11
 
11
12
  const { t } = useI18n()
12
13
  const ui = useUiStore()
@@ -207,9 +208,9 @@ async function remove() {
207
208
  "
208
209
  :help="field.help"
209
210
  >
210
- <UInput
211
+ <SecretInput
211
212
  v-model="values[field.key]"
212
- :type="field.secret ? 'password' : 'text'"
213
+ :secret="!!field.secret"
213
214
  class="font-mono"
214
215
  :placeholder="field.placeholder"
215
216
  />
@@ -8,6 +8,7 @@ import { computed, reactive, ref, watch } from 'vue'
8
8
  import type { NotificationType } from '~/types/notifications'
9
9
  import type { SlackMemberMappingEntry, SlackMemberRole, SlackRoute } from '~/types/slack'
10
10
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
11
+ import SecretInput from '~/components/common/SecretInput.vue'
11
12
 
12
13
  const ui = useUiStore()
13
14
  const slack = useSlackStore()
@@ -188,13 +189,7 @@ async function saveMapping() {
188
189
  {{ t('slack.connect.orPasteToken') }}
189
190
  </span>
190
191
  <div class="flex gap-2">
191
- <UInput
192
- v-model="tokenInput"
193
- size="sm"
194
- class="flex-1"
195
- type="password"
196
- placeholder="xoxb-…"
197
- />
192
+ <SecretInput v-model="tokenInput" size="sm" class="flex-1" placeholder="xoxb-…" />
198
193
  <UButton
199
194
  color="primary"
200
195
  variant="soft"
@@ -12,6 +12,7 @@ import type {
12
12
  RequirementPriority,
13
13
  SpecModule,
14
14
  } from '~/types/spec'
15
+ import IconButton from '~/components/common/IconButton.vue'
15
16
 
16
17
  const { t } = useI18n()
17
18
  const board = useBoardStore()
@@ -176,7 +177,14 @@ function kindLabel(item: RequirementItem): string {
176
177
  {{ t('spec.mode.gherkin') }}
177
178
  </UButton>
178
179
  </div>
179
- <UButton icon="i-lucide-x" color="neutral" variant="ghost" size="sm" @click="close" />
180
+ <IconButton
181
+ icon="i-lucide-x"
182
+ color="neutral"
183
+ variant="ghost"
184
+ size="sm"
185
+ :label="t('common.close')"
186
+ @click="close"
187
+ />
180
188
  </div>
181
189
  </header>
182
190
 
@@ -286,7 +286,7 @@ const canApprove = computed(
286
286
  v-model="perViewNotes[p.view]"
287
287
  rows="2"
288
288
  :placeholder="t('visualConfirm.notePlaceholder', { view: p.view })"
289
- class="mt-1 w-full rounded-md border border-slate-700 bg-slate-950 px-2 py-1.5 text-[12px] text-slate-200 placeholder:text-slate-600 focus:border-amber-500 focus:outline-none"
289
+ class="mt-1 w-full rounded-md border border-slate-700 bg-slate-950 px-2 py-1.5 text-[12px] text-slate-200 placeholder:text-slate-600 focus:border-amber-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/60"
290
290
  />
291
291
  </div>
292
292
  </div>
@@ -340,7 +340,7 @@ const canApprove = computed(
340
340
  v-model="globalFindings"
341
341
  rows="3"
342
342
  :placeholder="t('visualConfirm.requestFix.placeholder')"
343
- class="w-full rounded-md border border-slate-700 bg-slate-950 px-3 py-2 text-[13px] text-slate-200 placeholder:text-slate-600 focus:border-amber-500 focus:outline-none"
343
+ class="w-full rounded-md border border-slate-700 bg-slate-950 px-3 py-2 text-[13px] text-slate-200 placeholder:text-slate-600 focus:border-amber-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/60"
344
344
  />
345
345
  <div class="mt-2 flex items-center justify-between">
346
346
  <span class="text-[11px] text-slate-500">
@@ -21,6 +21,8 @@
21
21
  "retry": "Retry",
22
22
  "copy": "Copy",
23
23
  "copied": "Copied to clipboard",
24
+ "reveal": "Reveal",
25
+ "hide": "Hide",
24
26
  "delete": "Delete",
25
27
  "remove": "Remove",
26
28
  "confirm": {
@@ -40,6 +40,8 @@
40
40
  "reconfigureHint": "Puedes volver a configurarlo más tarde."
41
41
  },
42
42
  "copied": "Copiado al portapapeles",
43
+ "reveal": "Mostrar",
44
+ "hide": "Ocultar",
43
45
  "disconnect": "Desconectar",
44
46
  "copyFailed": "No se pudo copiar al portapapeles",
45
47
  "revoke": "Revocar",
@@ -40,6 +40,8 @@
40
40
  "reconfigureHint": "Vous pourrez le reconfigurer plus tard."
41
41
  },
42
42
  "copied": "Copié dans le presse-papiers",
43
+ "reveal": "Afficher",
44
+ "hide": "Masquer",
43
45
  "disconnect": "Déconnecter",
44
46
  "copyFailed": "Échec de la copie dans le presse-papiers",
45
47
  "revoke": "Révoquer",
@@ -40,6 +40,8 @@
40
40
  "reconfigureHint": "אפשר להגדיר זאת שוב מאוחר יותר."
41
41
  },
42
42
  "copied": "הועתק ללוח",
43
+ "reveal": "הצג",
44
+ "hide": "הסתר",
43
45
  "disconnect": "נתק",
44
46
  "copyFailed": "לא ניתן להעתיק ללוח",
45
47
  "revoke": "לבטל",
@@ -40,6 +40,8 @@
40
40
  "reconfigureHint": "後で再設定できます。"
41
41
  },
42
42
  "copied": "クリップボードにコピーしました",
43
+ "reveal": "表示",
44
+ "hide": "非表示",
43
45
  "disconnect": "切断",
44
46
  "copyFailed": "クリップボードにコピーできませんでした",
45
47
  "revoke": "取り消す",
@@ -40,6 +40,8 @@
40
40
  "reconfigureHint": "Możesz skonfigurować to ponownie później."
41
41
  },
42
42
  "copied": "Skopiowano do schowka",
43
+ "reveal": "Pokaż",
44
+ "hide": "Ukryj",
43
45
  "disconnect": "Rozłącz",
44
46
  "copyFailed": "Nie udało się skopiować do schowka",
45
47
  "revoke": "Unieważnij",
@@ -40,6 +40,8 @@
40
40
  "reconfigureHint": "Bunu daha sonra yeniden yapılandırabilirsiniz."
41
41
  },
42
42
  "copied": "Panoya kopyalandı",
43
+ "reveal": "Göster",
44
+ "hide": "Gizle",
43
45
  "disconnect": "Bağlantıyı kes",
44
46
  "copyFailed": "Panoya kopyalanamadı",
45
47
  "revoke": "İptal et",
@@ -40,6 +40,8 @@
40
40
  "reconfigureHint": "Ви зможете налаштувати це знову пізніше."
41
41
  },
42
42
  "copied": "Скопійовано в буфер обміну",
43
+ "reveal": "Показати",
44
+ "hide": "Приховати",
43
45
  "disconnect": "Від'єднати",
44
46
  "copyFailed": "Не вдалося скопіювати до буфера обміну",
45
47
  "revoke": "Відкликати",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.96.2",
3
+ "version": "0.96.4",
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
  "valibot": "^1.4.2",
35
35
  "vue": "3.5.39",
36
36
  "wretch": "^3.0.9",
37
- "@cat-factory/contracts": "0.106.0"
37
+ "@cat-factory/contracts": "0.107.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@toad-contracts/testing": "0.3.2",