@cat-factory/app 0.308.0 → 0.310.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 (265) hide show
  1. package/README.md +32 -2
  2. package/app/assets/css/main.css +22 -0
  3. package/app/assets/css/prose.css +4 -4
  4. package/app/assets/css/type.css +24 -0
  5. package/app/components/assistant/AssistantModal.vue +8 -6
  6. package/app/components/auth/BackendMisconfiguredScreen.vue +4 -3
  7. package/app/components/auth/LoginScreen.vue +40 -26
  8. package/app/components/auth/ResetPasswordScreen.vue +9 -4
  9. package/app/components/auth/UserMenu.vue +5 -4
  10. package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +48 -37
  11. package/app/components/binaryOutput/BinaryOutputReport.vue +10 -14
  12. package/app/components/binaryOutput/StoredAssetView.vue +13 -11
  13. package/app/components/board/AddTaskModal.vue +30 -27
  14. package/app/components/board/AgentFailureCard.vue +21 -18
  15. package/app/components/board/AgentFailureHistory.vue +18 -9
  16. package/app/components/board/CreateInitiativeModal.vue +7 -6
  17. package/app/components/board/FailureDetail.vue +22 -12
  18. package/app/components/board/FailureHistoryList.vue +5 -5
  19. package/app/components/board/RecurringPipelineModal.vue +12 -11
  20. package/app/components/board/ReviewFrictionDialog.vue +8 -6
  21. package/app/components/board/StepExecutionHistory.vue +8 -8
  22. package/app/components/board/nodes/AgentChip.vue +1 -1
  23. package/app/components/board/nodes/BlockNode.vue +25 -24
  24. package/app/components/board/nodes/DecisionBadge.vue +4 -3
  25. package/app/components/board/nodes/EpicNode.vue +4 -4
  26. package/app/components/board/nodes/FrameSwimlanes.vue +8 -10
  27. package/app/components/board/nodes/InitiativeCard.vue +4 -4
  28. package/app/components/board/nodes/LaneGroup.vue +3 -5
  29. package/app/components/board/nodes/TaskCard.vue +20 -15
  30. package/app/components/board/nodes/TaskLane.vue +3 -3
  31. package/app/components/board/nodes/TaskPipelineMini.vue +21 -18
  32. package/app/components/bootstrap/AdoptionReviewModal.vue +36 -26
  33. package/app/components/bootstrap/BootstrapModal.vue +14 -13
  34. package/app/components/bootstrap/BootstrapRunSteps.vue +1 -1
  35. package/app/components/brainstorm/BrainstormWindow.vue +13 -10
  36. package/app/components/bugFishing/BugFishingWindow.vue +81 -58
  37. package/app/components/clarity/ClarityReviewWindow.vue +13 -10
  38. package/app/components/common/AsyncViewError.vue +1 -1
  39. package/app/components/common/ClarificationItem.vue +6 -6
  40. package/app/components/common/CopyButton.vue +12 -11
  41. package/app/components/common/DescriptorFields.vue +1 -1
  42. package/app/components/common/EmptyState.vue +1 -1
  43. package/app/components/common/IconButton.vue +34 -9
  44. package/app/components/common/InterviewGateNotice.vue +4 -4
  45. package/app/components/common/MarkdownProse.vue +8 -2
  46. package/app/components/common/SectionLabel.vue +38 -0
  47. package/app/components/consensus/ConsensusSessionWindow.vue +10 -9
  48. package/app/components/context/ContextAttachmentFields.vue +32 -20
  49. package/app/components/docs/DocInterviewWindow.vue +10 -9
  50. package/app/components/documents/ContextDocumentPicker.vue +22 -21
  51. package/app/components/documents/DocumentImportModal.vue +7 -5
  52. package/app/components/documents/DocumentOriginLink.vue +12 -9
  53. package/app/components/documents/DocumentSourceConnectModal.vue +2 -2
  54. package/app/components/documents/DocumentSyncState.vue +4 -4
  55. package/app/components/documents/DocumentTemplatesModal.vue +5 -4
  56. package/app/components/documents/RepoContextDocPicker.vue +7 -9
  57. package/app/components/documents/StartFromDesignModal.vue +4 -4
  58. package/app/components/documents/TaskContextDocs.vue +1 -1
  59. package/app/components/environments/EnvSetupStepper.vue +3 -3
  60. package/app/components/environments/EnvironmentStatusPanel.vue +15 -13
  61. package/app/components/environments/steps/EnvPreflightStep.vue +8 -8
  62. package/app/components/environments/steps/EnvReviewStep.vue +23 -22
  63. package/app/components/environments/steps/EnvSaveStep.vue +5 -5
  64. package/app/components/focus/BlockFocusView.vue +11 -10
  65. package/app/components/followUp/FollowUpWindow.vue +18 -15
  66. package/app/components/forkDecision/ForkDecisionWindow.vue +85 -97
  67. package/app/components/foundational/FoundationalContractSummary.vue +2 -2
  68. package/app/components/foundational/FoundationalServiceCatalogList.vue +6 -5
  69. package/app/components/foundational/FoundationalServiceRegistry.vue +10 -9
  70. package/app/components/foundational/FoundationalServiceSources.vue +7 -6
  71. package/app/components/foundational/FoundationalSuppressions.vue +2 -2
  72. package/app/components/fragments/FragmentLibraryManager.vue +8 -7
  73. package/app/components/fragments/FragmentSelector.vue +22 -20
  74. package/app/components/gates/GateFailingCheckList.vue +7 -6
  75. package/app/components/gates/GateResultView.vue +55 -49
  76. package/app/components/github/AddServiceFromRepoModal.vue +16 -11
  77. package/app/components/github/BranchProtectionPreflight.vue +5 -5
  78. package/app/components/github/GitHubConnect.vue +5 -3
  79. package/app/components/github/GitHubOnboarding.vue +7 -2
  80. package/app/components/github/GitHubPanel.vue +23 -19
  81. package/app/components/github/RepoTreeBrowser.vue +16 -13
  82. package/app/components/humanTest/HumanTestWindow.vue +29 -24
  83. package/app/components/initiative/InitiativePlanNotice.vue +3 -3
  84. package/app/components/initiative/InitiativePlanReview.vue +46 -38
  85. package/app/components/initiative/InitiativePlanningWindow.vue +5 -5
  86. package/app/components/initiative/InitiativeTrackerWindow.vue +213 -165
  87. package/app/components/judge/JudgeResultView.vue +28 -27
  88. package/app/components/kaizen/KaizenPanel.vue +70 -56
  89. package/app/components/kaizen/KaizenStepStatus.vue +7 -6
  90. package/app/components/layout/AccountAuditLog.vue +1 -1
  91. package/app/components/layout/AccountDeploymentSettings.vue +10 -10
  92. package/app/components/layout/AccountFailureKindRules.vue +13 -13
  93. package/app/components/layout/AccountFoundationalSettings.vue +1 -1
  94. package/app/components/layout/AccountFragmentSettings.vue +1 -1
  95. package/app/components/layout/AccountModelPolicySettings.vue +7 -7
  96. package/app/components/layout/AccountPlatformAlertSettings.vue +8 -8
  97. package/app/components/layout/AccountRiskPolicySettings.vue +2 -2
  98. package/app/components/layout/AccountRunCredentialSettings.vue +3 -3
  99. package/app/components/layout/AccountSkillSettings.vue +1 -1
  100. package/app/components/layout/AccountTeamSettings.vue +13 -9
  101. package/app/components/layout/AiProvidersBanner.vue +1 -1
  102. package/app/components/layout/AppearanceSwitcher.vue +5 -4
  103. package/app/components/layout/BoardSwitcher.vue +13 -10
  104. package/app/components/layout/BoardToolbar.vue +10 -5
  105. package/app/components/layout/CommandBar.vue +8 -5
  106. package/app/components/layout/DefaultTestEnvBanner.vue +6 -5
  107. package/app/components/layout/IntegrationsHub.vue +12 -9
  108. package/app/components/layout/LanguageSwitcher.vue +5 -4
  109. package/app/components/layout/ModelProvidersHub.vue +10 -7
  110. package/app/components/layout/NotificationsInbox.vue +47 -36
  111. package/app/components/layout/PersonalSetupModal.vue +9 -6
  112. package/app/components/layout/RolePrompt.vue +5 -4
  113. package/app/components/layout/SideBar.vue +7 -8
  114. package/app/components/layout/SpendWarningBanner.vue +3 -3
  115. package/app/components/layout/TranslationWarningBanner.vue +5 -4
  116. package/app/components/layout/UiModeSwitcher.vue +15 -12
  117. package/app/components/layout/UiRoleSwitcher.vue +10 -8
  118. package/app/components/layout/WorkspaceMembersSettings.vue +2 -2
  119. package/app/components/media/ArtifactLightbox.vue +74 -44
  120. package/app/components/media/ImageCompare.vue +80 -85
  121. package/app/components/merge/MergeEffortChips.vue +4 -6
  122. package/app/components/notifications/NotificationSettingsPanel.vue +6 -5
  123. package/app/components/observability/OutcomeFilterChips.vue +6 -5
  124. package/app/components/observability/RunFailureSummary.vue +22 -23
  125. package/app/components/observability/StepMetricsBar.vue +9 -8
  126. package/app/components/observability/StepModelActivity.vue +8 -5
  127. package/app/components/observability/ToolCallList.vue +19 -20
  128. package/app/components/outcome/OutcomeSummaryWindow.vue +48 -46
  129. package/app/components/palettes/AgentPalette.vue +21 -15
  130. package/app/components/palettes/AgentTierSelect.vue +1 -1
  131. package/app/components/palettes/PipelinePurposeSelect.vue +1 -1
  132. package/app/components/panels/AgentStepDetail.vue +57 -49
  133. package/app/components/panels/AttemptEntryHeader.vue +2 -2
  134. package/app/components/panels/DecisionModal.vue +1 -1
  135. package/app/components/panels/GenericStructuredResultView.vue +6 -5
  136. package/app/components/panels/InspectorPanel.vue +15 -13
  137. package/app/components/panels/MergerResultView.vue +10 -9
  138. package/app/components/panels/ObservabilityPanel.vue +211 -186
  139. package/app/components/panels/OperatorDashboardPanel.vue +124 -115
  140. package/app/components/panels/ReportsPanel.vue +87 -83
  141. package/app/components/panels/ReportsSpendBreakdown.vue +2 -2
  142. package/app/components/panels/ResultWindowShell.vue +152 -137
  143. package/app/components/panels/RunDetailLoadState.vue +1 -1
  144. package/app/components/panels/StepContainerStatus.vue +17 -16
  145. package/app/components/panels/StepDelegatedStatus.vue +24 -21
  146. package/app/components/panels/StepEffortReport.vue +12 -17
  147. package/app/components/panels/StepFragmentAdherence.vue +9 -10
  148. package/app/components/panels/StepMetadataCard.vue +41 -43
  149. package/app/components/panels/StepReproductionReport.vue +18 -20
  150. package/app/components/panels/StepRestartControl.vue +3 -2
  151. package/app/components/panels/StepRunMeta.vue +27 -26
  152. package/app/components/panels/StepTestReport.vue +16 -15
  153. package/app/components/panels/StepToolServers.vue +7 -8
  154. package/app/components/panels/StepValidationReport.vue +5 -5
  155. package/app/components/panels/inspector/ContainerSummary.vue +4 -4
  156. package/app/components/panels/inspector/DocReferenceRepos.vue +5 -4
  157. package/app/components/panels/inspector/EnvProbeReport.vue +16 -13
  158. package/app/components/panels/inspector/EpicChildren.vue +12 -10
  159. package/app/components/panels/inspector/FrontendBindingsResolved.vue +5 -4
  160. package/app/components/panels/inspector/FrontendConfig.vue +29 -36
  161. package/app/components/panels/inspector/InitiativeInspector.vue +3 -3
  162. package/app/components/panels/inspector/InspectorSection.vue +25 -17
  163. package/app/components/panels/inspector/RecurringScheduleSettings.vue +9 -8
  164. package/app/components/panels/inspector/ServiceConnections.vue +5 -4
  165. package/app/components/panels/inspector/ServiceReleaseHealthConfig.vue +2 -2
  166. package/app/components/panels/inspector/ServiceSelfTests.vue +9 -13
  167. package/app/components/panels/inspector/ServiceTestConfig.vue +46 -49
  168. package/app/components/panels/inspector/ServiceTestSecrets.vue +3 -3
  169. package/app/components/panels/inspector/ServiceTestingContext.vue +2 -2
  170. package/app/components/panels/inspector/ServiceValidationConfig.vue +2 -2
  171. package/app/components/panels/inspector/TaskAgentConfig.vue +2 -2
  172. package/app/components/panels/inspector/TaskAprioriBranches.vue +6 -5
  173. package/app/components/panels/inspector/TaskDependencies.vue +1 -1
  174. package/app/components/panels/inspector/TaskEstimateBadge.vue +3 -7
  175. package/app/components/panels/inspector/TaskExecution.vue +18 -16
  176. package/app/components/panels/inspector/TaskRunSettings.vue +45 -44
  177. package/app/components/panels/inspector/TaskStructure.vue +5 -4
  178. package/app/components/pipeline/AgentPromptEditor.vue +15 -14
  179. package/app/components/pipeline/BinaryOutputStepPicker.vue +42 -52
  180. package/app/components/pipeline/EstimateThresholdFields.vue +14 -11
  181. package/app/components/pipeline/GateConfigFields.vue +19 -23
  182. package/app/components/pipeline/IterationCapPrompt.vue +1 -1
  183. package/app/components/pipeline/PipelineBuilder.vue +113 -99
  184. package/app/components/pipeline/PipelineHealthModal.vue +7 -7
  185. package/app/components/pipeline/PipelinePicker.vue +11 -9
  186. package/app/components/pipeline/PipelinePreview.vue +6 -7
  187. package/app/components/pipeline/PipelineProgress.vue +49 -45
  188. package/app/components/prReview/PrReviewWindow.vue +87 -65
  189. package/app/components/providers/AiPresetMismatchDialog.vue +4 -3
  190. package/app/components/providers/AiProviderOnboardingModal.vue +2 -2
  191. package/app/components/providers/ApiKeysSection.vue +11 -9
  192. package/app/components/providers/PersonalSubscriptionSection.vue +4 -3
  193. package/app/components/providers/VendorCredentialsModal.vue +5 -4
  194. package/app/components/provisioning/ProvisioningLogsDrawer.vue +10 -9
  195. package/app/components/ralph/RalphLoopResultView.vue +29 -29
  196. package/app/components/requirements/RequirementsReviewWindow.vue +22 -17
  197. package/app/components/riskPolicy/RiskPolicyPicker.vue +13 -11
  198. package/app/components/riskPolicy/RiskPolicyPreview.vue +16 -15
  199. package/app/components/sandbox/SandboxPanel.vue +93 -73
  200. package/app/components/settings/ApiTokensPanel.vue +1 -1
  201. package/app/components/settings/BudgetSettings.vue +18 -17
  202. package/app/components/settings/CapabilityCredentialsPanel.vue +5 -5
  203. package/app/components/settings/CloudflareHandlerSection.vue +44 -41
  204. package/app/components/settings/ComposeEnvironmentSetupSection.vue +14 -12
  205. package/app/components/settings/ConnectionTestVerdict.vue +1 -1
  206. package/app/components/settings/ConsensusGroupsSection.vue +58 -47
  207. package/app/components/settings/CustomManifestTypeEditor.vue +8 -7
  208. package/app/components/settings/DefaultProvisionTypeSection.vue +5 -5
  209. package/app/components/settings/InfraHandlersConfigurator.vue +26 -24
  210. package/app/components/settings/InfrastructureBackendPicker.vue +18 -18
  211. package/app/components/settings/IssueTrackerPanel.vue +30 -21
  212. package/app/components/settings/KubernetesEngineForm.vue +9 -8
  213. package/app/components/settings/KubernetesEnvironmentForm.vue +4 -3
  214. package/app/components/settings/LocalContainerPoolSettings.vue +2 -2
  215. package/app/components/settings/LocalModelEndpointsPanel.vue +13 -11
  216. package/app/components/settings/MergeClassRulesEditor.vue +6 -5
  217. package/app/components/settings/MergeRolePolicyEditor.vue +13 -12
  218. package/app/components/settings/ModelConfigurationPanel.vue +17 -19
  219. package/app/components/settings/ModelPresetHealthModal.vue +3 -3
  220. package/app/components/settings/ObservabilityConnectionPanel.vue +1 -1
  221. package/app/components/settings/OpenRouterCatalogPanel.vue +10 -8
  222. package/app/components/settings/PackageRegistriesPanel.vue +4 -4
  223. package/app/components/settings/ProviderConnectionTab.vue +5 -4
  224. package/app/components/settings/ProviderManifestEditor.vue +9 -8
  225. package/app/components/settings/ProviderPreferenceEditor.vue +13 -13
  226. package/app/components/settings/RiskPolicyCreateForm.vue +40 -26
  227. package/app/components/settings/RiskPolicyEditorRow.vue +21 -19
  228. package/app/components/settings/RiskPolicyHealthModal.vue +3 -3
  229. package/app/components/settings/RiskPolicyPanel.vue +11 -13
  230. package/app/components/settings/ServiceFragmentDefaultsPanel.vue +15 -12
  231. package/app/components/settings/SharedStacksPanel.vue +5 -5
  232. package/app/components/settings/TaskSourceCard.vue +2 -2
  233. package/app/components/settings/TaskTypeSuppressionsPanel.vue +4 -4
  234. package/app/components/settings/ToolServerChecklist.vue +22 -22
  235. package/app/components/settings/UsageSettings.vue +13 -13
  236. package/app/components/settings/UserSecretsSection.vue +4 -3
  237. package/app/components/settings/WorkspaceMetadataSettings.vue +6 -5
  238. package/app/components/settings/WorkspaceSettingsPanel.vue +72 -58
  239. package/app/components/skills/ReviewSkillQueue.vue +12 -10
  240. package/app/components/skills/SkillLibraryManager.vue +9 -8
  241. package/app/components/slack/SlackPanel.vue +17 -12
  242. package/app/components/spec/ServiceSpecWindow.vue +19 -19
  243. package/app/components/tasks/BugHuntModal.vue +1 -1
  244. package/app/components/tasks/ContextIssuePicker.vue +16 -15
  245. package/app/components/tasks/TaskContextIssues.vue +5 -4
  246. package/app/components/tasks/TaskImportModal.vue +3 -2
  247. package/app/components/tasks/TaskSourceConnectModal.vue +5 -5
  248. package/app/components/testing/TestReportWindow.vue +67 -60
  249. package/app/components/theme/ThemeImportModal.vue +2 -2
  250. package/app/components/tutorial/TutorialNudge.vue +3 -2
  251. package/app/components/tutorial/TutorialOverlay.vue +3 -2
  252. package/app/components/vcs/GitLabConnect.vue +1 -1
  253. package/app/components/visualConfirm/VisualConfirmationWindow.vue +79 -54
  254. package/app/utils/theme/builtins.ts +7 -1
  255. package/i18n/locales/de.json +7 -3
  256. package/i18n/locales/en.json +7 -3
  257. package/i18n/locales/es.json +7 -3
  258. package/i18n/locales/fr.json +7 -3
  259. package/i18n/locales/he.json +7 -3
  260. package/i18n/locales/it.json +7 -3
  261. package/i18n/locales/ja.json +7 -3
  262. package/i18n/locales/pl.json +7 -3
  263. package/i18n/locales/tr.json +7 -3
  264. package/i18n/locales/uk.json +7 -3
  265. package/package.json +1 -1
