@cat-factory/app 0.309.0 → 0.310.1

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 (133) hide show
  1. package/README.md +11 -2
  2. package/app/components/assistant/AssistantModal.vue +5 -4
  3. package/app/components/auth/BackendMisconfiguredScreen.vue +4 -3
  4. package/app/components/auth/LoginScreen.vue +40 -26
  5. package/app/components/auth/ResetPasswordScreen.vue +9 -4
  6. package/app/components/auth/UserMenu.vue +4 -3
  7. package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +34 -23
  8. package/app/components/binaryOutput/StoredAssetView.vue +8 -6
  9. package/app/components/board/AddTaskModal.vue +16 -15
  10. package/app/components/board/AgentFailureCard.vue +12 -9
  11. package/app/components/board/AgentFailureHistory.vue +18 -9
  12. package/app/components/board/CreateInitiativeModal.vue +5 -4
  13. package/app/components/board/FailureDetail.vue +22 -12
  14. package/app/components/board/ReviewFrictionDialog.vue +4 -3
  15. package/app/components/board/nodes/BlockNode.vue +17 -15
  16. package/app/components/board/nodes/DecisionBadge.vue +4 -3
  17. package/app/components/board/nodes/FrameSwimlanes.vue +4 -3
  18. package/app/components/board/nodes/TaskCard.vue +12 -7
  19. package/app/components/board/nodes/TaskPipelineMini.vue +9 -7
  20. package/app/components/bootstrap/AdoptionReviewModal.vue +30 -21
  21. package/app/components/bootstrap/BootstrapModal.vue +3 -3
  22. package/app/components/brainstorm/BrainstormWindow.vue +5 -3
  23. package/app/components/bugFishing/BugFishingWindow.vue +49 -27
  24. package/app/components/clarity/ClarityReviewWindow.vue +5 -3
  25. package/app/components/common/CopyButton.vue +12 -11
  26. package/app/components/common/IconButton.vue +34 -9
  27. package/app/components/context/ContextAttachmentFields.vue +23 -12
  28. package/app/components/documents/ContextDocumentPicker.vue +12 -9
  29. package/app/components/documents/DocumentImportModal.vue +4 -3
  30. package/app/components/documents/DocumentOriginLink.vue +12 -9
  31. package/app/components/documents/DocumentSyncState.vue +3 -3
  32. package/app/components/documents/RepoContextDocPicker.vue +4 -3
  33. package/app/components/environments/EnvironmentStatusPanel.vue +4 -3
  34. package/app/components/followUp/FollowUpWindow.vue +9 -6
  35. package/app/components/forkDecision/ForkDecisionWindow.vue +71 -80
  36. package/app/components/foundational/FoundationalServiceCatalogList.vue +3 -2
  37. package/app/components/foundational/FoundationalServiceRegistry.vue +7 -6
  38. package/app/components/foundational/FoundationalServiceSources.vue +7 -6
  39. package/app/components/fragments/FragmentLibraryManager.vue +5 -4
  40. package/app/components/fragments/FragmentSelector.vue +12 -9
  41. package/app/components/gates/GateFailingCheckList.vue +4 -3
  42. package/app/components/gates/GateResultView.vue +17 -12
  43. package/app/components/github/AddServiceFromRepoModal.vue +11 -7
  44. package/app/components/github/GitHubOnboarding.vue +7 -2
  45. package/app/components/github/GitHubPanel.vue +9 -6
  46. package/app/components/github/RepoTreeBrowser.vue +15 -12
  47. package/app/components/humanTest/HumanTestWindow.vue +13 -9
  48. package/app/components/initiative/InitiativePlanReview.vue +28 -18
  49. package/app/components/initiative/InitiativeTrackerWindow.vue +171 -124
  50. package/app/components/judge/JudgeResultView.vue +5 -3
  51. package/app/components/kaizen/KaizenPanel.vue +67 -53
  52. package/app/components/layout/AccountTeamSettings.vue +9 -9
  53. package/app/components/layout/AppearanceSwitcher.vue +4 -3
  54. package/app/components/layout/BoardSwitcher.vue +10 -8
  55. package/app/components/layout/BoardToolbar.vue +5 -4
  56. package/app/components/layout/CommandBar.vue +4 -3
  57. package/app/components/layout/DefaultTestEnvBanner.vue +4 -3
  58. package/app/components/layout/IntegrationsHub.vue +8 -6
  59. package/app/components/layout/LanguageSwitcher.vue +4 -3
  60. package/app/components/layout/ModelProvidersHub.vue +4 -3
  61. package/app/components/layout/NotificationsInbox.vue +40 -30
  62. package/app/components/layout/PersonalSetupModal.vue +4 -3
  63. package/app/components/layout/RolePrompt.vue +4 -3
  64. package/app/components/layout/SideBar.vue +4 -3
  65. package/app/components/layout/TranslationWarningBanner.vue +4 -3
  66. package/app/components/layout/UiModeSwitcher.vue +8 -6
  67. package/app/components/layout/UiRoleSwitcher.vue +8 -6
  68. package/app/components/layout/WorkspaceMembersSettings.vue +2 -2
  69. package/app/components/media/ArtifactLightbox.vue +69 -39
  70. package/app/components/media/ImageCompare.vue +69 -74
  71. package/app/components/observability/OutcomeFilterChips.vue +5 -4
  72. package/app/components/observability/RunFailureSummary.vue +8 -6
  73. package/app/components/observability/StepModelActivity.vue +5 -3
  74. package/app/components/observability/ToolCallList.vue +4 -2
  75. package/app/components/outcome/OutcomeSummaryWindow.vue +5 -4
  76. package/app/components/palettes/AgentPalette.vue +18 -14
  77. package/app/components/panels/AgentStepDetail.vue +30 -19
  78. package/app/components/panels/ObservabilityPanel.vue +149 -114
  79. package/app/components/panels/OperatorDashboardPanel.vue +99 -91
  80. package/app/components/panels/ReportsPanel.vue +64 -61
  81. package/app/components/panels/ResultWindowShell.vue +148 -134
  82. package/app/components/panels/StepContainerStatus.vue +9 -9
  83. package/app/components/panels/StepDelegatedStatus.vue +11 -9
  84. package/app/components/panels/StepRestartControl.vue +3 -2
  85. package/app/components/panels/inspector/EpicChildren.vue +4 -3
  86. package/app/components/panels/inspector/FrontendConfig.vue +3 -2
  87. package/app/components/panels/inspector/InspectorSection.vue +22 -17
  88. package/app/components/panels/inspector/ServiceConnections.vue +3 -2
  89. package/app/components/panels/inspector/ServiceTestConfig.vue +5 -4
  90. package/app/components/panels/inspector/TaskExecution.vue +5 -4
  91. package/app/components/panels/inspector/TaskRunSettings.vue +10 -6
  92. package/app/components/pipeline/EstimateThresholdFields.vue +13 -10
  93. package/app/components/pipeline/GateConfigFields.vue +14 -18
  94. package/app/components/pipeline/PipelineBuilder.vue +90 -77
  95. package/app/components/pipeline/PipelinePicker.vue +8 -6
  96. package/app/components/pipeline/PipelineProgress.vue +26 -21
  97. package/app/components/prReview/PrReviewWindow.vue +46 -25
  98. package/app/components/providers/ApiKeysSection.vue +4 -3
  99. package/app/components/ralph/RalphLoopResultView.vue +4 -3
  100. package/app/components/requirements/RequirementsReviewWindow.vue +10 -6
  101. package/app/components/riskPolicy/RiskPolicyPicker.vue +8 -6
  102. package/app/components/sandbox/SandboxPanel.vue +73 -54
  103. package/app/components/settings/CloudflareHandlerSection.vue +33 -30
  104. package/app/components/settings/ConsensusGroupsSection.vue +40 -30
  105. package/app/components/settings/InfraHandlersConfigurator.vue +16 -13
  106. package/app/components/settings/InfrastructureBackendPicker.vue +17 -17
  107. package/app/components/settings/IssueTrackerPanel.vue +21 -6
  108. package/app/components/settings/LocalModelEndpointsPanel.vue +3 -2
  109. package/app/components/settings/ModelConfigurationPanel.vue +9 -8
  110. package/app/components/settings/OpenRouterCatalogPanel.vue +4 -3
  111. package/app/components/settings/ProviderPreferenceEditor.vue +5 -6
  112. package/app/components/settings/RiskPolicyEditorRow.vue +3 -2
  113. package/app/components/settings/ServiceFragmentDefaultsPanel.vue +10 -8
  114. package/app/components/skills/ReviewSkillQueue.vue +4 -3
  115. package/app/components/skills/SkillLibraryManager.vue +7 -6
  116. package/app/components/slack/SlackPanel.vue +9 -5
  117. package/app/components/spec/ServiceSpecWindow.vue +4 -3
  118. package/app/components/tasks/ContextIssuePicker.vue +12 -9
  119. package/app/components/tasks/TaskContextIssues.vue +4 -3
  120. package/app/components/testing/TestReportWindow.vue +14 -8
  121. package/app/components/theme/ThemeImportModal.vue +2 -2
  122. package/app/components/visualConfirm/VisualConfirmationWindow.vue +62 -35
  123. package/i18n/locales/de.json +7 -3
  124. package/i18n/locales/en.json +7 -3
  125. package/i18n/locales/es.json +7 -3
  126. package/i18n/locales/fr.json +7 -3
  127. package/i18n/locales/he.json +7 -3
  128. package/i18n/locales/it.json +7 -3
  129. package/i18n/locales/ja.json +7 -3
  130. package/i18n/locales/pl.json +7 -3
  131. package/i18n/locales/tr.json +7 -3
  132. package/i18n/locales/uk.json +7 -3
  133. package/package.json +2 -2
