@cat-factory/app 0.302.2 → 0.303.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 (328) hide show
  1. package/README.md +16 -0
  2. package/app/app.config.ts +19 -10
  3. package/app/assets/css/main.css +13 -13
  4. package/app/assets/css/prose.css +22 -22
  5. package/app/assets/css/tokens.css +298 -0
  6. package/app/components/assistant/AssistantModal.vue +22 -22
  7. package/app/components/auth/AuthGate.vue +1 -1
  8. package/app/components/auth/BackendMisconfiguredScreen.vue +10 -10
  9. package/app/components/auth/LoginScreen.vue +34 -36
  10. package/app/components/auth/ResetPasswordScreen.vue +12 -12
  11. package/app/components/auth/UserMenu.vue +4 -4
  12. package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +27 -25
  13. package/app/components/binaryOutput/BinaryOutputReport.vue +15 -15
  14. package/app/components/binaryOutput/StoredAssetView.vue +5 -5
  15. package/app/components/board/AddTaskModal.vue +17 -20
  16. package/app/components/board/AgentFailureCard.vue +11 -11
  17. package/app/components/board/AgentFailureHistory.vue +2 -2
  18. package/app/components/board/BoardCanvas.vue +6 -5
  19. package/app/components/board/CreateInitiativeModal.vue +8 -10
  20. package/app/components/board/DependencyConnectOverlay.vue +1 -1
  21. package/app/components/board/FailureHistoryList.vue +7 -7
  22. package/app/components/board/RecurringPipelineModal.vue +17 -17
  23. package/app/components/board/ReviewFrictionDialog.vue +6 -6
  24. package/app/components/board/StepExecutionHistory.vue +13 -13
  25. package/app/components/board/TaskDependencyEdges.vue +8 -8
  26. package/app/components/board/nodes/AgentChip.vue +10 -10
  27. package/app/components/board/nodes/BlockNode.vue +29 -29
  28. package/app/components/board/nodes/DecisionBadge.vue +2 -2
  29. package/app/components/board/nodes/EpicNode.vue +14 -10
  30. package/app/components/board/nodes/FrameSwimlanes.vue +7 -9
  31. package/app/components/board/nodes/InitiativeCard.vue +8 -8
  32. package/app/components/board/nodes/LaneGroup.vue +1 -1
  33. package/app/components/board/nodes/LaneTask.vue +2 -2
  34. package/app/components/board/nodes/ResizeGrips.vue +4 -4
  35. package/app/components/board/nodes/TaskCard.vue +18 -18
  36. package/app/components/board/nodes/TaskLane.vue +5 -5
  37. package/app/components/board/nodes/TaskPipelineMini.vue +18 -18
  38. package/app/components/bootstrap/AdoptionReviewModal.vue +29 -26
  39. package/app/components/bootstrap/BootstrapModal.vue +21 -23
  40. package/app/components/bootstrap/BootstrapRunSteps.vue +1 -1
  41. package/app/components/brainstorm/BrainstormWindow.vue +30 -33
  42. package/app/components/bugFishing/BugFishingWindow.vue +49 -49
  43. package/app/components/clarity/ClarityReviewWindow.vue +30 -30
  44. package/app/components/common/AsyncViewError.vue +5 -5
  45. package/app/components/common/ClarificationItem.vue +8 -13
  46. package/app/components/common/ConfirmDialog.vue +4 -2
  47. package/app/components/common/DescriptorFields.vue +1 -1
  48. package/app/components/common/EmptyState.vue +3 -3
  49. package/app/components/common/InterviewGateNotice.vue +7 -7
  50. package/app/components/common/KeyboardShortcutsHelp.vue +1 -1
  51. package/app/components/common/MarkdownProse.vue +14 -13
  52. package/app/components/consensus/ConsensusSessionWindow.vue +26 -26
  53. package/app/components/context/ContextAttachmentFields.vue +17 -17
  54. package/app/components/docs/DocInterviewWindow.vue +14 -14
  55. package/app/components/documents/ContextDocumentPicker.vue +17 -17
  56. package/app/components/documents/DocumentImportModal.vue +7 -7
  57. package/app/components/documents/DocumentSourceConnectModal.vue +3 -3
  58. package/app/components/documents/DocumentSyncState.vue +5 -5
  59. package/app/components/documents/DocumentTemplatesModal.vue +15 -15
  60. package/app/components/documents/RepoContextDocPicker.vue +5 -5
  61. package/app/components/documents/SpawnPreviewModal.vue +12 -12
  62. package/app/components/documents/StartFromDesignModal.vue +11 -11
  63. package/app/components/documents/TaskContextDocs.vue +5 -5
  64. package/app/components/environments/EnvSetupStepper.vue +6 -6
  65. package/app/components/environments/EnvironmentStatusPanel.vue +17 -17
  66. package/app/components/environments/steps/EnvPickStep.vue +3 -3
  67. package/app/components/environments/steps/EnvPreflightStep.vue +9 -9
  68. package/app/components/environments/steps/EnvReviewStep.vue +25 -22
  69. package/app/components/environments/steps/EnvSaveStep.vue +7 -5
  70. package/app/components/environments/steps/JourneyStepNav.vue +1 -1
  71. package/app/components/focus/BlockFocusView.vue +17 -19
  72. package/app/components/followUp/FollowUpWindow.vue +23 -23
  73. package/app/components/forkDecision/ForkDecisionWindow.vue +37 -39
  74. package/app/components/foundational/FoundationalContractSummary.vue +7 -7
  75. package/app/components/foundational/FoundationalServiceCatalogList.vue +11 -11
  76. package/app/components/foundational/FoundationalServiceManager.vue +1 -1
  77. package/app/components/foundational/FoundationalServiceRegistry.vue +13 -13
  78. package/app/components/foundational/FoundationalServiceSources.vue +13 -13
  79. package/app/components/foundational/FoundationalSuppressions.vue +7 -7
  80. package/app/components/foundational/ServiceCatalogConnection.vue +10 -10
  81. package/app/components/fragments/FragmentLibraryManager.vue +41 -44
  82. package/app/components/fragments/FragmentSelector.vue +13 -13
  83. package/app/components/fragments/GitHubDocUrlImport.vue +1 -1
  84. package/app/components/gates/GateFailingCheckList.vue +5 -5
  85. package/app/components/gates/GateResultView.vue +50 -47
  86. package/app/components/github/AddServiceFromRepoModal.vue +14 -14
  87. package/app/components/github/BranchProtectionPreflight.vue +13 -13
  88. package/app/components/github/GitHubConnect.vue +7 -7
  89. package/app/components/github/GitHubOnboarding.vue +7 -7
  90. package/app/components/github/GitHubPanel.vue +37 -40
  91. package/app/components/github/RepoTreeBrowser.vue +18 -18
  92. package/app/components/humanTest/HumanTestWindow.vue +30 -33
  93. package/app/components/initiative/InitiativePlanNotice.vue +5 -5
  94. package/app/components/initiative/InitiativePlanReview.vue +27 -29
  95. package/app/components/initiative/InitiativePlanningWindow.vue +10 -10
  96. package/app/components/initiative/InitiativeTrackerWindow.vue +71 -65
  97. package/app/components/inputGate/InputGateNotice.vue +2 -4
  98. package/app/components/judge/JudgeResultView.vue +30 -32
  99. package/app/components/kaizen/KaizenPanel.vue +40 -36
  100. package/app/components/kaizen/KaizenStepStatus.vue +21 -21
  101. package/app/components/layout/AccountAuditLog.vue +10 -10
  102. package/app/components/layout/AccountDeploymentSettings.vue +20 -20
  103. package/app/components/layout/AccountFailureKindRules.vue +13 -13
  104. package/app/components/layout/AccountFoundationalSettings.vue +1 -1
  105. package/app/components/layout/AccountFragmentSettings.vue +1 -1
  106. package/app/components/layout/AccountModelPolicySettings.vue +9 -9
  107. package/app/components/layout/AccountPlatformAlertSettings.vue +11 -11
  108. package/app/components/layout/AccountRiskPolicySettings.vue +2 -2
  109. package/app/components/layout/AccountRunCredentialSettings.vue +5 -5
  110. package/app/components/layout/AccountSkillSettings.vue +1 -1
  111. package/app/components/layout/AccountTeamSettings.vue +23 -15
  112. package/app/components/layout/AiProvidersBanner.vue +11 -8
  113. package/app/components/layout/AppearanceSwitcher.vue +108 -0
  114. package/app/components/layout/BoardSwitcher.vue +8 -11
  115. package/app/components/layout/BoardToolbar.vue +3 -3
  116. package/app/components/layout/CommandBar.vue +7 -9
  117. package/app/components/layout/ConnectionStatusBanner.vue +2 -2
  118. package/app/components/layout/DefaultTestEnvBanner.vue +5 -5
  119. package/app/components/layout/GitHubPatBanner.vue +5 -5
  120. package/app/components/layout/GitHubPatPermissionsBanner.vue +16 -10
  121. package/app/components/layout/InfraSetupBanner.vue +6 -6
  122. package/app/components/layout/IntegrationsHub.vue +15 -15
  123. package/app/components/layout/LanguageSwitcher.vue +5 -5
  124. package/app/components/layout/ModelProvidersHub.vue +13 -16
  125. package/app/components/layout/NotificationsInbox.vue +10 -10
  126. package/app/components/layout/PersonalSetupModal.vue +8 -8
  127. package/app/components/layout/ProviderConfigBanner.vue +4 -4
  128. package/app/components/layout/RolePrompt.vue +7 -7
  129. package/app/components/layout/SideBar.vue +6 -4
  130. package/app/components/layout/SpendWarningBanner.vue +17 -14
  131. package/app/components/layout/TranslationWarningBanner.vue +4 -4
  132. package/app/components/layout/UiModeSwitcher.vue +13 -13
  133. package/app/components/layout/UiRoleSwitcher.vue +8 -8
  134. package/app/components/layout/WorkspaceMembersSettings.vue +15 -11
  135. package/app/components/media/ArtifactLightbox.vue +13 -13
  136. package/app/components/media/ImageCompare.vue +31 -24
  137. package/app/components/merge/MergeEffortChips.vue +2 -2
  138. package/app/components/notifications/NotificationSettingsPanel.vue +14 -12
  139. package/app/components/observability/OutcomeFilterChips.vue +6 -6
  140. package/app/components/observability/RunFailureSummary.vue +28 -25
  141. package/app/components/observability/StepMetricsBar.vue +26 -28
  142. package/app/components/observability/StepModelActivity.vue +2 -2
  143. package/app/components/observability/ToolCallList.vue +24 -24
  144. package/app/components/outcome/OutcomeSummaryWindow.vue +47 -47
  145. package/app/components/palettes/AgentPalette.vue +8 -8
  146. package/app/components/palettes/AgentTierSelect.vue +1 -1
  147. package/app/components/palettes/PipelinePurposeSelect.vue +1 -1
  148. package/app/components/panels/AgentStepDetail.vue +52 -51
  149. package/app/components/panels/AttemptEntryHeader.vue +4 -6
  150. package/app/components/panels/DecisionModal.vue +7 -7
  151. package/app/components/panels/GenericStructuredResultView.vue +7 -7
  152. package/app/components/panels/InspectorPanel.vue +17 -17
  153. package/app/components/panels/MergerResultView.vue +19 -19
  154. package/app/components/panels/ObservabilityPanel.vue +115 -123
  155. package/app/components/panels/OperatorDashboardPanel.vue +89 -97
  156. package/app/components/panels/ReportsPanel.vue +69 -66
  157. package/app/components/panels/ReportsSpendBreakdown.vue +9 -9
  158. package/app/components/panels/ResultWindowShell.vue +38 -38
  159. package/app/components/panels/RunDetailLoadState.vue +2 -2
  160. package/app/components/panels/StepContainerStatus.vue +9 -9
  161. package/app/components/panels/StepDelegatedStatus.logic.spec.ts +135 -0
  162. package/app/components/panels/StepDelegatedStatus.logic.ts +160 -0
  163. package/app/components/panels/StepDelegatedStatus.vue +181 -0
  164. package/app/components/panels/StepEffortReport.vue +16 -15
  165. package/app/components/panels/StepFragmentAdherence.vue +14 -10
  166. package/app/components/panels/StepMetadataCard.vue +74 -45
  167. package/app/components/panels/StepReproductionReport.vue +25 -21
  168. package/app/components/panels/StepRunMeta.vue +20 -20
  169. package/app/components/panels/StepTestReport.logic.spec.ts +8 -3
  170. package/app/components/panels/StepTestReport.logic.ts +4 -4
  171. package/app/components/panels/StepTestReport.vue +28 -28
  172. package/app/components/panels/StepToolServers.vue +14 -11
  173. package/app/components/panels/StepValidationReport.vue +7 -7
  174. package/app/components/panels/inspector/ContainerSummary.vue +9 -9
  175. package/app/components/panels/inspector/DocReferenceRepos.vue +3 -3
  176. package/app/components/panels/inspector/EnvProbeReport.vue +24 -27
  177. package/app/components/panels/inspector/EpicChildren.vue +8 -12
  178. package/app/components/panels/inspector/FrontendBindingsResolved.vue +11 -11
  179. package/app/components/panels/inspector/FrontendConfig.vue +37 -41
  180. package/app/components/panels/inspector/InitiativeInspector.vue +3 -3
  181. package/app/components/panels/inspector/InspectorSection.vue +6 -6
  182. package/app/components/panels/inspector/RecurringScheduleSettings.vue +16 -19
  183. package/app/components/panels/inspector/ServiceConnections.vue +2 -2
  184. package/app/components/panels/inspector/ServiceReleaseHealthConfig.vue +2 -2
  185. package/app/components/panels/inspector/ServiceSelfTests.vue +21 -17
  186. package/app/components/panels/inspector/ServiceTestConfig.vue +51 -59
  187. package/app/components/panels/inspector/ServiceTestSecrets.vue +5 -5
  188. package/app/components/panels/inspector/ServiceTestingContext.vue +3 -3
  189. package/app/components/panels/inspector/ServiceValidationConfig.vue +2 -2
  190. package/app/components/panels/inspector/TaskAgentConfig.vue +3 -3
  191. package/app/components/panels/inspector/TaskAprioriBranches.vue +8 -5
  192. package/app/components/panels/inspector/TaskDependencies.vue +1 -1
  193. package/app/components/panels/inspector/TaskEstimateBadge.vue +11 -11
  194. package/app/components/panels/inspector/TaskExecution.vue +17 -17
  195. package/app/components/panels/inspector/TaskReviewTarget.vue +2 -2
  196. package/app/components/panels/inspector/TaskRunSettings.vue +35 -37
  197. package/app/components/panels/inspector/TaskStructure.vue +3 -3
  198. package/app/components/pipeline/AgentKindIcon.vue +1 -1
  199. package/app/components/pipeline/AgentPromptEditor.vue +17 -17
  200. package/app/components/pipeline/BinaryOutputStepPicker.vue +42 -56
  201. package/app/components/pipeline/EstimateThresholdFields.vue +4 -4
  202. package/app/components/pipeline/GateConfigFields.vue +13 -10
  203. package/app/components/pipeline/IterationCapPrompt.vue +4 -2
  204. package/app/components/pipeline/PipelineBuilder.vue +47 -55
  205. package/app/components/pipeline/PipelineHealthModal.vue +27 -29
  206. package/app/components/pipeline/PipelinePicker.vue +10 -10
  207. package/app/components/pipeline/PipelinePreview.vue +10 -10
  208. package/app/components/pipeline/PipelineProgress.vue +64 -67
  209. package/app/components/prReview/PrReviewPhaseBadge.vue +7 -7
  210. package/app/components/prReview/PrReviewWindow.vue +75 -77
  211. package/app/components/providers/AiPresetMismatchDialog.vue +5 -5
  212. package/app/components/providers/AiProviderOnboardingModal.vue +9 -9
  213. package/app/components/providers/ApiKeysSection.vue +13 -13
  214. package/app/components/providers/PersonalCredentialModal.vue +2 -2
  215. package/app/components/providers/PersonalSubscriptionSection.vue +10 -10
  216. package/app/components/providers/SignInRequiredNotice.vue +1 -1
  217. package/app/components/providers/VendorCredentialsModal.vue +10 -10
  218. package/app/components/provisioning/ProvisioningLogsDrawer.vue +13 -13
  219. package/app/components/ralph/RalphLoopResultView.vue +28 -30
  220. package/app/components/recurring/RecurrenceEditor.vue +2 -2
  221. package/app/components/requirements/RequirementsReviewWindow.vue +51 -58
  222. package/app/components/riskPolicy/RiskPolicyPicker.vue +12 -15
  223. package/app/components/riskPolicy/RiskPolicyPreview.vue +14 -14
  224. package/app/components/sandbox/SandboxPanel.vue +58 -59
  225. package/app/components/settings/AccountSettingsPanel.vue +1 -1
  226. package/app/components/settings/ApiTokensPanel.vue +10 -10
  227. package/app/components/settings/BudgetSettings.vue +18 -22
  228. package/app/components/settings/CapabilityCredentialsPanel.vue +15 -15
  229. package/app/components/settings/CloudflareHandlerSection.vue +20 -20
  230. package/app/components/settings/ComposeEnvironmentSetupSection.vue +13 -13
  231. package/app/components/settings/ConnectionTestVerdict.vue +3 -3
  232. package/app/components/settings/ConnectionWarnings.vue +1 -1
  233. package/app/components/settings/ConsensusGroupsSection.vue +27 -35
  234. package/app/components/settings/CustomManifestTypeEditor.vue +10 -10
  235. package/app/components/settings/DefaultProvisionTypeSection.vue +6 -6
  236. package/app/components/settings/InfraHandlersConfigurator.vue +24 -22
  237. package/app/components/settings/InfrastructureBackendPicker.vue +6 -6
  238. package/app/components/settings/InfrastructureWindow.vue +5 -5
  239. package/app/components/settings/IssueTrackerPanel.vue +29 -23
  240. package/app/components/settings/KubernetesEngineForm.vue +11 -11
  241. package/app/components/settings/KubernetesEnvironmentForm.vue +4 -4
  242. package/app/components/settings/LocalContainerPoolSettings.vue +6 -11
  243. package/app/components/settings/LocalModelEndpointsPanel.vue +20 -18
  244. package/app/components/settings/McpAuthorizeScreen.vue +13 -15
  245. package/app/components/settings/McpOAuthCallbackScreen.vue +16 -10
  246. package/app/components/settings/MergeClassRulesEditor.vue +6 -9
  247. package/app/components/settings/MergeRolePolicyEditor.vue +16 -16
  248. package/app/components/settings/ModelConfigurationPanel.vue +23 -30
  249. package/app/components/settings/ModelPresetHealthModal.vue +15 -13
  250. package/app/components/settings/ObservabilityConnectionPanel.vue +4 -4
  251. package/app/components/settings/OpenRouterCatalogPanel.vue +23 -19
  252. package/app/components/settings/PackageRegistriesPanel.vue +8 -8
  253. package/app/components/settings/ProviderConnectionTab.vue +8 -11
  254. package/app/components/settings/ProviderManifestEditor.vue +9 -9
  255. package/app/components/settings/ProviderPreferenceEditor.vue +8 -8
  256. package/app/components/settings/RiskPolicyCreateForm.vue +10 -10
  257. package/app/components/settings/RiskPolicyEditorRow.vue +14 -14
  258. package/app/components/settings/RiskPolicyHealthModal.vue +15 -13
  259. package/app/components/settings/RiskPolicyInheritedRow.vue +1 -1
  260. package/app/components/settings/RiskPolicyPanel.vue +10 -15
  261. package/app/components/settings/ServiceFragmentDefaultsPanel.vue +7 -7
  262. package/app/components/settings/SharedStacksPanel.vue +9 -12
  263. package/app/components/settings/TaskSourceCard.vue +4 -4
  264. package/app/components/settings/TaskTypeSuppressionsPanel.vue +6 -6
  265. package/app/components/settings/ToolServerChecklist.vue +36 -29
  266. package/app/components/settings/UsageSettings.vue +15 -15
  267. package/app/components/settings/UserSecretsSection.vue +9 -12
  268. package/app/components/settings/WorkspaceMetadataSettings.vue +5 -5
  269. package/app/components/settings/WorkspaceSettingsPanel.vue +47 -47
  270. package/app/components/skills/ReviewSkillQueue.vue +10 -10
  271. package/app/components/skills/SkillLibraryManager.vue +23 -20
  272. package/app/components/slack/SlackPanel.vue +13 -17
  273. package/app/components/spec/ServiceSpecWindow.vue +45 -42
  274. package/app/components/tasks/BugHuntModal.vue +24 -27
  275. package/app/components/tasks/ContextIssuePicker.vue +11 -11
  276. package/app/components/tasks/TaskContextIssues.vue +3 -3
  277. package/app/components/tasks/TaskImportModal.vue +7 -7
  278. package/app/components/tasks/TaskSourceConnectModal.vue +10 -8
  279. package/app/components/testing/TestReportWindow.vue +99 -82
  280. package/app/components/theme/ThemeImportModal.vue +115 -0
  281. package/app/components/tutorial/TutorialCatalogue.vue +9 -9
  282. package/app/components/tutorial/TutorialNudge.vue +5 -5
  283. package/app/components/tutorial/TutorialOverlay.vue +12 -12
  284. package/app/components/tutorial/TutorialPrompt.vue +6 -9
  285. package/app/components/vcs/GitLabConnect.vue +4 -4
  286. package/app/components/vcs/VcsConnectSurfaces.vue +2 -2
  287. package/app/components/visualConfirm/VisualConfirmationWindow.vue +36 -36
  288. package/app/composables/api/client.ts +6 -1
  289. package/app/composables/usePipelineErrorToast.ts +17 -0
  290. package/app/modular/agent-kinds.ts +24 -0
  291. package/app/pages/index.vue +5 -5
  292. package/app/plugins/theme.client.ts +125 -0
  293. package/app/spa-loading-template.html +52 -14
  294. package/app/stores/agents.ts +1 -1
  295. package/app/stores/theme.spec.ts +56 -0
  296. package/app/stores/theme.ts +87 -0
  297. package/app/types/domain.ts +19 -0
  298. package/app/types/execution.ts +4 -0
  299. package/app/utils/agentOutput.ts +1 -1
  300. package/app/utils/binaryOutput.ts +6 -6
  301. package/app/utils/bootstrapSteps.ts +14 -14
  302. package/app/utils/catalog.ts +87 -83
  303. package/app/utils/colorTint.ts +10 -0
  304. package/app/utils/failureKinds.ts +1 -0
  305. package/app/utils/observability.ts +3 -3
  306. package/app/utils/pipelineRender.ts +13 -17
  307. package/app/utils/swimlanes.ts +4 -4
  308. package/app/utils/theme/bootPalette.spec.ts +54 -0
  309. package/app/utils/theme/bootPalette.ts +108 -0
  310. package/app/utils/theme/builtins.spec.ts +38 -0
  311. package/app/utils/theme/builtins.ts +55 -0
  312. package/app/utils/theme/css.ts +162 -0
  313. package/app/utils/theme/doc.ts +353 -0
  314. package/app/utils/theme/link.ts +93 -0
  315. package/app/utils/theme/presets.ts +509 -0
  316. package/app/utils/theme/theme.spec.ts +224 -0
  317. package/i18n/locales/de.json +61 -4
  318. package/i18n/locales/en.json +70 -4
  319. package/i18n/locales/es.json +61 -4
  320. package/i18n/locales/fr.json +61 -4
  321. package/i18n/locales/he.json +61 -4
  322. package/i18n/locales/it.json +61 -4
  323. package/i18n/locales/ja.json +61 -4
  324. package/i18n/locales/pl.json +61 -4
  325. package/i18n/locales/tr.json +61 -4
  326. package/i18n/locales/uk.json +61 -4
  327. package/nuxt.config.ts +37 -11
  328. package/package.json +9 -8