package/README.md CHANGED
@@ -300,6 +300,36 @@ Every colour in the SPA is a token that follows the THEME and the colour MODE. T
300
300
  - **Never** a raw Tailwind hue (`bg-slate-900`, `text-amber-300`, `text-pink-300`) nor a fixed numbered alias (`text-primary-400`, `bg-warning-950/40`): the first is welded to one palette and one mode, the second follows the palette but not the mode. `scripts/check-frontend-palette.mjs` (CI) bans both. `red` and `rose` both mean `error`.
301
301
  - **Never** a fixed `white` / `black` utility (`bg-white`, `text-black`, `border-white/5`, `bg-white/[0.02]`): invisible or wrong in light mode. Use `border-inverted` (a selection ring), `border-default` / `bg-muted` (faint chrome) or `text-inverted` (text over a filled surface). A line that genuinely needs a fixed value says why with a `fixed-colour-ok:` comment, on that line or the one above it.
302
302
 
303
+ ### Type through named steps, never a pixel literal
304
+
305
+ Every font size in the SPA is a NAMED step, so it scales with the theme and lines up with the sizes Nuxt UI gives its own components. Two sources of steps:
306
+
307
+ - **Tailwind's own** from `text-xs` up (`text-xs`, `text-sm`, `text-base`, ...), each paired with a line height.
308
+ - **The app's two steps below them** (`app/assets/css/type.css`), for the dense surfaces Tailwind's scale bottoms out above: `text-2xs` (0.6875rem, 11px at the default root) for the dense body size that panel rows, node metadata and inline detail are written at, and `text-3xs` (0.625rem, 10px) for the caption tier below it: pill and badge text on a board node, a quoted source excerpt, an axis or legend caption. The section eyebrow is `text-2xs`, not `text-3xs`, and is not written by hand at all (see the last bullet). Neither declares a paired LINE HEIGHT: dense text inherits its rhythm from the surrounding block, and a block that wants a specific rhythm says `leading-*`, which wins over a step's paired value anyway.
309
+ - **Never** a literal size (`text-[11px]`, `text-[0.6875rem]`). A theme document's `fontSize` is applied as a root `font-size` (`utils/theme/css.ts`), so every rem step follows it and a pixel literal does not: raising the font size would grow Nuxt UI's components while the app's own text sat still. A literal is also unnameable, so a `UBadge size="sm"` beside one is sized by a system the label cannot refer to. `scripts/check-frontend-type-scale.mjs` (CI) bans the utility forms (`text-[length:11px]`, `[font-size:11px]`, `[font:…]`), a px declaration in CSS (`font-size: 11px`, the `font:` shorthand) and a px JS style property (`fontSize: '11px'`, `style.fontSize = …`); its header lists exactly what it matches. A line that genuinely needs one says why with a `type-literal-ok:` comment, on that line or the one above.
310
+ - **A section eyebrow is [`common/SectionLabel.vue`](./app/components/common/SectionLabel.vue)**, not a hand-written class list. The label that titles a group inside a panel, window, inspector section or dropdown is one recipe, `text-2xs font-semibold uppercase tracking-wide text-muted`, and the component owns all five: they are not overridable, because a caller wanting a different size or colour wants a different thing. The caller picks the ELEMENT with `as` (`div` by default; `h3` / `h4` for a real heading, `label` with an `:id` for a field; a collapsing section is a `UButton` around `as="span"`) and owns SPACING through the ordinary `class` attribute. An ACCENT-coloured uppercase label (`text-primary`, `text-app-info-400`) is not this component: it is signalling state or category rather than titling a section, so it keeps its own classes. Nor is a pill, a chip, a metadata row that merely shares the uppercase styling (`BlockNode.vue`'s composition line), or a caption inside a control (a switcher trigger's label line, a slider's end labels): the test is whether the text TITLES the block after it. The colour is `text-muted` and not the fainter `text-dimmed` for contrast: at 11px `text-dimmed` is 2.63:1 on a light surface, under WCAG AA, where `text-muted` is 4.76:1. (The SPA's wider use of `text-dimmed` outside this component is a separate problem.)
311
+
312
+ ### Radius through the theme scale
313
+
314
+ Every `rounded-*` utility in the SPA derives from `--ui-radius`, so a theme document's `radius` reaches all of them. Nuxt UI rebuilds seven of Tailwind's steps on it (`rounded-xs` is half it, `rounded-sm` is it, then 1.5x, 2x, 3x, 4x and 6x), and [`main.css`](./app/assets/css/main.css) binds the two spellings that sat outside that: `--radius`, which the bare `rounded` alias and its side and corner forms resolve through, and `--radius-4xl`. Tailwind declares both `@theme default`, so the app's own `@theme inline` block wins, and `inline` is what makes the utility compile to `var(--ui-radius)` rather than to a value frozen at build time. Neither number moves on the default theme.
315
+
316
+ That is the whole mechanism, and it is why there is no rule here about WHICH step to pick. Two things still name a number instead of the scale:
317
+
318
+ - **Never an arbitrary value**: `rounded-[10px]`, `rounded-t-[0.6rem]`, or Tailwind v4's CSS-variable shorthand `rounded-(--x)`. Welded to one number the way a pixel font size is, and unnameable besides, so a `UBadge` beside one is shaped by a system the box cannot refer to. The SPA has none.
319
+ - **In CSS, reach the scale through `var(--ui-radius)`**, on its own or inside a `calc()`: `calc(var(--ui-radius) * 2)` is the `rounded-lg` step, and `calc(var(--ui-radius) - 1px)` is an inner box kept concentric with its parent, which follows the theme just as well. **Never a `--radius-*` theme variable**, even though one works today: Tailwind declares that scale in a `@theme default inline` block and emits a variable only where the COMPILED stylesheet graph references it, so `var(--radius-lg)` is correct until the last other reference is deleted and then silently computes to 0. `--ui-radius` is an ordinary custom property on `:root`, so it is there in a scoped SFC `<style>` block too, where the `--radius-*` scale never is. **Never a raw literal** in px, pt, rem or em, the per-corner longhands (`border-top-left-radius`, the logical `border-start-start-radius`) and the JS `borderRadius` / `'border-radius'` spelling a `:style` binding uses included. The pill spellings (`9999px`, `50%`) and `0` are exempt, and a shorthand is read value by value, so `50% 50% 8px 8px` is still caught on the `8px`.
320
+ - `scripts/check-frontend-radius.mjs` (CI) bans both. It needs no notion of where a class list lives, because `rounded-[` and `rounded-(` are not English words. A line that genuinely needs a fixed radius says why with a `radius-literal-ok:` comment, on that line or the one above.
321
+
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
+
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
+
303
333
  ### Themes and colour mode