package/README.md CHANGED
@@ -321,6 +321,15 @@ That is the whole mechanism, and it is why there is no rule here about WHICH ste
321
321
 
322
322
  The default `radius` lives on the `Cat Factory` document in `app/utils/theme/builtins.ts`. It equals `THEME_DEFAULTS.radius` and so writes no CSS, which is the point: changing the number there is the whole edit.
323
323
 
324
+ ### A control is its Nuxt UI component
325
+
326
+ Every CONTROL in the SPA is the Nuxt UI component for it, never the raw HTML element. A raw `<button>` beside a `UButton` is the same control rendered twice, and only one of the two follows the theme, the focus ring, the disabled state and the size scale; the SPA carried 152 of them, 24 raw `<input>`, five hand-built tables and six hand-rolled `<details>`, each with its own border, background and hover recipe.
327
+
328
+ - **The mapping**: `<button>` → `UButton`; `<input>` → `UInput` / `UInputNumber` / `UCheckbox` / `URadioGroup` / `USlider` / `UFileUpload`, by what the control IS rather than by its `type=`; `<select>` → `USelect` (`USelectMenu` when it needs searching, `UInputMenu` when the list is open-ended); `<textarea>` → `UTextarea`; `<table>` → `UTable` with column definitions; `<details>` → `UCollapsible`; `<form>` → `UForm`; `<a href>` → `ULink`; `<datalist>` → `UInputMenu` with `mode="autocomplete"`, which is what a datalist was (the DEFAULT combobox mode writes its model only on SELECT, so a typed free-text value is dropped). Which input for which job is the skill's [component-selection matrix](https://github.com/nuxt/ui/blob/v4/skills/nuxt-ui/references/component-selection.md), not restated here.
329
+ - **An icon-only button is [`common/IconButton.vue`](./app/components/common/IconButton.vue)**, which wraps `UButton` in a `UTooltip` and applies `label` as the tooltip text AND `aria-label`. A `title` attribute is not a tooltip: it never fires on touch, is unstyled, waits about a second, and looks nothing like the app's other hints. `IconButton` and `CopyButton` own their tooltip, so passing either a `title` renders a second hint over the first. A `title` on a LABELLED `UButton` is still fine: there it is a hover hint beside the control's own name rather than the name itself. `class` on `IconButton` lands on the wrapper the tooltip needs, which is the box the parent lays out, so it carries layout (`ms-auto`, `shrink-0`, `nodrag`); the button's own look goes through its props and `:ui="{ base: '…' }"`. The guard refuses an icon-only `UButton` whose only name is a `title`.
330
+ - **Prose and layout markup is not in scope.** `<p>`, `<h1>`-`<h6>`, `<ul>`, `<li>`, `<dl>`, `<pre>`, `<code>`, `<img>`, `<nav>`, `<div>` and `<span>` are not controls and have no Nuxt UI component; heading typography is the type scale's rule above.
331
+ - `scripts/check-frontend-primitives.mjs` (CI) holds the count at zero. There is deliberately **no allow-list**: the migration expected two exceptions (a Vue Flow gesture handle, a full-bleed media tile) and both converted cleanly, so the exception table would have had more surface than the thing it governed. A site that genuinely cannot convert says why with a `raw-control-ok:` comment, on that line or the one above; nothing in the tree needs one, and a second one appearing is the signal that the rule needs revisiting rather than the site.
332
+
324
333
  ### Themes and colour mode