@@ -407,7 +407,7 @@ export function noFailingCallReason(evidence: RunFailureEvidence): NoFailingCall
407
407
 
408
408
  /** Tailwind text/bg colour for an output-headroom level (green → amber → red). */
409
409
  export function headroomColor(ratio: number | null, truncated: boolean): string {
410
- if (truncated || (ratio != null && ratio >= 0.98)) return 'text-rose-400'
411
- if (ratio != null && ratio >= 0.8) return 'text-amber-400'
412
- return 'text-emerald-400'
410
+ if (truncated || (ratio != null && ratio >= 0.98)) return 'text-app-error-400'
411
+ if (ratio != null && ratio >= 0.8) return 'text-app-warning-400'
412
+ return 'text-app-success-400'
413
413
  }
@@ -49,7 +49,7 @@ export type CompanionState = 'possible' | 'running' | 'completed' | 'skipped' |
49
49
  */
50
50
  export const FAILED_STEP_META = {
51
51
  label: 'Failed',
52
- color: '#ef4444',
52
+ color: 'var(--ui-error)',
53
53
  icon: 'i-lucide-circle-x',
54
54
  } as const
55
55
 
@@ -75,32 +75,32 @@ export const COMPANION_STATE_META: Record<
75
75
  > = {
76
76
  possible: {
77
77
  label: 'May run',
78
- dot: 'border-slate-600 bg-slate-800/40',
79
- text: 'text-slate-400',
78
+ dot: 'border-app-600 bg-elevated/40',
79
+ text: 'text-muted',
80
80
  icon: 'i-lucide-circle-dashed',
81
81
  },
82
82
  running: {
83
83
  label: 'Running',
84
- dot: 'border-amber-400 bg-amber-500/20',
85
- text: 'text-amber-300',
84
+ dot: 'border-app-warning-400 bg-app-warning-500/20',
85
+ text: 'text-app-warning-300',
86
86
  icon: 'i-lucide-loader',
87
87
  },
88
88
  completed: {
89
89
  label: 'Ran',
90
- dot: 'border-emerald-500 bg-emerald-500/20',
91
- text: 'text-emerald-300',
90
+ dot: 'border-app-success-500 bg-app-success-500/20',
91
+ text: 'text-app-success-300',
92
92
  icon: 'i-lucide-circle-check',
93
93
  },
94
94
  skipped: {
95
95
  label: 'Skipped',
96
- dot: 'border-slate-700 bg-slate-800/40',
97
- text: 'text-slate-500',
96
+ dot: 'border-muted bg-elevated/40',
97
+ text: 'text-dimmed',
98
98
  icon: 'i-lucide-circle-slash',
99
99
  },
100
100
  failed: {
101
101
  label: 'Gave up',
102
- dot: 'border-rose-500 bg-rose-500/20',
103
- text: 'text-rose-400',
102
+ dot: 'border-app-error-500 bg-app-error-500/20',
103
+ text: 'text-app-error-400',
104
104
  icon: 'i-lucide-circle-x',
105
105
  },
106
106
  }