304
334
 
305
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).
@@ -354,8 +384,8 @@ protects, so the two never contradict silently:
354
384
  freely; here a failed backend call is reported with `usePipelineErrorToast().present(error, key)`.
355
385
  [Rule](#every-failure-toast-goes-through-one-funnel).
356
386
  - **Icon-only buttons carry an accessible name.** Use `common/IconButton.vue`
357
- ([source](./app/components/common/IconButton.vue)), which applies `label` as both `title` and
358
- `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>`.
359
389
  - **Colour goes through theme tokens, never a fixed palette shade.** The skill bans raw Tailwind
360
390
  hues; this repo also bans fixed numbered aliases, enforced by
361
391
  [`scripts/check-frontend-palette.mjs`](https://github.com/kibertoad/cat-factory/blob/main/scripts/check-frontend-palette.mjs).
@@ -6,6 +6,28 @@
6
6
  /* The app's own mode-adaptive colour tokens (`app-*`), layered on Nuxt UI's. */
7
7
  @import './tokens.css';
8
8
 
9
+ /* The app's own type steps below `text-xs` (`text-2xs`, `text-3xs`). */
10
+ @import './type.css';
11
+
12
+ /* Bind the last two radius steps Nuxt UI leaves alone onto `--ui-radius`, so EVERY `rounded-*`
13
+ * utility follows a theme document's `radius` (issue #2251).
14
+ *
15
+ * Nuxt UI rebuilds seven of Tailwind's steps on `--ui-radius` (`--radius-sm` is it, `--radius-lg`
16
+ * is twice it, and so on), which is why a hand-written box on a named step already lines up with
17
+ * the Nuxt UI component beside it. Two spellings sat outside that: the bare `rounded` alias, whose
18
+ * `--radius` Tailwind declares `@theme default inline reference` and so inlines as a literal
19
+ * 0.25rem, and `rounded-4xl`, whose `--radius-4xl` keeps Tailwind's own 2rem. Both are `default`,
20
+ * so declaring them here wins, and `inline` is what lets `rounded` compile to `var(--ui-radius)`
21
+ * rather than to a value frozen at build time.
22
+ *
23
+ * Neither number moves on the default theme: `--ui-radius` is 0.25rem, and 8x it is the 2rem
24
+ * `--radius-4xl` already held. Declaring them here also EMITS both as ordinary custom properties,
25
+ * where Tailwind's `reference` kept `--radius` out of the stylesheet entirely. */
26
+ @theme inline {
27
+ --radius: var(--ui-radius);
28
+ --radius-4xl: calc(var(--ui-radius) * 8);
29
+ }
30
+
9
31
  /* The two toaster rules below are scoped by the `app-toaster` marker class that
10
32
  * `app.config.ts` puts on the toaster's viewport slot, NOT by `[data-slot='viewport']`.
11
33
  * That attribute is Nuxt UI's name for the scroll region of ELEVEN components
@@ -17,7 +17,7 @@
17
17
  .reader-prose.review-mode [data-src-start] {
18
18
  position: relative;
19
19
  cursor: pointer;
20
- border-radius: 0.375rem;
20
+ border-radius: calc(var(--ui-radius) * 1.5);
21
21
  transition: background 0.12s ease;
22
22
  }
23
23
  .reader-prose.review-mode [data-src-start]:hover {
@@ -34,7 +34,7 @@
34
34
  width: 1.1rem;
35
35
  align-items: center;
36
36
  justify-content: center;
37
- border-radius: 0.25rem;
37
+ border-radius: var(--ui-radius);
38
38
  background: var(--ui-primary);
39
39
  color: var(--ui-text-inverted);
40
40
  font-size: 0.8rem;
@@ -81,7 +81,7 @@
81
81
  font-style: italic;
82
82
  }
83
83
  .reader-prose code {
84
- border-radius: 0.25rem;
84
+ border-radius: var(--ui-radius);
85
85
  background: var(--ui-bg-elevated);
86
86
  padding: 0.1rem 0.3rem;
87
87
  font-family: var(--font-mono);
@@ -91,7 +91,7 @@
91
91
  .reader-prose pre {
92
92
  margin: 0.6rem 0;
93
93
  overflow: auto;
94
- border-radius: 0.5rem;
94
+ border-radius: calc(var(--ui-radius) * 2);
95
95
  background: var(--app-bg-canvas);
96
96
  padding: 0.75rem 0.9rem;
97
97
  }
@@ -0,0 +1,24 @@
1
+ /* The app's own type steps below Tailwind's `text-xs` (issue #2248).
2
+ *
3
+ * Both are rem, so both scale with a theme document's `fontSize`, which `utils/theme/css.ts` applies
4
+ * as the root `font-size`:
5
+ *
6
+ * --text-2xs 0.6875rem (11px at the 16px default) the dense body size: panel rows, node
7
+ * metadata, inline detail, and the section
8
+ * eyebrow (`common/SectionLabel.vue`).
9
+ * --text-3xs 0.625rem (10px) the caption tier below the eyebrow: pill and
10
+ * badge text on a board node, a quoted source
11
+ * excerpt, axis and legend captions.
12
+ *
13
+ * Neither step declares a line height. Tailwind pairs `--text-<n>` with an optional
14
+ * `--text-<n>--line-height`, and when one exists the utility sets both properties. Dense text here
15
+ * inherits its rhythm from the surrounding block; a block that wants a specific rhythm says so with
16
+ * `leading-*`.
17
+ *
18
+ * `scripts/check-frontend-type-scale.mjs` bans font-size literals, so these two and Tailwind's own
19
+ * steps are the whole scale. */
20
+
21
+ @theme {
22
+ --text-2xs: 0.6875rem;
23
+ --text-3xs: 0.625rem;
24
+ }
@@ -22,6 +22,7 @@
22
22
  // the retry belongs.
23
23
  import type { AssistantActionId, AssistantOutcome } from '~/types/domain'
24
24
  import { answerFor, assistantSurface, revealTarget, submitGate } from './AssistantModal.logic'
25
+ import SectionLabel from '~/components/common/SectionLabel.vue'
25
26
 
26
27
  const { t } = useI18n()
27
28
  const ui = useUiStore()
@@ -254,20 +255,21 @@ function reveal(blockId: string): void {
254
255
 
255
256
  <!-- What it can do, always visible: the catalog is the affordance. -->
256
257
  <div v-if="examples.length" class="space-y-2">
257
- <p class="text-xs font-medium uppercase tracking-wide text-dimmed">
258
+ <SectionLabel as="p">
258
259
  {{ t('assistant.examplesTitle') }}
259
- </p>
260
+ </SectionLabel>
260
261
  <div class="flex flex-col gap-1">
261
- <button
262
+ <UButton
263
+ color="neutral"
264
+ variant="ghost"
262
265
  v-for="entry in examples"
263
266
  :key="entry.actionId"
264
- type="button"
265
- 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"
266
268
  @click="useExample(entry.example)"
267
269
  >
268
270
  <span class="text-muted">{{ entry.label }}</span>
269
271
  <span class="block text-xs text-dimmed">“{{ entry.example }}”</span>
270
- </button>
272
+ </UButton>
271
273
  </div>
272
274
  </div>
273
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"
@@ -50,9 +51,9 @@ const items = computed<DropdownMenuItem[][]>(() => [
50
51
  <div class="truncate text-xs font-medium text-highlighted">
51
52
  {{ auth.user.name || auth.user.login }}
52
53
  </div>
53
- <div class="truncate text-[10px] text-dimmed">@{{ auth.user.login }}</div>
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>
@@ -202,7 +202,7 @@ const { requestClose } = useUnsavedGuard({
202
202
  declared its candidates and one whose block was unreadable need different fixes. -->
203
203
  <p
204
204
  v-if="noChoiceKey"
205
- class="mb-3 rounded border border-app-warning-500/30 bg-app-warning-500/5 px-3 py-2 text-xs text-app-warning-200"
205
+ class="mb-3 rounded-sm border border-app-warning-500/30 bg-app-warning-500/5 px-3 py-2 text-xs text-app-warning-200"
206
206
  data-testid="binary-candidates-no-choice"
207
207
  >
208
208
  {{ t(noChoiceKey) }}
@@ -211,7 +211,7 @@ const { requestClose } = useUnsavedGuard({
211
211
  <!-- An automatic keep is NOT a review, and must never render as one. -->
212
212
  <p
213
213
  v-if="view.automatic"
214
- class="mb-3 rounded border border-app-600/40 bg-elevated/40 px-3 py-2 text-xs text-toned"
214
+ class="mb-3 rounded-sm border border-app-600/40 bg-elevated/40 px-3 py-2 text-xs text-toned"
215
215
  data-testid="binary-candidates-automatic"
216
216
  >
217
217
  {{ t('binaryCandidates.automatic') }}
@@ -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') }}
@@ -243,7 +253,7 @@ const { requestClose } = useUnsavedGuard({
243
253
  <div
244
254
  v-for="row in group.rows"
245
255
  :key="row.id"
246
- class="rounded border p-2 transition"
256
+ class="rounded-sm border p-2 transition"
247
257
  :class="[
248
258
  selected.includes(row.id) || row.kept
249
259
  ? 'border-app-info-400/60 bg-app-info-500/5'
@@ -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-[11px] 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
@@ -297,14 +308,14 @@ const { requestClose } = useUnsavedGuard({
297
308
  v-else-if="row.previewUrl"
298
309
  :src="row.previewUrl"
299
310
  :alt="row.label ?? row.id"
300
- class="mb-2 max-h-56 w-full rounded object-contain"
311
+ class="mb-2 max-h-56 w-full rounded-sm object-contain"
301
312
  data-testid="binary-candidate-preview"
302
313
  />
303
314
  <!-- No preview is ORDINARY (a private asset store issues no link), so it is stated
304
315
  rather than left as an empty frame the reader reads as a failed generation. -->
305
316
  <p
306
317
  v-else
307
- class="mb-2 flex h-24 items-center justify-center rounded bg-elevated/60 px-2 text-center text-[10px] text-muted"
318
+ class="mb-2 flex h-24 items-center justify-center rounded-sm bg-elevated/60 px-2 text-center text-3xs text-muted"
308
319
  data-testid="binary-candidate-no-preview"
309
320
  >
310
321
  {{ t('binaryCandidates.noPreview') }}
@@ -316,10 +327,10 @@ const { requestClose } = useUnsavedGuard({
316
327
  : t('binaryCandidates.unattributed')
317
328
  }}
318
329
  </p>
319
- <p v-if="row.note" class="mt-1 text-[11px] text-muted">{{ row.note }}</p>
320
- <p class="mt-1 break-all text-[10px] text-dimmed">{{ row.location }}</p>
321
- <p v-if="row.contentType" class="text-[10px] text-dimmed">{{ row.contentType }}</p>
322
- <p v-if="row.kept" class="mt-1 text-[11px] text-app-success-300">
330
+ <p v-if="row.note" class="mt-1 text-2xs text-muted">{{ row.note }}</p>
331
+ <p class="mt-1 break-all text-3xs text-dimmed">{{ row.location }}</p>
332
+ <p v-if="row.contentType" class="text-3xs text-dimmed">{{ row.contentType }}</p>
333
+ <p v-if="row.kept" class="mt-1 text-2xs text-app-success-300">
323
334
  {{
324
335
  row.storeAs
325
336
  ? t('binaryCandidates.keptAs', { id: row.storeAs })
@@ -352,19 +363,19 @@ const { requestClose } = useUnsavedGuard({
352
363
  />
353
364
  <p
354
365
  v-if="missingAliases.length"
355
- class="mt-1 text-[11px] text-app-warning-300"
366
+ class="mt-1 text-2xs text-app-warning-300"
356
367
  data-testid="binary-candidates-missing-alias"
357
368
  >
358
369
  {{ t('binaryCandidates.missingAlias') }}
359
370
  </p>
360
371
  <p
361
372
  v-else-if="duplicateAliases"
362
- class="mt-1 text-[11px] text-app-warning-300"
373
+ class="mt-1 text-2xs text-app-warning-300"
363
374
  data-testid="binary-candidates-duplicate-alias"
364
375
  >
365
376
  {{ t('binaryCandidates.duplicateAlias') }}
366
377
  </p>
367
- <p v-if="candidates.error" class="mt-1 text-[11px] text-app-error-300">
378
+ <p v-if="candidates.error" class="mt-1 text-2xs text-app-error-300">
368
379
  {{ candidates.error }}
369
380
  </p>
370
381
  <div class="mt-2 flex justify-end">
@@ -395,7 +406,7 @@ const { requestClose } = useUnsavedGuard({
395
406
  >
396
407
  <UIcon name="i-lucide-unlink" class="h-8 w-8 opacity-40" />
397
408
  <p class="text-sm">{{ t('binaryCandidates.noRun.title') }}</p>
398
- <p class="max-w-md text-[11px] text-dimmed">{{ t('binaryCandidates.noRun.hint') }}</p>
409
+ <p class="max-w-md text-2xs text-dimmed">{{ t('binaryCandidates.noRun.hint') }}</p>
399
410
  </div>
400
411
  <div
401
412
  v-else-if="absence === 'loading'"
@@ -411,7 +422,7 @@ const { requestClose } = useUnsavedGuard({
411
422
  >
412
423
  <UIcon name="i-lucide-triangle-alert" class="h-8 w-8 text-app-warning-400/70" />
413
424
  <p class="text-sm">{{ t('binaryCandidates.loadFailed') }}</p>
414
- <p class="max-w-md break-words text-[11px] text-dimmed">{{ candidates.error }}</p>
425
+ <p class="max-w-md break-words text-2xs text-dimmed">{{ candidates.error }}</p>
415
426
  <UButton
416
427
  size="xs"
417
428
  color="neutral"