325
334
 
326
335
  A **theme** is a Nuxt UI theme editor document (https://ui.nuxt.com/theme): the sparse `ThemeDoc` the editor edits and shares as a `?doc=` link, decoded by `utils/theme/link.ts` and typed in `utils/theme/doc.ts` (a port of the editor engine's tables). The app speaks THAT format rather than one of its own, so a theme built in the editor imports as-is and a built-in theme is a document a user could have exported. `utils/theme/builtins.ts` ships `Cat Factory` (the default; its `colors` MUST equal `app.config.ts`'s `ui.colors`, which paints the first frame) and the editor's own `Mono` preset, referenced from the preset table rather than copied. The `theme` store persists the pick and the user's imported documents in this browser; `plugins/theme.client.ts` applies the active document at runtime: aliases onto `appConfig.ui.colors` (Nuxt UI's own colours plugin re-emits the ramps), default variants and component overrides onto `appConfig.ui.<component>` merged INTO the layer's own overrides, and tokens / radius / fonts onto one `<style id="app-theme">` whose selectors anchor on the `data-theme` attribute on `<html>` (`utils/theme/css.ts`, which is also the write boundary for untrusted values). The faces the built-in themes name (Geist, Geist Mono) are self-hosted through `@nuxt/fonts` (`fonts.families` in `nuxt.config.ts`, pinned by `builtins.spec.ts`); a font an IMPORTED theme names is applied as a family name only, so it renders when the device or the deployment provides it. A editor link whose document equals one of the editor's presets collapses to `{ preset: 'mono' }`; `utils/theme/presets.ts` is a copy of the editor's preset table so such a link rebuilds (a decode table, not menu entries).
@@ -375,8 +384,8 @@ protects, so the two never contradict silently:
375
384
  freely; here a failed backend call is reported with `usePipelineErrorToast().present(error, key)`.
376
385
  [Rule](#every-failure-toast-goes-through-one-funnel).
377
386
  - **Icon-only buttons carry an accessible name.** Use `common/IconButton.vue`
378
- ([source](./app/components/common/IconButton.vue)), which applies `label` as both `title` and
379
- `aria-label`, rather than a bare `<UButton icon>`.
387
+ ([source](./app/components/common/IconButton.vue)), which applies `label` as both the tooltip
388
+ and `aria-label`, rather than a bare `<UButton icon>`.
380
389
  - **Colour goes through theme tokens, never a fixed palette shade.** The skill bans raw Tailwind
381
390
  hues; this repo also bans fixed numbered aliases, enforced by
382
391
  [`scripts/check-frontend-palette.mjs`](https://github.com/kibertoad/cat-factory/blob/main/scripts/check-frontend-palette.mjs).
@@ -259,16 +259,17 @@ function reveal(blockId: string): void {
259
259
  {{ t('assistant.examplesTitle') }}
260
260
  </SectionLabel>
261
261
  <div class="flex flex-col gap-1">
262
- <button
262
+ <UButton
263
+ color="neutral"
264
+ variant="ghost"
263
265
  v-for="entry in examples"
264
266
  :key="entry.actionId"
265
- type="button"
266
- class="rounded-md px-2 py-1 text-left text-sm text-toned hover:bg-elevated"
267
+ class="block rounded-md px-2 py-1 text-left text-sm text-toned hover:bg-elevated"
267
268
  @click="useExample(entry.example)"
268
269
  >
269
270
  <span class="text-muted">{{ entry.label }}</span>
270
271
  <span class="block text-xs text-dimmed">“{{ entry.example }}”</span>
271
- </button>
272
+ </UButton>
272
273
  </div>
273
274
  </div>
274
275
 
@@ -41,16 +41,17 @@ function reload() {
41
41
  <span class="font-medium text-toned">{{ t('app.misconfigured.howToFix') }}</span>
42
42
  {{ problem.remedy }}
43
43
  </p>
44
- <a
44
+ <ULink
45
+ raw
45
46
  v-if="problem.docsUrl"
46
- :href="problem.docsUrl"
47
+ :to="problem.docsUrl"
47
48
  target="_blank"
48
49
  rel="noopener noreferrer"
49
50
  class="mt-2 inline-flex items-center gap-1 text-sm text-app-warning-300 hover:text-app-warning-200"
50
51
  >
51
52
  <UIcon name="i-lucide-book-open" class="h-4 w-4" />
52
53
  {{ t('app.misconfigured.viewDocs') }}
53
- </a>
54
+ </ULink>
54
55
  </li>
55
56
  </ul>
56
57
 
@@ -286,16 +286,17 @@ const noSignInMethod = computed(
286
286
  <!-- Only when nothing can be installed here: the paste form below carries its own
287
287
  per-provider link, so showing these too would offer the same thing twice. -->
288
288
  <div v-if="installableProviders.length === 0" class="flex flex-wrap gap-3 px-1">
289
- <a
289
+ <ULink
290
+ raw
290
291
  v-for="p in ALL_PROVIDERS"
291
292
  :key="p"
292
- :href="tokenCreateUrl(p)"
293
+ :to="tokenCreateUrl(p)"
293
294
  target="_blank"
294
295
  rel="noopener noreferrer"
295
296
  class="text-xs text-primary hover:underline"
296
297
  >
297
298
  {{ t('auth.localMode.createToken', { provider: PROVIDER_LABELS[p] }) }}
298
- </a>
299
+ </ULink>
299
300
  </div>
300
301
  </template>
301
302
 
@@ -379,10 +380,10 @@ const noSignInMethod = computed(
379
380
  </div>
380
381
 
381
382
  <!-- Email / password -->
382
- <form
383
+ <UForm
383
384
  v-if="auth.providers.password && mode !== 'forgot'"
384
385
  class="space-y-3"
385
- @submit.prevent="submitPassword"
386
+ @submit="submitPassword"
386
387
  >
387
388
  <UInput
388
389
  v-if="mode === 'signup'"
@@ -426,36 +427,43 @@ const noSignInMethod = computed(
426
427
  <template v-if="mode === 'login'">
427
428
  <i18n-t keypath="auth.login.needAccount" tag="span" scope="global">
428
429
  <template #signUp>
429
- <button
430
- type="button"
431
- class="text-primary hover:underline"
430
+ <UButton
431
+ color="primary"
432
+ variant="link"
433
+ class="p-0 text-xs text-primary hover:underline"
432
434
  @click="setMode('signup')"
433
435
  >
434
436
  {{ t('auth.login.signUp') }}
435
- </button>
437
+ </UButton>
436
438
  </template>
437
439
  </i18n-t>
438
440
  </template>
439
441
  <template v-else>
440
442
  <i18n-t keypath="auth.login.haveAccount" tag="span" scope="global">
441
443
  <template #signIn>
442
- <button
443
- type="button"
444
- class="text-primary hover:underline"
444
+ <UButton
445
+ color="primary"
446
+ variant="link"
447
+ class="p-0 text-xs text-primary hover:underline"
445
448
  @click="setMode('login')"
446
449
  >
447
450
  {{ t('auth.login.signIn') }}
448
- </button>
451
+ </UButton>
449
452
  </template>
450
453
  </i18n-t>
451
454
  </template>
452
455
  </p>
453
456
  <p v-if="mode === 'login'" class="text-center text-xs text-muted">
454
- <button type="button" class="text-primary hover:underline" @click="setMode('forgot')">
457
+ <UButton
458
+ color="primary"
459
+ variant="link"
460
+ class="p-0 text-xs text-primary hover:underline"
461
+ @click="setMode('forgot')"
462
+ >
455
463
  {{ t('auth.login.forgotPassword') }}
456
- </button>
464
+ </UButton>
457
465
  </p>
458
- </form>
466
+ </UForm>
459
467
 
460
468
  <!-- Paste-a-token sign-in: your own PAT on a hosted node; on local mode the token this
461
469
  deployment will operate with (see `remotePatProviders`). -->
@@ -472,7 +480,7 @@ const noSignInMethod = computed(
472
480
  <span class="h-px flex-1 bg-elevated" /> {{ t('auth.login.or') }}
473
481
  <span class="h-px flex-1 bg-elevated" />
474
482
  </div>
475
- <form class="space-y-3" @submit.prevent="submitRemotePat">
483
+ <UForm class="space-y-3" @submit="submitRemotePat">
476
484
  <div v-if="remotePatProviders.length > 1" class="flex gap-2">
477
485
  <UButton
478
486
  v-for="p in remotePatProviders"
@@ -517,8 +525,9 @@ const noSignInMethod = computed(
517
525
  {{ t('auth.localMode.tokenBecomesCredential') }}
518
526
  </p>
519
527
  <p class="px-1 text-center">
520
- <a
521
- :href="tokenCreateUrl(remotePatProvider)"
528
+ <ULink
529
+ raw
530
+ :to="tokenCreateUrl(remotePatProvider)"
522
531
  target="_blank"
523
532
  rel="noopener noreferrer"
524
533
  class="text-xs text-primary hover:underline"
@@ -526,9 +535,9 @@ const noSignInMethod = computed(
526
535
  {{
527
536
  t('auth.localMode.createToken', { provider: PROVIDER_LABELS[remotePatProvider] })
528
537
  }}
529
- </a>
538
+ </ULink>
530
539
  </p>
531
- </form>
540
+ </UForm>
532
541
  </template>
533
542
 
534
543
  <!-- No sign-in method configured on a remote deployment: explain, don't show a blank card -->
@@ -542,10 +551,10 @@ const noSignInMethod = computed(
542
551
  />
543
552
 
544
553
  <!-- Forgot password: request a reset link by email -->
545
- <form
554
+ <UForm
546
555
  v-if="auth.providers.password && mode === 'forgot'"
547
556
  class="space-y-3"
548
- @submit.prevent="submitForgot"
557
+ @submit="submitForgot"
549
558
  >
550
559
  <template v-if="forgotSent">
551
560
  <p class="text-sm text-toned">
@@ -568,11 +577,16 @@ const noSignInMethod = computed(
568
577
  </UButton>
569
578
  </template>
570
579
  <p class="text-center text-xs text-muted">
571
- <button type="button" class="text-primary hover:underline" @click="setMode('login')">
580
+ <UButton
581
+ color="primary"
582
+ variant="link"
583
+ class="p-0 text-xs text-primary hover:underline"
584
+ @click="setMode('login')"
585
+ >
572
586
  {{ t('auth.login.backToSignIn') }}
573
- </button>
587
+ </UButton>
574
588
  </p>
575
- </form>
589
+ </UForm>
576
590
  </div>
577
591
  </div>
578
592
  </template>
@@ -76,7 +76,7 @@ function goToLogin() {
76
76
  </UButton>
77
77
  </template>
78
78
 
79
- <form v-else class="space-y-3" @submit.prevent="submit">
79
+ <UForm v-else class="space-y-3" @submit="submit">
80
80
  <SecretInput
81
81
  v-model="password"
82
82
  required
@@ -98,11 +98,16 @@ function goToLogin() {
98
98
  {{ t('auth.resetPassword.submit') }}
99
99
  </UButton>
100
100
  <p class="text-center text-xs text-muted">
101
- <button type="button" class="text-primary hover:underline" @click="goToLogin">
101
+ <UButton
102
+ color="primary"
103
+ variant="link"
104
+ class="p-0 text-xs text-primary hover:underline"
105
+ @click="goToLogin"
106
+ >
102
107
  {{ t('auth.resetPassword.backToSignIn') }}
103
- </button>
108
+ </UButton>
104
109
  </p>
105
- </form>
110
+ </UForm>
106
111
  </div>
107
112
  </div>
108
113
  </template>
@@ -33,8 +33,9 @@ const items = computed<DropdownMenuItem[][]>(() => [
33
33
 
34
34
  <template>
35
35
  <UDropdownMenu v-if="auth.user" :items="items" :content="{ side: 'top', align: 'start' }">
36
- <button
37
- type="button"
36
+ <UButton
37
+ color="neutral"
38
+ variant="ghost"
38
39
  data-testid="user-menu"
39
40
  :title="collapsed ? auth.user.name || auth.user.login : undefined"
40
41
  class="flex w-full items-center gap-2 rounded-lg border border-default bg-default/60 p-2 text-start transition hover:bg-elevated/60"
@@ -53,6 +54,6 @@ const items = computed<DropdownMenuItem[][]>(() => [
53
54
  <div class="truncate text-3xs text-dimmed">@{{ auth.user.login }}</div>
54
55
  </div>
55
56
  <UIcon v-if="!collapsed" name="i-lucide-chevron-up" class="h-4 w-4 shrink-0 text-dimmed" />
56
- </button>
57
+ </UButton>
57
58
  </UDropdownMenu>
58
59
  </template>
@@ -235,6 +235,16 @@ const { requestClose } = useUnsavedGuard({
235
235
  >
236
236
  </p>
237
237
 
238
+ <!-- Single-select renders a checkbox per card, not one radio group, so this line is what
239
+ tells everyone (a screen reader through each box's description) that ticking one clears
240
+ the other. -->
241
+ <p
242
+ v-if="view.awaiting && !view.multiSelect"
243
+ id="binary-candidates-pick-one"
244
+ class="mb-3 text-xs text-muted"
245
+ >
246
+ {{ t('binaryCandidates.pickOne') }}
247
+ </p>
238
248
  <div v-for="group in view.groups" :key="group.subject ?? '·'" class="mb-6">
239
249
  <h3 class="mb-2 text-xs font-medium text-muted">
240
250
  {{ group.subject ?? t('binaryCandidates.unlabelledSubject') }}
@@ -255,33 +265,34 @@ const { requestClose } = useUnsavedGuard({
255
265
  >
256
266
  <!-- The REAL control, not the card's click handler: ticking a candidate is the only
257
267
  way to keep an asset, so without a focusable input the gate could not be completed
258
- by keyboard at all (UX-80). The whole window is ONE radio group in single-select
259
- mode, because `toggle` replaces the selection across every subject rather than per
260
- group.
268
+ by keyboard at all (UX-80). In single-select mode `toggle` replaces the selection
269
+ across every subject rather than per group.
261
270
 
262
- `@click.stop` belongs on the LABEL, which is the element the card's own toggle has
263
- to be shielded from. On the input alone it stopped the wrong click: activating a
264
- label forwards a synthetic click to its input (which `.stop` there does not
265
- prevent, only its propagation), so a click on the label TEXT bubbled to the card
266
- and toggled, then the forwarded click toggled back. On a checkbox that nets to no
267
- change, which means no re-render, which leaves the box ticked over a candidate
268
- that is no longer selected. -->
269
- <label
270
- v-if="view.awaiting"
271
- class="mb-1.5 flex cursor-pointer items-center gap-2 text-2xs text-muted"
272
- @click.stop
273
- >
274
- <input
275
- :type="view.multiSelect ? 'checkbox' : 'radio'"
276
- name="binary-candidate"
277
- class="accent-app-info-500"
278
- :checked="selected.includes(row.id)"
271
+ `@click.stop` belongs on the WRAPPER, which is what the card's own toggle has to
272
+ be shielded from, and NOT on the control: activating a label forwards a synthetic
273
+ click to its control (which `.stop` there does not prevent, only its
274
+ propagation), so a click on the label TEXT bubbles to the card and toggles, then
275
+ the forwarded click toggles back. On a checkbox that nets to no change, which
276
+ means no re-render, which leaves the box ticked over a candidate that is no
277
+ longer selected. UCheckbox is not the place for it either: Nuxt UI forwards
278
+ `$attrs` onto the inner checkbox button rather than onto its root, so a listener
279
+ put there is the same "on the control alone" mistake spelled differently. -->
280
+ <!-- One control either way. Single-select is a checkbox rather than a radio group,
281
+ because the rows render per candidate across several subjects and a radio group
282
+ needs the whole set in one place. So the box announces as a checkbox, and the
283
+ "pick one" line above is its description: that is the cue that ticking it clears
284
+ the previous pick. -->
285
+ <div v-if="view.awaiting" class="mb-1.5" @click.stop>
286
+ <UCheckbox
287
+ size="xs"
288
+ :model-value="selected.includes(row.id)"
289
+ :label="candidateLabel(row)"
279
290
  :aria-label="candidateLabel(row)"
291
+ :aria-describedby="view.multiSelect ? undefined : 'binary-candidates-pick-one'"
280
292
  data-testid="binary-candidate-select"
281
- @change="toggle(row.id)"
293
+ @update:model-value="toggle(row.id)"
282
294
  />
283
- <span class="truncate">{{ candidateLabel(row) }}</span>
284
- </label>
295
+ </div>
285
296
  <!-- Staged through the platform's OWN asset storage: we hold the bytes, so the card
286
297
  renders them (and offers to open or save one) rather than waiting for a public
287
298
  link the shipped storage never issues. Checked first because a candidate can
@@ -112,22 +112,24 @@ const filename = computed(() => {
112
112
  <!-- The two things a person does with a delivered asset. Both hang off the object URL, so
113
113
  they appear only once the bytes are in hand. -->
114
114
  <div v-if="url" class="flex items-center gap-3 text-2xs">
115
- <a
116
- :href="url"
115
+ <ULink
116
+ raw
117
+ :to="url"
117
118
  target="_blank"
118
119
  rel="noopener"
119
120
  class="text-app-info-300 hover:underline"
120
121
  data-testid="stored-asset-open"
121
122
  @click.stop
122
- >{{ t('binaryOutput.asset.open') }}</a
123
+ >{{ t('binaryOutput.asset.open') }}</ULink
123
124
  >
124
- <a
125
- :href="url"
125
+ <ULink
126
+ raw
127
+ :to="url"
126
128
  :download="filename"
127
129
  class="text-app-info-300 hover:underline"
128
130
  data-testid="stored-asset-download"
129
131
  @click.stop
130
- >{{ t('binaryOutput.asset.download') }}</a
132
+ >{{ t('binaryOutput.asset.download') }}</ULink
131
133
  >
132
134
  </div>
133
135
  </div>
@@ -142,6 +142,11 @@ const timeboxHours = ref<number | undefined>(undefined)
142
142
  // default — an expedition exists to cover ground nobody thought to look at, so narrowing it is
143
143
  // the deliberate act) plus an optional focus folded into every angle's prompt.
144
144
  const fishingPhaseIds = ref<string[]>([])
145
+ function toggleFishingPhase(id: string, checked: boolean) {
146
+ fishingPhaseIds.value = checked
147
+ ? [...fishingPhaseIds.value, id]
148
+ : fishingPhaseIds.value.filter((x) => x !== id)
149
+ }
145
150
  const fishingFocus = ref('')
146
151
  /** Held as a string because the input is a text field; parsed at submit, blank ⇒ the default. */
147
152
  const fishingMaxPasses = ref('')
@@ -1105,23 +1110,19 @@ function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseC
1105
1110
  :description="t('board.addTask.bugFishingFields.angles.hint')"
1106
1111
  >
1107
1112
  <div class="grid gap-1.5 sm:grid-cols-2">
1108
- <label
1113
+ <!-- Each angle keeps its own test hook, so the boolean is derived from the id
1114
+ list rather than bound through a checkbox group. -->
1115
+ <UCheckbox
1109
1116
  v-for="phase in BUG_FISHING_PHASES"
1110
1117
  :key="phase.id"
1111
- class="flex items-start gap-2 rounded-md px-1.5 py-1 text-xs hover:bg-elevated/40"
1112
- >
1113
- <input
1114
- v-model="fishingPhaseIds"
1115
- type="checkbox"
1116
- :value="phase.id"
1117
- class="mt-0.5 accent-app-info-500"
1118
- :data-testid="`add-task-fishing-angle-${phase.id}`"
1119
- />
1120
- <span class="min-w-0">
1121
- <span class="block text-default">{{ phase.title }}</span>
1122
- <span class="block text-2xs text-dimmed">{{ phase.goal }}</span>
1123
- </span>
1124
- </label>
1118
+ size="xs"
1119
+ :model-value="fishingPhaseIds.includes(phase.id)"
1120
+ :label="phase.title"
1121
+ :description="phase.goal"
1122
+ class="rounded-md px-1.5 py-1 hover:bg-elevated/40"
1123
+ :data-testid="`add-task-fishing-angle-${phase.id}`"
1124
+ @update:model-value="toggleFishingPhase(phase.id, $event === true)"
1125
+ />
1125
1126
  </div>
1126
1127
  <p v-if="fishingPhaseIds.length === 0" class="mt-1.5 text-2xs text-dimmed">
1127
1128
  {{ t('board.addTask.bugFishingFields.angles.allSelected') }}
@@ -197,8 +197,9 @@ async function retry() {
197
197
  />
198
198
 
199
199
  <div class="mt-2 flex flex-wrap items-center gap-2">
200
- <button
201
- type="button"
200
+ <UButton
201
+ color="neutral"
202
+ variant="ghost"
202
203
  class="nodrag flex items-center gap-1 rounded-md bg-app-error-900/40 text-app-error-200 hover:bg-app-error-900/70 disabled:opacity-60"
203
204
  :class="compact ? 'px-2 py-0.5 text-3xs' : 'px-2 py-1 text-2xs'"
204
205
  :disabled="retrying || !access.canExecuteRuns.value"
@@ -211,11 +212,12 @@ async function retry() {
211
212
  :class="[compact ? 'h-3 w-3' : 'h-3.5 w-3.5', { 'animate-spin': retrying }]"
212
213
  />
213
214
  {{ retrying ? t('board.failure.retrying') : compact ? t('common.retry') : retryLabel }}
214
- </button>
215
+ </UButton>
215
216
 
216
- <button
217
+ <UButton
218
+ color="neutral"
219
+ variant="ghost"
217
220
  v-if="run.kind === 'bootstrap'"
218
- type="button"
219
221
  class="nodrag flex items-center gap-1 rounded-md bg-app-error-900/20 text-app-error-300 hover:bg-app-error-900/50"
220
222
  :class="compact ? 'px-2 py-0.5 text-3xs' : 'px-2 py-1 text-2xs'"
221
223
  data-testid="agent-failure-inspect"
@@ -223,16 +225,17 @@ async function retry() {
223
225
  >
224
226
  <UIcon name="i-lucide-activity" :class="compact ? 'h-3 w-3' : 'h-3.5 w-3.5'" />
225
227
  {{ t('observability.modelActivity') }}
226
- </button>
228
+ </UButton>
227
229
 
228
230
  <!-- Environment provisioning failures are almost always a deploy-backend / provider-config
229
231
  issue, so link straight to where it's set up rather than leaving the user to hunt. The
230
232
  destination + label follow the cause: a `deploy_runner_unwired` failure needs the runner
231
233
  pool (Agent containers tab), every other cause needs the environment provider (Test
232
234
  environments tab) — see `routesToRunnerPool`. -->
233
- <button
235
+ <UButton
236
+ color="neutral"
237
+ variant="ghost"
234
238
  v-if="isEnvironmentFailure"
235
- type="button"
236
239
  class="nodrag flex items-center gap-1 rounded-md bg-app-error-900/20 text-app-error-300 hover:bg-app-error-900/50"
237
240
  :class="compact ? 'px-2 py-0.5 text-3xs' : 'px-2 py-1 text-2xs'"
238
241
  data-testid="agent-failure-configure-environment"
@@ -240,7 +243,7 @@ async function retry() {
240
243
  >
241
244
  <UIcon name="i-lucide-settings" :class="compact ? 'h-3 w-3' : 'h-3.5 w-3.5'" />
242
245
  {{ failureSetupLabel }}
243
- </button>
246
+ </UButton>
244
247
  </div>
245
248
  </div>
246
249
  </template>
@@ -13,18 +13,27 @@ const { t } = useI18n()
13
13
  </script>
14
14
 
15
15
  <template>
16
- <details
16
+ <UCollapsible
17
17
  v-if="failures.length"
18
18
  class="nodrag rounded-lg border border-muted/60 bg-default/40 px-3 py-2"
19
19
  data-testid="agent-failure-history"
20
20
  >
21
- <summary
22
- class="flex cursor-pointer items-center gap-1.5 text-2xs text-muted hover:text-default"
23
- >
24
- <UIcon name="i-lucide-history" class="h-3.5 w-3.5 shrink-0" />
25
- {{ t('board.failure.history.previousErrors', { count: failures.length }, failures.length) }}
26
- </summary>
21
+ <template #default="{ open }">
22
+ <UButton
23
+ variant="link"
24
+ color="neutral"
25
+ size="xs"
26
+ icon="i-lucide-history"
27
+ :trailing-icon="open ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
28
+ :label="
29
+ t('board.failure.history.previousErrors', { count: failures.length }, failures.length)
30
+ "
31
+ :ui="{ base: 'w-full justify-start gap-1.5 p-0 text-2xs text-muted hover:text-default' }"
32
+ />
33
+ </template>
27
34
 
28
- <FailureHistoryList :failures="props.failures" class="mt-2" />
29
- </details>
35
+ <template #content>
36
+ <FailureHistoryList :failures="props.failures" class="mt-2" />
37
+ </template>
38
+ </UCollapsible>
30
39
  </template>
@@ -193,16 +193,17 @@ async function create() {
193
193
  <div v-if="presets.length > 1" class="space-y-1.5">
194
194
  <span class="text-xs font-medium text-toned">{{ t('initiative.create.preset') }}</span>
195
195
  <div class="grid gap-2" data-testid="initiative-preset-picker">
196
- <button
196
+ <UButton
197
+ color="neutral"
198
+ variant="ghost"
197
199
  v-for="p in presets"
198
200
  :key="p.id"
199
- type="button"
200
201
  :data-testid="`initiative-preset-option-${p.id}`"
201
202
  :aria-pressed="p.id === selectedPresetId"
202
203
  class="flex items-start gap-3 rounded-md border px-3 py-2 text-left transition"
203
204
  :class="
204
205
  p.id === selectedPresetId
205
- ? 'border-primary bg-primary/10'
206
+ ? 'border-primary bg-primary/10 hover:bg-primary/10 focus-visible:bg-primary/10 disabled:bg-primary/10'
206
207
  : 'border-muted hover:border-app-600'
207
208
  "
208
209
  @click="selectPreset(p.id)"
@@ -220,7 +221,7 @@ async function create() {
220
221
  {{ p.presentation.description }}
221
222
  </span>
222
223
  </span>
223
- </button>
224
+ </UButton>
224
225
  </div>
225
226
  </div>
226
227
 
@@ -18,18 +18,28 @@ const { t } = useI18n()
18
18
  </script>
19
19
 
20
20
  <template>
21
- <details v-if="detail && detail !== message" class="mt-1">
22
- <summary class="cursor-pointer" :class="summaryClass">
23
- {{ t('board.failure.showDetail') }}
24
- </summary>
21
+ <UCollapsible v-if="detail && detail !== message" class="mt-1">
22
+ <template #default="{ open }">
23
+ <UButton
24
+ variant="link"
25
+ color="neutral"
26
+ size="xs"
27
+ :icon="open ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
28
+ :label="t('board.failure.showDetail')"
29
+ :class="summaryClass"
30
+ :ui="{ base: 'w-full justify-start gap-1 p-0' }"
31
+ />
32
+ </template>
25
33
  <!-- The stack trace / extended detail: the first thing a user does with it is copy it, so
26
34
  offer a copy affordance floated over the scroll box (UX-39). -->
27
- <div class="relative mt-1">
28
- <CopyButton :text="detail" class="absolute end-1 top-1 z-10" />
29
- <pre
30
- class="max-h-32 overflow-auto whitespace-pre-wrap rounded-sm p-1.5 pe-9"
31
- :class="preClass"
32
- >{{ detail }}</pre>
33
- </div>
34
- </details>
35
+ <template #content>
36
+ <div class="relative mt-1">
37
+ <CopyButton :text="detail" class="absolute end-1 top-1 z-10" />
38
+ <pre
39
+ class="max-h-32 overflow-auto whitespace-pre-wrap rounded-sm p-1.5 pe-9"
40
+ :class="preClass"
41
+ >{{ detail }}</pre>
42
+ </div>
43
+ </template>
44
+ </UCollapsible>
35
45
  </template>
@@ -90,8 +90,9 @@ function createAnyway() {
90
90
  </SectionLabel>
91
91
  <ul class="space-y-1">
92
92
  <li v-for="item in ctx.debt" :key="item.blockId">
93
- <button
94
- type="button"
93
+ <UButton
94
+ color="neutral"
95
+ variant="ghost"
95
96
  class="flex w-full items-center justify-between gap-3 rounded-md px-2 py-1.5 text-left text-sm hover:bg-elevated/60 disabled:opacity-50"
96
97
  :disabled="pending"
97
98
  @click="goToBlock(item.blockId)"
@@ -102,7 +103,7 @@ function createAnyway() {
102
103
  <span class="shrink-0 text-xs text-dimmed">
103
104
  {{ t('errors.reviewFriction.waiting', { minutes: item.waitingMinutes }) }}
104
105
  </span>
105
- </button>
106
+ </UButton>
106
107
  </li>
107
108
  </ul>
108
109
  </div>