@@ -348,11 +348,7 @@ function describeUnhandledSkipReason(reason: never): string {
348
348
  */
349
349
  export function subtaskIconClass(status: string, runFailed: boolean): string[] {
350
350
  return [
351
- status === 'in_progress'
352
- ? runFailed
353
- ? 'text-indigo-400'
354
- : 'animate-spin text-indigo-400'
355
- : '',
356
- status === 'completed' ? 'text-emerald-400' : 'text-slate-500',
351
+ status === 'in_progress' ? (runFailed ? 'text-primary' : 'animate-spin text-primary') : '',
352
+ status === 'completed' ? 'text-app-success-400' : 'text-dimmed',
357
353
  ]
358
354
  }
@@ -147,25 +147,25 @@ export const LANE_META: Record<
147
147
  labelKey: 'board.lanes.notStarted.label',
148
148
  emptyKey: 'board.lanes.notStarted.empty',
149
149
  icon: 'i-lucide-circle-dashed',
150
- color: '#64748b',
150
+ color: 'var(--ui-text-muted)',
151
151
  },
152
152
  in_progress: {
153
153
  labelKey: 'board.lanes.inProgress.label',
154
154
  emptyKey: 'board.lanes.inProgress.empty',
155
155
  icon: 'i-lucide-loader',
156
- color: '#6366f1',
156
+ color: 'var(--ui-primary)',
157
157
  },
