@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.
- package/README.md +16 -0
- package/app/app.config.ts +19 -10
- package/app/assets/css/main.css +13 -13
- package/app/assets/css/prose.css +22 -22
- package/app/assets/css/tokens.css +298 -0
- package/app/components/assistant/AssistantModal.vue +22 -22
- package/app/components/auth/AuthGate.vue +1 -1
- package/app/components/auth/BackendMisconfiguredScreen.vue +10 -10
- package/app/components/auth/LoginScreen.vue +34 -36
- package/app/components/auth/ResetPasswordScreen.vue +12 -12
- package/app/components/auth/UserMenu.vue +4 -4
- package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +27 -25
- package/app/components/binaryOutput/BinaryOutputReport.vue +15 -15
- package/app/components/binaryOutput/StoredAssetView.vue +5 -5
- package/app/components/board/AddTaskModal.vue +17 -20
- package/app/components/board/AgentFailureCard.vue +11 -11
- package/app/components/board/AgentFailureHistory.vue +2 -2
- package/app/components/board/BoardCanvas.vue +6 -5
- package/app/components/board/CreateInitiativeModal.vue +8 -10
- package/app/components/board/DependencyConnectOverlay.vue +1 -1
- package/app/components/board/FailureHistoryList.vue +7 -7
- package/app/components/board/RecurringPipelineModal.vue +17 -17
- package/app/components/board/ReviewFrictionDialog.vue +6 -6
- package/app/components/board/StepExecutionHistory.vue +13 -13
- package/app/components/board/TaskDependencyEdges.vue +8 -8
- package/app/components/board/nodes/AgentChip.vue +10 -10
- package/app/components/board/nodes/BlockNode.vue +29 -29
- package/app/components/board/nodes/DecisionBadge.vue +2 -2
- package/app/components/board/nodes/EpicNode.vue +14 -10
- package/app/components/board/nodes/FrameSwimlanes.vue +7 -9
- package/app/components/board/nodes/InitiativeCard.vue +8 -8
- package/app/components/board/nodes/LaneGroup.vue +1 -1
- package/app/components/board/nodes/LaneTask.vue +2 -2
- package/app/components/board/nodes/ResizeGrips.vue +4 -4
- package/app/components/board/nodes/TaskCard.vue +18 -18
- package/app/components/board/nodes/TaskLane.vue +5 -5
- package/app/components/board/nodes/TaskPipelineMini.vue +18 -18
- package/app/components/bootstrap/AdoptionReviewModal.vue +29 -26
- package/app/components/bootstrap/BootstrapModal.vue +21 -23
- package/app/components/bootstrap/BootstrapRunSteps.vue +1 -1
- package/app/components/brainstorm/BrainstormWindow.vue +30 -33
- package/app/components/bugFishing/BugFishingWindow.vue +49 -49
- package/app/components/clarity/ClarityReviewWindow.vue +30 -30
- package/app/components/common/AsyncViewError.vue +5 -5
- package/app/components/common/ClarificationItem.vue +8 -13
- package/app/components/common/ConfirmDialog.vue +4 -2
- package/app/components/common/DescriptorFields.vue +1 -1
- package/app/components/common/EmptyState.vue +3 -3
- package/app/components/common/InterviewGateNotice.vue +7 -7
- package/app/components/common/KeyboardShortcutsHelp.vue +1 -1
- package/app/components/common/MarkdownProse.vue +14 -13
- package/app/components/consensus/ConsensusSessionWindow.vue +26 -26
- package/app/components/context/ContextAttachmentFields.vue +17 -17
- package/app/components/docs/DocInterviewWindow.vue +14 -14
- package/app/components/documents/ContextDocumentPicker.vue +17 -17
- package/app/components/documents/DocumentImportModal.vue +7 -7
- package/app/components/documents/DocumentSourceConnectModal.vue +3 -3
- package/app/components/documents/DocumentSyncState.vue +5 -5
- package/app/components/documents/DocumentTemplatesModal.vue +15 -15
- package/app/components/documents/RepoContextDocPicker.vue +5 -5
- package/app/components/documents/SpawnPreviewModal.vue +12 -12
- package/app/components/documents/StartFromDesignModal.vue +11 -11
- package/app/components/documents/TaskContextDocs.vue +5 -5
- package/app/components/environments/EnvSetupStepper.vue +6 -6
- package/app/components/environments/EnvironmentStatusPanel.vue +17 -17
- package/app/components/environments/steps/EnvPickStep.vue +3 -3
- package/app/components/environments/steps/EnvPreflightStep.vue +9 -9
- package/app/components/environments/steps/EnvReviewStep.vue +25 -22
- package/app/components/environments/steps/EnvSaveStep.vue +7 -5
- package/app/components/environments/steps/JourneyStepNav.vue +1 -1
- package/app/components/focus/BlockFocusView.vue +17 -19
- package/app/components/followUp/FollowUpWindow.vue +23 -23
- package/app/components/forkDecision/ForkDecisionWindow.vue +37 -39
- package/app/components/foundational/FoundationalContractSummary.vue +7 -7
- package/app/components/foundational/FoundationalServiceCatalogList.vue +11 -11
- package/app/components/foundational/FoundationalServiceManager.vue +1 -1
- package/app/components/foundational/FoundationalServiceRegistry.vue +13 -13
- package/app/components/foundational/FoundationalServiceSources.vue +13 -13
- package/app/components/foundational/FoundationalSuppressions.vue +7 -7
- package/app/components/foundational/ServiceCatalogConnection.vue +10 -10
- package/app/components/fragments/FragmentLibraryManager.vue +41 -44
- package/app/components/fragments/FragmentSelector.vue +13 -13
- package/app/components/fragments/GitHubDocUrlImport.vue +1 -1
- package/app/components/gates/GateFailingCheckList.vue +5 -5
- package/app/components/gates/GateResultView.vue +50 -47
- package/app/components/github/AddServiceFromRepoModal.vue +14 -14
- package/app/components/github/BranchProtectionPreflight.vue +13 -13
- package/app/components/github/GitHubConnect.vue +7 -7
- package/app/components/github/GitHubOnboarding.vue +7 -7
- package/app/components/github/GitHubPanel.vue +37 -40
- package/app/components/github/RepoTreeBrowser.vue +18 -18
- package/app/components/humanTest/HumanTestWindow.vue +30 -33
- package/app/components/initiative/InitiativePlanNotice.vue +5 -5
- package/app/components/initiative/InitiativePlanReview.vue +27 -29
- package/app/components/initiative/InitiativePlanningWindow.vue +10 -10
- package/app/components/initiative/InitiativeTrackerWindow.vue +71 -65
- package/app/components/inputGate/InputGateNotice.vue +2 -4
- package/app/components/judge/JudgeResultView.vue +30 -32
- package/app/components/kaizen/KaizenPanel.vue +40 -36
- package/app/components/kaizen/KaizenStepStatus.vue +21 -21
- package/app/components/layout/AccountAuditLog.vue +10 -10
- package/app/components/layout/AccountDeploymentSettings.vue +20 -20
- package/app/components/layout/AccountFailureKindRules.vue +13 -13
- package/app/components/layout/AccountFoundationalSettings.vue +1 -1
- package/app/components/layout/AccountFragmentSettings.vue +1 -1
- package/app/components/layout/AccountModelPolicySettings.vue +9 -9
- package/app/components/layout/AccountPlatformAlertSettings.vue +11 -11
- package/app/components/layout/AccountRiskPolicySettings.vue +2 -2
- package/app/components/layout/AccountRunCredentialSettings.vue +5 -5
- package/app/components/layout/AccountSkillSettings.vue +1 -1
- package/app/components/layout/AccountTeamSettings.vue +23 -15
- package/app/components/layout/AiProvidersBanner.vue +11 -8
- package/app/components/layout/AppearanceSwitcher.vue +108 -0
- package/app/components/layout/BoardSwitcher.vue +8 -11
- package/app/components/layout/BoardToolbar.vue +3 -3
- package/app/components/layout/CommandBar.vue +7 -9
- package/app/components/layout/ConnectionStatusBanner.vue +2 -2
- package/app/components/layout/DefaultTestEnvBanner.vue +5 -5
- package/app/components/layout/GitHubPatBanner.vue +5 -5
- package/app/components/layout/GitHubPatPermissionsBanner.vue +16 -10
- package/app/components/layout/InfraSetupBanner.vue +6 -6
- package/app/components/layout/IntegrationsHub.vue +15 -15
- package/app/components/layout/LanguageSwitcher.vue +5 -5
- package/app/components/layout/ModelProvidersHub.vue +13 -16
- package/app/components/layout/NotificationsInbox.vue +10 -10
- package/app/components/layout/PersonalSetupModal.vue +8 -8
- package/app/components/layout/ProviderConfigBanner.vue +4 -4
- package/app/components/layout/RolePrompt.vue +7 -7
- package/app/components/layout/SideBar.vue +6 -4
- package/app/components/layout/SpendWarningBanner.vue +17 -14
- package/app/components/layout/TranslationWarningBanner.vue +4 -4
- package/app/components/layout/UiModeSwitcher.vue +13 -13
- package/app/components/layout/UiRoleSwitcher.vue +8 -8
- package/app/components/layout/WorkspaceMembersSettings.vue +15 -11
- package/app/components/media/ArtifactLightbox.vue +13 -13
- package/app/components/media/ImageCompare.vue +31 -24
- package/app/components/merge/MergeEffortChips.vue +2 -2
- package/app/components/notifications/NotificationSettingsPanel.vue +14 -12
- package/app/components/observability/OutcomeFilterChips.vue +6 -6
- package/app/components/observability/RunFailureSummary.vue +28 -25
- package/app/components/observability/StepMetricsBar.vue +26 -28
- package/app/components/observability/StepModelActivity.vue +2 -2
- package/app/components/observability/ToolCallList.vue +24 -24
- package/app/components/outcome/OutcomeSummaryWindow.vue +47 -47
- package/app/components/palettes/AgentPalette.vue +8 -8
- package/app/components/palettes/AgentTierSelect.vue +1 -1
- package/app/components/palettes/PipelinePurposeSelect.vue +1 -1
- package/app/components/panels/AgentStepDetail.vue +52 -51
- package/app/components/panels/AttemptEntryHeader.vue +4 -6
- package/app/components/panels/DecisionModal.vue +7 -7
- package/app/components/panels/GenericStructuredResultView.vue +7 -7
- package/app/components/panels/InspectorPanel.vue +17 -17
- package/app/components/panels/MergerResultView.vue +19 -19
- package/app/components/panels/ObservabilityPanel.vue +115 -123
- package/app/components/panels/OperatorDashboardPanel.vue +89 -97
- package/app/components/panels/ReportsPanel.vue +69 -66
- package/app/components/panels/ReportsSpendBreakdown.vue +9 -9
- package/app/components/panels/ResultWindowShell.vue +38 -38
- package/app/components/panels/RunDetailLoadState.vue +2 -2
- package/app/components/panels/StepContainerStatus.vue +9 -9
- package/app/components/panels/StepDelegatedStatus.logic.spec.ts +135 -0
- package/app/components/panels/StepDelegatedStatus.logic.ts +160 -0
- package/app/components/panels/StepDelegatedStatus.vue +181 -0
- package/app/components/panels/StepEffortReport.vue +16 -15
- package/app/components/panels/StepFragmentAdherence.vue +14 -10
- package/app/components/panels/StepMetadataCard.vue +74 -45
- package/app/components/panels/StepReproductionReport.vue +25 -21
- package/app/components/panels/StepRunMeta.vue +20 -20
- package/app/components/panels/StepTestReport.logic.spec.ts +8 -3
- package/app/components/panels/StepTestReport.logic.ts +4 -4
- package/app/components/panels/StepTestReport.vue +28 -28
- package/app/components/panels/StepToolServers.vue +14 -11
- package/app/components/panels/StepValidationReport.vue +7 -7
- package/app/components/panels/inspector/ContainerSummary.vue +9 -9
- package/app/components/panels/inspector/DocReferenceRepos.vue +3 -3
- package/app/components/panels/inspector/EnvProbeReport.vue +24 -27
- package/app/components/panels/inspector/EpicChildren.vue +8 -12
- package/app/components/panels/inspector/FrontendBindingsResolved.vue +11 -11
- package/app/components/panels/inspector/FrontendConfig.vue +37 -41
- package/app/components/panels/inspector/InitiativeInspector.vue +3 -3
- package/app/components/panels/inspector/InspectorSection.vue +6 -6
- package/app/components/panels/inspector/RecurringScheduleSettings.vue +16 -19
- package/app/components/panels/inspector/ServiceConnections.vue +2 -2
- package/app/components/panels/inspector/ServiceReleaseHealthConfig.vue +2 -2
- package/app/components/panels/inspector/ServiceSelfTests.vue +21 -17
- package/app/components/panels/inspector/ServiceTestConfig.vue +51 -59
- package/app/components/panels/inspector/ServiceTestSecrets.vue +5 -5
- package/app/components/panels/inspector/ServiceTestingContext.vue +3 -3
- package/app/components/panels/inspector/ServiceValidationConfig.vue +2 -2
- package/app/components/panels/inspector/TaskAgentConfig.vue +3 -3
- package/app/components/panels/inspector/TaskAprioriBranches.vue +8 -5
- package/app/components/panels/inspector/TaskDependencies.vue +1 -1
- package/app/components/panels/inspector/TaskEstimateBadge.vue +11 -11
- package/app/components/panels/inspector/TaskExecution.vue +17 -17
- package/app/components/panels/inspector/TaskReviewTarget.vue +2 -2
- package/app/components/panels/inspector/TaskRunSettings.vue +35 -37
- package/app/components/panels/inspector/TaskStructure.vue +3 -3
- package/app/components/pipeline/AgentKindIcon.vue +1 -1
- package/app/components/pipeline/AgentPromptEditor.vue +17 -17
- package/app/components/pipeline/BinaryOutputStepPicker.vue +42 -56
- package/app/components/pipeline/EstimateThresholdFields.vue +4 -4
- package/app/components/pipeline/GateConfigFields.vue +13 -10
- package/app/components/pipeline/IterationCapPrompt.vue +4 -2
- package/app/components/pipeline/PipelineBuilder.vue +47 -55
- package/app/components/pipeline/PipelineHealthModal.vue +27 -29
- package/app/components/pipeline/PipelinePicker.vue +10 -10
- package/app/components/pipeline/PipelinePreview.vue +10 -10
- package/app/components/pipeline/PipelineProgress.vue +64 -67
- package/app/components/prReview/PrReviewPhaseBadge.vue +7 -7
- package/app/components/prReview/PrReviewWindow.vue +75 -77
- package/app/components/providers/AiPresetMismatchDialog.vue +5 -5
- package/app/components/providers/AiProviderOnboardingModal.vue +9 -9
- package/app/components/providers/ApiKeysSection.vue +13 -13
- package/app/components/providers/PersonalCredentialModal.vue +2 -2
- package/app/components/providers/PersonalSubscriptionSection.vue +10 -10
- package/app/components/providers/SignInRequiredNotice.vue +1 -1
- package/app/components/providers/VendorCredentialsModal.vue +10 -10
- package/app/components/provisioning/ProvisioningLogsDrawer.vue +13 -13
- package/app/components/ralph/RalphLoopResultView.vue +28 -30
- package/app/components/recurring/RecurrenceEditor.vue +2 -2
- package/app/components/requirements/RequirementsReviewWindow.vue +51 -58
- package/app/components/riskPolicy/RiskPolicyPicker.vue +12 -15
- package/app/components/riskPolicy/RiskPolicyPreview.vue +14 -14
- package/app/components/sandbox/SandboxPanel.vue +58 -59
- package/app/components/settings/AccountSettingsPanel.vue +1 -1
- package/app/components/settings/ApiTokensPanel.vue +10 -10
- package/app/components/settings/BudgetSettings.vue +18 -22
- package/app/components/settings/CapabilityCredentialsPanel.vue +15 -15
- package/app/components/settings/CloudflareHandlerSection.vue +20 -20
- package/app/components/settings/ComposeEnvironmentSetupSection.vue +13 -13
- package/app/components/settings/ConnectionTestVerdict.vue +3 -3
- package/app/components/settings/ConnectionWarnings.vue +1 -1
- package/app/components/settings/ConsensusGroupsSection.vue +27 -35
- package/app/components/settings/CustomManifestTypeEditor.vue +10 -10
- package/app/components/settings/DefaultProvisionTypeSection.vue +6 -6
- package/app/components/settings/InfraHandlersConfigurator.vue +24 -22
- package/app/components/settings/InfrastructureBackendPicker.vue +6 -6
- package/app/components/settings/InfrastructureWindow.vue +5 -5
- package/app/components/settings/IssueTrackerPanel.vue +29 -23
- package/app/components/settings/KubernetesEngineForm.vue +11 -11
- package/app/components/settings/KubernetesEnvironmentForm.vue +4 -4
- package/app/components/settings/LocalContainerPoolSettings.vue +6 -11
- package/app/components/settings/LocalModelEndpointsPanel.vue +20 -18
- package/app/components/settings/McpAuthorizeScreen.vue +13 -15
- package/app/components/settings/McpOAuthCallbackScreen.vue +16 -10
- package/app/components/settings/MergeClassRulesEditor.vue +6 -9
- package/app/components/settings/MergeRolePolicyEditor.vue +16 -16
- package/app/components/settings/ModelConfigurationPanel.vue +23 -30
- package/app/components/settings/ModelPresetHealthModal.vue +15 -13
- package/app/components/settings/ObservabilityConnectionPanel.vue +4 -4
- package/app/components/settings/OpenRouterCatalogPanel.vue +23 -19
- package/app/components/settings/PackageRegistriesPanel.vue +8 -8
- package/app/components/settings/ProviderConnectionTab.vue +8 -11
- package/app/components/settings/ProviderManifestEditor.vue +9 -9
- package/app/components/settings/ProviderPreferenceEditor.vue +8 -8
- package/app/components/settings/RiskPolicyCreateForm.vue +10 -10
- package/app/components/settings/RiskPolicyEditorRow.vue +14 -14
- package/app/components/settings/RiskPolicyHealthModal.vue +15 -13
- package/app/components/settings/RiskPolicyInheritedRow.vue +1 -1
- package/app/components/settings/RiskPolicyPanel.vue +10 -15
- package/app/components/settings/ServiceFragmentDefaultsPanel.vue +7 -7
- package/app/components/settings/SharedStacksPanel.vue +9 -12
- package/app/components/settings/TaskSourceCard.vue +4 -4
- package/app/components/settings/TaskTypeSuppressionsPanel.vue +6 -6
- package/app/components/settings/ToolServerChecklist.vue +36 -29
- package/app/components/settings/UsageSettings.vue +15 -15
- package/app/components/settings/UserSecretsSection.vue +9 -12
- package/app/components/settings/WorkspaceMetadataSettings.vue +5 -5
- package/app/components/settings/WorkspaceSettingsPanel.vue +47 -47
- package/app/components/skills/ReviewSkillQueue.vue +10 -10
- package/app/components/skills/SkillLibraryManager.vue +23 -20
- package/app/components/slack/SlackPanel.vue +13 -17
- package/app/components/spec/ServiceSpecWindow.vue +45 -42
- package/app/components/tasks/BugHuntModal.vue +24 -27
- package/app/components/tasks/ContextIssuePicker.vue +11 -11
- package/app/components/tasks/TaskContextIssues.vue +3 -3
- package/app/components/tasks/TaskImportModal.vue +7 -7
- package/app/components/tasks/TaskSourceConnectModal.vue +10 -8
- package/app/components/testing/TestReportWindow.vue +99 -82
- package/app/components/theme/ThemeImportModal.vue +115 -0
- package/app/components/tutorial/TutorialCatalogue.vue +9 -9
- package/app/components/tutorial/TutorialNudge.vue +5 -5
- package/app/components/tutorial/TutorialOverlay.vue +12 -12
- package/app/components/tutorial/TutorialPrompt.vue +6 -9
- package/app/components/vcs/GitLabConnect.vue +4 -4
- package/app/components/vcs/VcsConnectSurfaces.vue +2 -2
- package/app/components/visualConfirm/VisualConfirmationWindow.vue +36 -36
- package/app/composables/api/client.ts +6 -1
- package/app/composables/usePipelineErrorToast.ts +17 -0
- package/app/modular/agent-kinds.ts +24 -0
- package/app/pages/index.vue +5 -5
- package/app/plugins/theme.client.ts +125 -0
- package/app/spa-loading-template.html +52 -14
- package/app/stores/agents.ts +1 -1
- package/app/stores/theme.spec.ts +56 -0
- package/app/stores/theme.ts +87 -0
- package/app/types/domain.ts +19 -0
- package/app/types/execution.ts +4 -0
- package/app/utils/agentOutput.ts +1 -1
- package/app/utils/binaryOutput.ts +6 -6
- package/app/utils/bootstrapSteps.ts +14 -14
- package/app/utils/catalog.ts +87 -83
- package/app/utils/colorTint.ts +10 -0
- package/app/utils/failureKinds.ts +1 -0
- package/app/utils/observability.ts +3 -3
- package/app/utils/pipelineRender.ts +13 -17
- package/app/utils/swimlanes.ts +4 -4
- package/app/utils/theme/bootPalette.spec.ts +54 -0
- package/app/utils/theme/bootPalette.ts +108 -0
- package/app/utils/theme/builtins.spec.ts +38 -0
- package/app/utils/theme/builtins.ts +55 -0
- package/app/utils/theme/css.ts +162 -0
- package/app/utils/theme/doc.ts +353 -0
- package/app/utils/theme/link.ts +93 -0
- package/app/utils/theme/presets.ts +509 -0
- package/app/utils/theme/theme.spec.ts +224 -0
- package/i18n/locales/de.json +61 -4
- package/i18n/locales/en.json +70 -4
- package/i18n/locales/es.json +61 -4
- package/i18n/locales/fr.json +61 -4
- package/i18n/locales/he.json +61 -4
- package/i18n/locales/it.json +61 -4
- package/i18n/locales/ja.json +61 -4
- package/i18n/locales/pl.json +61 -4
- package/i18n/locales/tr.json +61 -4
- package/i18n/locales/uk.json +61 -4
- package/nuxt.config.ts +37 -11
- 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-
|
|
411
|
-
if (ratio != null && ratio >= 0.8) return 'text-
|
|
412
|
-
return 'text-
|
|
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: '
|
|
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-
|
|
79
|
-
text: 'text-
|
|
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-
|
|
85
|
-
text: 'text-
|
|
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-
|
|
91
|
-
text: 'text-
|
|
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-
|
|
97
|
-
text: 'text-
|
|
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-
|
|
103
|
-
text: 'text-
|
|
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
|
-
|
|
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
|
}
|
package/app/utils/swimlanes.ts
CHANGED
|
@@ -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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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
|
+
}
|