158
158
  needs_you: {
159
159
  labelKey: 'board.lanes.needsYou.label',
160
160
  emptyKey: 'board.lanes.needsYou.empty',
161
161
  icon: 'i-lucide-hand',
162
- color: '#f59e0b',
162
+ color: 'var(--ui-warning)',
163
163
  },
164
164
  done: {
165
165
  labelKey: 'board.lanes.done.label',
166
166
  emptyKey: 'board.lanes.done.empty',
167
167
  icon: 'i-lucide-circle-check',
168
- color: '#16a34a',
168
+ color: 'var(--ui-text-muted)',
169
169
  },
170
170
  }
171
171
 
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ BOOT_PALETTE_KEY,
4
+ readBootPalette,
5
+ writeBootPalette,
6
+ type BootPaletteEntry,
7
+ } from '~/utils/theme/bootPalette'
8
+
9
+ const dark: BootPaletteEntry = {
10
+ bg: 'oklch(0.129 0.042 264.695)',
11
+ track: 'oklch(0.279 0.041 260.031)',
12
+ accent: 'oklch(0.673 0.182 276.935)',
13
+ status: 'oklch(0.704 0.04 256.788)',
14
+ }
15
+ const light: BootPaletteEntry = {
16
+ bg: 'rgb(226, 232, 240)',
17
+ track: '#cbd5e1',
18
+ accent: 'black',
19
+ status: 'rgb(100, 116, 139)',
20
+ }
21
+
22
+ describe('boot palette cache', () => {
23
+ it('keeps one entry per colour mode under one theme', () => {
24
+ writeBootPalette('cat-factory', 'dark', dark)
25
+ writeBootPalette('cat-factory', 'light', light)
26
+ expect(readBootPalette()).toEqual({ theme: 'cat-factory', dark, light })
27
+ const darkAgain = { ...dark, accent: 'white' }
28
+ writeBootPalette('cat-factory', 'dark', darkAgain)
29
+ expect(readBootPalette()).toEqual({ theme: 'cat-factory', dark: darkAgain, light })
30
+ })
31
+
32
+ it("drops the other mode's entry when the theme changes, so a stale palette never dresses the shell", () => {
33
+ writeBootPalette('cat-factory', 'dark', dark)
34
+ writeBootPalette('cat-factory', 'light', light)
35
+ const monoDark = { ...dark, accent: 'white' }
36
+ writeBootPalette('mono', 'dark', monoDark)
37
+ expect(readBootPalette()).toEqual({ theme: 'mono', dark: monoDark })
38
+ })
39
+
40
+ it('drops an entry that is not a set of computed colours', () => {
41
+ localStorage.setItem(
42
+ BOOT_PALETTE_KEY,
43
+ JSON.stringify({
44
+ theme: 'x',
45
+ dark,
46
+ light: { ...light, bg: 'red; } body { display: none' },
47
+ other: dark,
48
+ }),
49
+ )
50
+ expect(readBootPalette()).toEqual({ theme: 'x', dark })
51
+ localStorage.setItem(BOOT_PALETTE_KEY, 'not json')
52
+ expect(readBootPalette()).toEqual({})
53
+ })
54
+ })
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The handful of colours the pre-JS loading shell (`spa-loading-template.html`) paints with,
3
+ * cached per colour mode so the FIRST frame after a reload wears the active theme instead of the
4
+ * default one.
5
+ *
6
+ * The shell renders before any stylesheet or the theme plugin exists, so it cannot resolve a token.
7
+ * What it can do is read `localStorage` from a two-line inline script. So after every theme or
8
+ * mode change the plugin resolves these tokens on `<html>` and writes the computed colours here,
9
+ * keyed by mode UNDER the active theme's id: the colour-mode script in `<head>` has already classed
10
+ * `<html>` when the shell paints, so the shell picks the entry for the mode it is about to show.
11
+ * A theme change drops the other mode's entry too (it belonged to the previous theme), so a mode
12
+ * never seen since the theme was picked has no entry and the shell falls back to its defaults.
13
+ *
14
+ * Values are computed colours (`oklch(...)`, `rgb(...)`), never the theme's own input, and the shell
15
+ * applies them through `style.setProperty`, which cannot break out of a declaration.
16
+ */
17
+
18
+ export const BOOT_PALETTE_KEY = 'cf-boot-palette'
19
+
20
+ export type ColorModeName = 'light' | 'dark'
21
+
22
+ /** The shell's slots, each the token it mirrors in the running app. */
23
+ export const BOOT_PALETTE_TOKENS = {
24
+ bg: '--app-bg-canvas',
25
+ track: '--ui-border',
26
+ accent: '--ui-primary',
27
+ status: '--ui-text-muted',
28
+ } as const
29
+
30
+ export type BootPaletteEntry = Record<keyof typeof BOOT_PALETTE_TOKENS, string>
31
+ export type BootPalette = { theme?: string } & Partial<Record<ColorModeName, BootPaletteEntry>>
32
+
33
+ // A computed colour as the browser reports it: a colour function or a hex literal.
34
+ const COMPUTED_COLOR = /^(?:[a-z]+\([\d.%,\s/a-z-]+\)|#[0-9a-f]{3,8}|[a-z]+)$/i
35
+
36
+ // A sentinel the probe's fallback lands on when a token is undefined. `color` is an INHERITED
37
+ // property, so a bare `color: var(--missing)` is invalid at computed-value time and resolves to the
38
+ // inherited colour (always a real `rgb(...)`), not to nothing: the guard below could never tell an
39
+ // unresolved token from a resolved one. Giving the `var()` this fallback makes the difference
40
+ // observable, since no theme paints a slot in it. `getComputedStyle` normalises it to this string.
41
+ const UNRESOLVED_SENTINEL = 'rgb(1, 2, 3)' // colour-literal-ok: a probe sentinel, never a painted colour
42
+
43
+ function isEntry(value: unknown): value is BootPaletteEntry {
44
+ if (typeof value !== 'object' || value === null) return false
45
+ return Object.keys(BOOT_PALETTE_TOKENS).every((slot) => {
46
+ const colour = (value as Record<string, unknown>)[slot]
47
+ return typeof colour === 'string' && COMPUTED_COLOR.test(colour)
48
+ })
49
+ }
50
+
51
+ /** Resolve the shell's slots against the current document, or null if any is not resolvable yet. */
52
+ export function captureBootPalette(
53
+ root: Element = document.documentElement,
54
+ ): BootPaletteEntry | null {
55
+ const probe = document.createElement('div')
56
+ root.appendChild(probe)
57
+ try {
58
+ const entry: Partial<BootPaletteEntry> = {}
59
+ for (const [slot, token] of Object.entries(BOOT_PALETTE_TOKENS)) {
60
+ probe.style.color = `var(${token}, ${UNRESOLVED_SENTINEL})`
61
+ const computed = getComputedStyle(probe).color
62
+ if (computed === UNRESOLVED_SENTINEL || !COMPUTED_COLOR.test(computed)) return null
63
+ entry[slot as keyof BootPaletteEntry] = computed
64
+ }
65
+ return entry as BootPaletteEntry
66
+ } finally {
67
+ probe.remove()
68
+ }
69
+ }
70
+
71
+ export function readBootPalette(storage: Storage = localStorage): BootPalette {
72
+ try {
73
+ const parsed: unknown = JSON.parse(storage.getItem(BOOT_PALETTE_KEY) ?? '{}')
74
+ if (typeof parsed !== 'object' || parsed === null) return {}
75
+ const palette: BootPalette = {}
76
+ const theme = (parsed as Record<string, unknown>).theme
77
+ if (typeof theme === 'string') palette.theme = theme
78
+ for (const mode of ['light', 'dark'] as const) {
79
+ const entry = (parsed as Record<string, unknown>)[mode]
80
+ if (isEntry(entry)) palette[mode] = entry
81
+ }
82
+ return palette
83
+ } catch {
84
+ return {}
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Record one mode's colours for a theme. The other mode's entry survives only if it was captured
90
+ * under the SAME theme; otherwise it is the previous theme's and would dress the shell wrongly the
91
+ * first time the OS starts the app in that mode.
92
+ */
93
+ export function writeBootPalette(
94
+ theme: string,
95
+ mode: ColorModeName,
96
+ entry: BootPaletteEntry,
97
+ storage: Storage = localStorage,
98
+ ): void {
99
+ const previous = readBootPalette(storage)
100
+ const palette: BootPalette = previous.theme === theme ? previous : { theme }
101
+ palette.theme = theme
102
+ palette[mode] = entry
103
+ try {
104
+ storage.setItem(BOOT_PALETTE_KEY, JSON.stringify(palette))
105
+ } catch {
106
+ // Storage full or disabled: the shell keeps its default colours, nothing else depends on it.
107
+ }
108
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { BUILTIN_THEMES, CAT_FACTORY_THEME, DEFAULT_THEME_ID } from '~/utils/theme/builtins'
3
+
4
+ // `app.config.ts` / `nuxt.config.ts` call Nuxt's auto-imported define helpers; under plain Vitest
5
+ // each is an identity function.
6
+ vi.stubGlobal('defineAppConfig', <T>(config: T) => config)
7
+ vi.stubGlobal('defineNuxtConfig', <T>(config: T) => config)
8
+
9
+ describe('built-in themes', () => {
10
+ it('states the default theme as exactly what app.config.ts paints the first frame with', async () => {
11
+ // A disagreement would flash one palette and settle on another: the theme plugin applies the
12
+ // document AFTER Nuxt UI's colours plugin has already emitted the ramps from `app.config`.
13
+ const appConfig = (await import('~/app.config')).default as {
14
+ ui: { colors: Record<string, string> }
15
+ }
16
+ expect(CAT_FACTORY_THEME.doc.colors).toEqual(appConfig.ui.colors)
17
+ })
18
+
19
+ it('self-hosts every face a built-in theme names', async () => {
20
+ // The theme plugin sets the family at runtime, which `@nuxt/fonts`' CSS scan cannot see, so the
21
+ // face must be listed in `fonts.families` or the built-in renders in the browser's fallback.
22
+ const nuxtConfig = (await import('../../../nuxt.config')).default as {
23
+ fonts: { families: Array<{ name: string }> }
24
+ }
25
+ const hosted = new Set(nuxtConfig.fonts.families.map((family) => family.name))
26
+ for (const theme of BUILTIN_THEMES) {
27
+ for (const face of Object.values(theme.doc.font ?? {})) {
28
+ if (typeof face === 'string') expect(hosted, `${theme.id} names ${face}`).toContain(face)
29
+ }
30
+ }
31
+ })
32
+
33
+ it('has unique ids and names the default among them', () => {
34
+ const ids = BUILTIN_THEMES.map((theme) => theme.id)
35
+ expect(new Set(ids).size).toBe(ids.length)
36
+ expect(ids).toContain(DEFAULT_THEME_ID)
37
+ })
38
+ })
@@ -0,0 +1,55 @@
1
+ import type { ThemeDoc } from '~/utils/theme/doc'
2
+ import { presets } from '~/utils/theme/presets'
3
+
4
+ /**
5
+ * A theme the switcher offers: a editor document under a display name. Built-ins and the user's
6
+ * imported themes share this shape, so the switcher, the store and the applier never branch on
7
+ * where a theme came from.
8
+ */
9
+ export interface NamedTheme {
10
+ id: string
11
+ /** A proper noun (a theme's name is data the way a locale's `name` is), not an i18n key. */
12
+ name: string
13
+ doc: ThemeDoc
14
+ }
15
+
16
+ export const DEFAULT_THEME_ID = 'cat-factory'
17
+
18
+ /**
19
+ * The app's own theme, stated as the document a user could have exported from the editor. It MUST
20
+ * agree with `app.config.ts`'s `ui.colors`: that block is what the very first paint renders from,
21
+ * before the theme plugin runs, and a disagreement would flash one palette and settle on another.
22
+ * `theme.builtins.spec.ts` pins the equality.
23
+ *
24
+ * Status hues are the app's long-standing choices (amber for warning, rose for error, emerald
25
+ * for success, sky for info, violet for the secondary accent), named here as aliases so a theme
26
+ * that recolours `warning` recolours every warning surface.
27
+ */
28
+ export const CAT_FACTORY_THEME: NamedTheme = {
29
+ id: DEFAULT_THEME_ID,
30
+ name: 'Cat Factory',
31
+ doc: {
32
+ version: 1,
33
+ colors: {
34
+ primary: 'indigo',
35
+ secondary: 'violet',
36
+ success: 'emerald',
37
+ info: 'sky',
38
+ warning: 'amber',
39
+ error: 'rose',
40
+ neutral: 'slate',
41
+ },
42
+ },
43
+ }
44
+
45
+ /**
46
+ * The editor's own Mono preset (black on a pure grey neutral, generous radius, Geist), taken from
47
+ * the preset table rather than copied, so it stays whatever the editor ships. Its faces are
48
+ * self-hosted in `nuxt.config.ts` (`fonts.families`); `builtins.spec.ts` pins that.
49
+ */
50
+ const monoPreset = presets.find((preset) => preset.id === 'mono')
51
+ if (!monoPreset) throw new Error('the editor preset table has no "mono" entry')
52
+
53
+ export const MONO_THEME: NamedTheme = { id: 'mono', name: monoPreset.name, doc: monoPreset.doc }
54
+
55
+ export const BUILTIN_THEMES: readonly NamedTheme[] = [CAT_FACTORY_THEME, MONO_THEME]
@@ -0,0 +1,162 @@
1
+ import {
2
+ THEME_DEFAULTS,
3
+ styleTokens,
4
+ type ModeTokens,
5
+ type ThemeDoc,
6
+ type ThemePalette,
7
+ } from '~/utils/theme/doc'
8
+
9
+ /**
10
+ * A theme document as the stylesheet the app injects at runtime (the `<style id="app-theme">` the
11
+ * theme plugin owns). This is the runtime twin of the editor's `main.css` export: the same
12
+ * variables, but scoped so they win without depending on source order.
13
+ *
14
+ * Every rule is anchored on `:root[data-theme]`, the attribute the plugin sets on `<html>`. That
15
+ * gives each block a specificity the library's own `@layer theme` rules AND `main.css`'s unlayered
16
+ * `.dark {}` blocks cannot reach: light values ride `:root[data-theme]` (0,2,0), dark values
17
+ * `:root[data-theme].dark` (0,3,0), so dark wins whenever the colour-mode class is present and the
18
+ * editor's "restate the library's dark value" workaround is unnecessary. It also means a theme can
19
+ * override the app's own `--app-*` tokens (declared in `main.css` on `.dark`) with no ordering
20
+ * game.
21
+ *
22
+ * Values are the WRITE BOUNDARY for untrusted input: an imported document ends up concatenated into
23
+ * a stylesheet, so every character class below excludes `;`, `}` and `<`, which is what keeps a
24
+ * value from ending its declaration early or closing the tag. A rejected value is dropped, never
25
+ * escaped: a half-applied theme is visible, a corrupted stylesheet is not.
26
+ */
27
+
28
+ const SAFE_CSS_VAR_KEY = /^--[\w-]+$/
29
+ const OKLCH_NUMBER = String.raw`\d+(?:\.\d+)?|\.\d+`
30
+ const SAFE_OKLCH = new RegExp(
31
+ String.raw`^oklch\(\s*(?:${OKLCH_NUMBER})%?\s+(?:${OKLCH_NUMBER}|none)\s+(?:(?:${OKLCH_NUMBER})(?:deg)?|none)\s*\)$`,
32
+ 'i',
33
+ )
34
+ const SAFE_HEX = /^#[0-9a-f]{3,8}$/i
35
+ // var() refs, hex, keywords, px/% lengths and literal oklch()/rgb() colours.
36
+ const SAFE_CSS_VAR_VALUE =
37
+ /^(?:var\(--[\w-]+\)|#[0-9a-f]{3,8}|[a-z]+|-?\d{1,3}(?:\.\d+)?(?:px|%|rem)|oklch\([\w.% -]{1,40}\)|rgba?\([\d.%, /]{1,40}\))$/i
38
+ const SAFE_PALETTE_NAME = /^[\w-]{1,50}$/
39
+ const SAFE_FONT_NAME = /^[\w -]{1,50}$/
40
+ const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
41
+
42
+ export function isSafeCssVarKey(key: string): boolean {
43
+ return SAFE_CSS_VAR_KEY.test(key) && !UNSAFE_KEYS.has(key.slice(2))
44
+ }
45
+
46
+ export function isSafeCssVarValue(value: unknown): value is string {
47
+ return typeof value === 'string' && SAFE_CSS_VAR_VALUE.test(value)
48
+ }
49
+
50
+ export function isSafeShadeValue(value: unknown): value is string {
51
+ return typeof value === 'string' && (SAFE_OKLCH.test(value) || SAFE_HEX.test(value))
52
+ }
53
+
54
+ export function isSafeFontName(value: unknown): value is string {
55
+ return typeof value === 'string' && SAFE_FONT_NAME.test(value)
56
+ }
57
+
58
+ /**
59
+ * A palette NAME safe to interpolate into `--color-<name>-<shade>` and into the alias map Nuxt UI
60
+ * re-emits as `--ui-color-<alias>-<shade>: var(--color-<value>-<shade>)`. Both a custom palette key
61
+ * and a document's alias VALUE reach generated CSS this way, so both must pass this one rule.
62
+ */
63
+ export function isSafePaletteName(value: unknown): value is string {
64
+ return typeof value === 'string' && SAFE_PALETTE_NAME.test(value) && !UNSAFE_KEYS.has(value)
65
+ }
66
+
67
+ function cleanVars(vars: Record<string, unknown> | undefined): Record<string, string> {
68
+ const result: Record<string, string> = {}
69
+ for (const [key, value] of Object.entries(vars ?? {})) {
70
+ if (isSafeCssVarKey(key) && isSafeCssVarValue(value)) result[key] = value
71
+ }
72
+ return result
73
+ }
74
+
75
+ /**
76
+ * The per-mode variable set a document resolves to. One precedence in BOTH modes: style shades
77
+ * first, then `blackAsPrimary` (the coarser instruction), then explicit tokens win. Light mirrors
78
+ * dark so a document carrying both `blackAsPrimary` and a `--ui-primary` style shade cannot render
79
+ * monochrome in one mode and a ramp colour in the other.
80
+ */
81
+ export function docModeTokens(doc: ThemeDoc): ModeTokens {
82
+ const style = styleTokens(doc.style)
83
+ const light = {
84
+ ...style.light,
85
+ ...(doc.blackAsPrimary ? { '--ui-primary': 'black' } : {}),
86
+ ...cleanVars(doc.tokens?.light),
87
+ }
88
+ const dark = {
89
+ ...style.dark,
90
+ ...(doc.blackAsPrimary ? { '--ui-primary': 'white' } : {}),
91
+ ...cleanVars(doc.tokens?.dark),
92
+ }
93
+ return { light, dark }
94
+ }
95
+
96
+ function paletteLines(name: string, palette: ThemePalette): string[] {
97
+ if (!isSafePaletteName(name)) return []
98
+ return Object.entries(palette.shades ?? {})
99
+ .filter(([shade, value]) => /^\d{2,3}$/.test(shade) && isSafeShadeValue(value))
100
+ .map(([shade, value]) => ` --color-${name}-${shade}: ${value};`)
101
+ }
102
+
103
+ function block(selector: string, lines: string[]): string[] {
104
+ return lines.length > 0 ? [`${selector} {`, ...lines, '}'] : []
105
+ }
106
+
107
+ function declarations(vars: Record<string, string>): string[] {
108
+ return Object.entries(vars).map(([key, value]) => ` ${key}: ${value};`)
109
+ }
110
+
111
+ /**
112
+ * The stylesheet for a document. Empty for an empty document, so the stock library theme injects
113
+ * nothing at all.
114
+ */
115
+ export function themeDocToCss(doc: ThemeDoc): string {
116
+ const root: string[] = []
117
+
118
+ // Custom palettes ride the runtime colours plugin's fallback: it emits
119
+ // `var(--color-<name>-<shade>, <tailwind literal>)` for every alias, so declaring the ramp as a
120
+ // real custom property on the root is enough for the alias to pick it up.
121
+ for (const [name, palette] of Object.entries(doc.palettes ?? {})) {
122
+ root.push(...paletteLines(name, palette))
123
+ }
124
+ if (doc.radius !== undefined && doc.radius !== THEME_DEFAULTS.radius && isFinite(doc.radius)) {
125
+ root.push(` --ui-radius: ${doc.radius}rem;`)
126
+ }
127
+ // Tailwind's `--font-*` theme variables are live custom properties (`font-sans` compiles to
128
+ // `var(--font-sans)`, and preflight's body family derives from it), so a root override reaches
129
+ // every utility and the classless body alike. The FACE is the deployment's to provide: this
130
+ // names the family and lets the browser fall back when it is not installed or self-hosted.
131
+ if (isSafeFontName(doc.font?.sans) && doc.font.sans !== THEME_DEFAULTS.font) {
132
+ root.push(` --font-sans: '${doc.font.sans}', ui-sans-serif, system-ui, sans-serif;`)
133
+ }
134
+ if (isSafeFontName(doc.font?.serif)) {
135
+ root.push(` --font-serif: '${doc.font.serif}', ui-serif, serif;`)
136
+ }
137
+ if (isSafeFontName(doc.font?.mono)) {
138
+ root.push(` --font-mono: '${doc.font.mono}', ui-monospace, monospace;`)
139
+ }
140
+ for (const step of ['normal', 'medium', 'semibold', 'bold'] as const) {
141
+ const weight = doc.font?.weights?.[step]
142
+ if (typeof weight === 'number' && weight >= 100 && weight <= 900) {
143
+ root.push(` --font-weight-${step}: ${weight};`)
144
+ }
145
+ }
146
+
147
+ const tokens = docModeTokens(doc)
148
+ const lines = [
149
+ ...block(':root[data-theme]', root),
150
+ ...block(':root[data-theme]', declarations(tokens.light)),
151
+ ...block(':root[data-theme].dark', declarations(tokens.dark)),
152
+ ]
153
+ if (
154
+ doc.fontSize !== undefined &&
155
+ doc.fontSize !== THEME_DEFAULTS.fontSize &&
156
+ doc.fontSize >= 8 &&
157
+ doc.fontSize <= 32
158
+ ) {
159
+ lines.push(`html[data-theme] { font-size: ${doc.fontSize}px; }`)
160
+ }
161
+ return lines.join('\n')
162
+ }