From bc3e3dad1c399fd1e20c6494c1bd8dbfa1590a3d Mon Sep 17 00:00:00 2001 From: webdevcody Date: Fri, 23 Jan 2026 12:55:01 -0500 Subject: [PATCH 01/15] splash screen configurable in global settings --- apps/server/src/services/auto-mode-service.ts | 231 +++++++++++------- apps/ui/src/app.tsx | 18 +- .../appearance/appearance-section.tsx | 36 ++- apps/ui/src/hooks/use-settings-migration.ts | 3 + apps/ui/src/hooks/use-settings-sync.ts | 2 + apps/ui/src/store/app-store.ts | 10 + libs/types/src/settings.ts | 5 + 7 files changed, 211 insertions(+), 94 deletions(-) diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 1f5407c8..8715278b 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -233,6 +233,7 @@ interface RunningFeature { abortController: AbortController; isAutoMode: boolean; startTime: number; + leaseCount: number; model?: string; provider?: ModelProvider; } @@ -334,6 +335,54 @@ export class AutoModeService { this.settingsService = settingsService ?? null; } + private acquireRunningFeature(params: { + featureId: string; + projectPath: string; + isAutoMode: boolean; + allowReuse?: boolean; + abortController?: AbortController; + }): RunningFeature { + const existing = this.runningFeatures.get(params.featureId); + if (existing) { + if (!params.allowReuse) { + throw new Error('already running'); + } + existing.leaseCount = (existing.leaseCount ?? 1) + 1; + return existing; + } + + const abortController = params.abortController ?? new AbortController(); + const entry: RunningFeature = { + featureId: params.featureId, + projectPath: params.projectPath, + worktreePath: null, + branchName: null, + abortController, + isAutoMode: params.isAutoMode, + startTime: Date.now(), + leaseCount: 1, + }; + this.runningFeatures.set(params.featureId, entry); + return entry; + } + + private releaseRunningFeature(featureId: string, options?: { force?: boolean }): void { + const entry = this.runningFeatures.get(featureId); + if (!entry) { + return; + } + + if (options?.force) { + this.runningFeatures.delete(featureId); + return; + } + + entry.leaseCount = (entry.leaseCount ?? 1) - 1; + if (entry.leaseCount <= 0) { + this.runningFeatures.delete(featureId); + } + } + /** * Track a failure and check if we should pause due to consecutive failures. * This handles cases where the SDK doesn't return useful error messages. @@ -1076,24 +1125,17 @@ export class AutoModeService { providedWorktreePath?: string, options?: { continuationPrompt?: string; + /** Internal flag: set to true when called from a method that already tracks the feature */ + _calledInternally?: boolean; } ): Promise { - if (this.runningFeatures.has(featureId)) { - throw new Error('already running'); - } - - // Add to running features immediately to prevent race conditions - const abortController = new AbortController(); - const tempRunningFeature: RunningFeature = { + const tempRunningFeature = this.acquireRunningFeature({ featureId, projectPath, - worktreePath: null, - branchName: null, - abortController, isAutoMode, - startTime: Date.now(), - }; - this.runningFeatures.set(featureId, tempRunningFeature); + allowReuse: options?._calledInternally, + }); + const abortController = tempRunningFeature.abortController; // Save execution state when feature starts if (isAutoMode) { @@ -1130,9 +1172,8 @@ export class AutoModeService { continuationPrompt = continuationPrompt.replace(/\{\{approvedPlan\}\}/g, planContent); // Recursively call executeFeature with the continuation prompt - // Remove from running features temporarily, it will be added back - this.runningFeatures.delete(featureId); - return this.executeFeature( + // Feature is already tracked, the recursive call will reuse the entry + return await this.executeFeature( projectPath, featureId, useWorktrees, @@ -1140,6 +1181,7 @@ export class AutoModeService { providedWorktreePath, { continuationPrompt, + _calledInternally: true, } ); } @@ -1149,9 +1191,8 @@ export class AutoModeService { logger.info( `Feature ${featureId} has existing context, resuming instead of starting fresh` ); - // Remove from running features temporarily, resumeFeature will add it back - this.runningFeatures.delete(featureId); - return this.resumeFeature(projectPath, featureId, useWorktrees); + // Feature is already tracked, resumeFeature will reuse the entry + return await this.resumeFeature(projectPath, featureId, useWorktrees, true); } } @@ -1401,7 +1442,7 @@ export class AutoModeService { logger.info( `Pending approvals at cleanup: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}` ); - this.runningFeatures.delete(featureId); + this.releaseRunningFeature(featureId); // Update execution state after feature completes if (this.autoLoopRunning && projectPath) { @@ -1581,7 +1622,7 @@ Complete the pipeline step instructions above. Review the previous work and appl // Remove from running features immediately to allow resume // The abort signal will still propagate to stop any ongoing execution - this.runningFeatures.delete(featureId); + this.releaseRunningFeature(featureId, { force: true }); return true; } @@ -1589,50 +1630,67 @@ Complete the pipeline step instructions above. Review the previous work and appl /** * Resume a feature (continues from saved context) */ - async resumeFeature(projectPath: string, featureId: string, useWorktrees = false): Promise { - if (this.runningFeatures.has(featureId)) { - throw new Error('already running'); - } - - // Load feature to check status - const feature = await this.loadFeature(projectPath, featureId); - if (!feature) { - throw new Error(`Feature ${featureId} not found`); - } - - // Check if feature is stuck in a pipeline step - const pipelineInfo = await this.detectPipelineStatus( - projectPath, + async resumeFeature( + projectPath: string, + featureId: string, + useWorktrees = false, + /** Internal flag: set to true when called from a method that already tracks the feature */ + _calledInternally = false + ): Promise { + this.acquireRunningFeature({ featureId, - (feature.status || '') as FeatureStatusWithPipeline - ); + projectPath, + isAutoMode: false, + allowReuse: _calledInternally, + }); - if (pipelineInfo.isPipeline) { - // Feature stuck in pipeline - use pipeline resume - return this.resumePipelineFeature(projectPath, feature, useWorktrees, pipelineInfo); - } - - // Normal resume flow for non-pipeline features - // Check if context exists in .automaker directory - const featureDir = getFeatureDir(projectPath, featureId); - const contextPath = path.join(featureDir, 'agent-output.md'); - - let hasContext = false; try { - await secureFs.access(contextPath); - hasContext = true; - } catch { - // No context - } + // Load feature to check status + const feature = await this.loadFeature(projectPath, featureId); + if (!feature) { + throw new Error(`Feature ${featureId} not found`); + } - if (hasContext) { - // Load previous context and continue - const context = (await secureFs.readFile(contextPath, 'utf-8')) as string; - return this.executeFeatureWithContext(projectPath, featureId, context, useWorktrees); - } + // Check if feature is stuck in a pipeline step + const pipelineInfo = await this.detectPipelineStatus( + projectPath, + featureId, + (feature.status || '') as FeatureStatusWithPipeline + ); - // No context, start fresh - executeFeature will handle adding to runningFeatures - return this.executeFeature(projectPath, featureId, useWorktrees, false); + if (pipelineInfo.isPipeline) { + // Feature stuck in pipeline - use pipeline resume + // Pass _alreadyTracked to prevent double-tracking + return await this.resumePipelineFeature(projectPath, feature, useWorktrees, pipelineInfo); + } + + // Normal resume flow for non-pipeline features + // Check if context exists in .automaker directory + const featureDir = getFeatureDir(projectPath, featureId); + const contextPath = path.join(featureDir, 'agent-output.md'); + + let hasContext = false; + try { + await secureFs.access(contextPath); + hasContext = true; + } catch { + // No context + } + + if (hasContext) { + // Load previous context and continue + // executeFeatureWithContext -> executeFeature will see feature is already tracked + const context = (await secureFs.readFile(contextPath, 'utf-8')) as string; + return await this.executeFeatureWithContext(projectPath, featureId, context, useWorktrees); + } + + // No context, start fresh - executeFeature will see feature is already tracked + return await this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, { + _calledInternally: true, + }); + } finally { + this.releaseRunningFeature(featureId); + } } /** @@ -1682,7 +1740,9 @@ Complete the pipeline step instructions above. Review the previous work and appl // Reset status to in_progress and start fresh await this.updateFeatureStatus(projectPath, featureId, 'in_progress'); - return this.executeFeature(projectPath, featureId, useWorktrees, false); + return this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, { + _calledInternally: true, + }); } // Edge Case 2: Step no longer exists in pipeline config @@ -1828,17 +1888,14 @@ Complete the pipeline step instructions above. Review the previous work and appl `[AutoMode] Resuming pipeline for feature ${featureId} from step ${startFromStepIndex + 1}/${sortedSteps.length}` ); - // Add to running features immediately - const abortController = new AbortController(); - this.runningFeatures.set(featureId, { + const runningEntry = this.acquireRunningFeature({ featureId, projectPath, - worktreePath: null, // Will be set below - branchName: feature.branchName ?? null, - abortController, isAutoMode: false, - startTime: Date.now(), + allowReuse: true, }); + const abortController = runningEntry.abortController; + runningEntry.branchName = feature.branchName ?? null; try { // Validate project path @@ -1863,11 +1920,8 @@ Complete the pipeline step instructions above. Review the previous work and appl validateWorkingDirectory(workDir); // Update running feature with worktree info - const runningFeature = this.runningFeatures.get(featureId); - if (runningFeature) { - runningFeature.worktreePath = worktreePath; - runningFeature.branchName = branchName ?? null; - } + runningEntry.worktreePath = worktreePath; + runningEntry.branchName = branchName ?? null; // Emit resume event this.emitAutoModeEvent('auto_mode_feature_start', { @@ -1945,7 +1999,7 @@ Complete the pipeline step instructions above. Review the previous work and appl }); } } finally { - this.runningFeatures.delete(featureId); + this.releaseRunningFeature(featureId); } } @@ -1962,11 +2016,12 @@ Complete the pipeline step instructions above. Review the previous work and appl // Validate project path early for fast failure validateWorkingDirectory(projectPath); - if (this.runningFeatures.has(featureId)) { - throw new Error(`Feature ${featureId} is already running`); - } - - const abortController = new AbortController(); + const runningEntry = this.acquireRunningFeature({ + featureId, + projectPath, + isAutoMode: false, + }); + const abortController = runningEntry.abortController; // Load feature info for context FIRST to get branchName const feature = await this.loadFeature(projectPath, featureId); @@ -2048,17 +2103,10 @@ Address the follow-up instructions above. Review the previous work and make the const provider = ProviderFactory.getProviderNameForModel(model); logger.info(`Follow-up for feature ${featureId} using model: ${model}, provider: ${provider}`); - this.runningFeatures.set(featureId, { - featureId, - projectPath, - worktreePath, - branchName, - abortController, - isAutoMode: false, - startTime: Date.now(), - model, - provider, - }); + runningEntry.worktreePath = worktreePath; + runningEntry.branchName = branchName; + runningEntry.model = model; + runningEntry.provider = provider; try { // Update feature status to in_progress BEFORE emitting event @@ -2206,7 +2254,7 @@ Address the follow-up instructions above. Review the previous work and make the } } } finally { - this.runningFeatures.delete(featureId); + this.releaseRunningFeature(featureId); } } @@ -4225,6 +4273,7 @@ After generating the revised spec, output: return this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, { continuationPrompt: prompt, + _calledInternally: true, }); } diff --git a/apps/ui/src/app.tsx b/apps/ui/src/app.tsx index 814dd38e..b2cb1525 100644 --- a/apps/ui/src/app.tsx +++ b/apps/ui/src/app.tsx @@ -6,14 +6,25 @@ import { SplashScreen } from './components/splash-screen'; import { useSettingsSync } from './hooks/use-settings-sync'; import { useCursorStatusInit } from './hooks/use-cursor-status-init'; import { useProviderAuthInit } from './hooks/use-provider-auth-init'; +import { useAppStore } from './store/app-store'; import './styles/global.css'; import './styles/theme-imports'; import './styles/font-imports'; const logger = createLogger('App'); +// Key for localStorage to persist splash screen preference +const DISABLE_SPLASH_KEY = 'automaker-disable-splash'; + export default function App() { + const disableSplashScreen = useAppStore((state) => state.disableSplashScreen); + const [showSplash, setShowSplash] = useState(() => { + // Check localStorage for user preference (available synchronously) + const savedPreference = localStorage.getItem(DISABLE_SPLASH_KEY); + if (savedPreference === 'true') { + return false; + } // Only show splash once per session if (sessionStorage.getItem('automaker-splash-shown')) { return false; @@ -21,6 +32,11 @@ export default function App() { return true; }); + // Sync the disableSplashScreen setting to localStorage for fast access on next startup + useEffect(() => { + localStorage.setItem(DISABLE_SPLASH_KEY, String(disableSplashScreen)); + }, [disableSplashScreen]); + // Clear accumulated PerformanceMeasure entries to prevent memory leak in dev mode // React's internal scheduler creates performance marks/measures that accumulate without cleanup useEffect(() => { @@ -61,7 +77,7 @@ export default function App() { return ( <> - {showSplash && } + {showSplash && !disableSplashScreen && } ); } diff --git a/apps/ui/src/components/views/settings-view/appearance/appearance-section.tsx b/apps/ui/src/components/views/settings-view/appearance/appearance-section.tsx index f449140b..d2c27cf8 100644 --- a/apps/ui/src/components/views/settings-view/appearance/appearance-section.tsx +++ b/apps/ui/src/components/views/settings-view/appearance/appearance-section.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { Label } from '@/components/ui/label'; -import { Palette, Moon, Sun, Type } from 'lucide-react'; +import { Switch } from '@/components/ui/switch'; +import { Palette, Moon, Sun, Type, Sparkles } from 'lucide-react'; import { darkThemes, lightThemes } from '@/config/theme-options'; import { UI_SANS_FONT_OPTIONS, @@ -18,7 +19,14 @@ interface AppearanceSectionProps { } export function AppearanceSection({ effectiveTheme, onThemeChange }: AppearanceSectionProps) { - const { fontFamilySans, fontFamilyMono, setFontSans, setFontMono } = useAppStore(); + const { + fontFamilySans, + fontFamilyMono, + setFontSans, + setFontMono, + disableSplashScreen, + setDisableSplashScreen, + } = useAppStore(); // Determine if current theme is light or dark const isLightTheme = lightThemes.some((t) => t.value === effectiveTheme); @@ -189,6 +197,30 @@ export function AppearanceSection({ effectiveTheme, onThemeChange }: AppearanceS + + {/* Splash Screen Section */} +
+
+ + +
+ +
+
+ +

+ Skip the animated splash screen when the app starts +

+
+ +
+
); diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 7398aece..05a692a9 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -181,6 +181,7 @@ export function parseLocalStorageSettings(): Partial | null { defaultPlanningMode: state.defaultPlanningMode as GlobalSettings['defaultPlanningMode'], defaultRequirePlanApproval: state.defaultRequirePlanApproval as boolean, muteDoneSound: state.muteDoneSound as boolean, + disableSplashScreen: state.disableSplashScreen as boolean, enhancementModel: state.enhancementModel as GlobalSettings['enhancementModel'], validationModel: state.validationModel as GlobalSettings['validationModel'], phaseModels: state.phaseModels as GlobalSettings['phaseModels'], @@ -711,6 +712,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void { model: 'claude-opus', }, muteDoneSound: settings.muteDoneSound ?? false, + disableSplashScreen: settings.disableSplashScreen ?? false, serverLogLevel: settings.serverLogLevel ?? 'info', enableRequestLogging: settings.enableRequestLogging ?? true, showQueryDevtools: settings.showQueryDevtools ?? true, @@ -798,6 +800,7 @@ function buildSettingsUpdateFromStore(): Record { defaultPlanningMode: state.defaultPlanningMode, defaultRequirePlanApproval: state.defaultRequirePlanApproval, muteDoneSound: state.muteDoneSound, + disableSplashScreen: state.disableSplashScreen, serverLogLevel: state.serverLogLevel, enableRequestLogging: state.enableRequestLogging, enhancementModel: state.enhancementModel, diff --git a/apps/ui/src/hooks/use-settings-sync.ts b/apps/ui/src/hooks/use-settings-sync.ts index 8c7d9961..ecec2571 100644 --- a/apps/ui/src/hooks/use-settings-sync.ts +++ b/apps/ui/src/hooks/use-settings-sync.ts @@ -64,6 +64,7 @@ const SETTINGS_FIELDS_TO_SYNC = [ 'defaultRequirePlanApproval', 'defaultFeatureModel', 'muteDoneSound', + 'disableSplashScreen', 'serverLogLevel', 'enableRequestLogging', 'showQueryDevtools', @@ -710,6 +711,7 @@ export async function refreshSettingsFromServer(): Promise { ? migratePhaseModelEntry(serverSettings.defaultFeatureModel) : { model: 'claude-opus' }, muteDoneSound: serverSettings.muteDoneSound, + disableSplashScreen: serverSettings.disableSplashScreen ?? false, serverLogLevel: serverSettings.serverLogLevel ?? 'info', enableRequestLogging: serverSettings.enableRequestLogging ?? true, enhancementModel: serverSettings.enhancementModel, diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 1fc95ddf..effcb65a 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -686,6 +686,9 @@ export interface AppState { // Audio Settings muteDoneSound: boolean; // When true, mute the notification sound when agents complete (default: false) + // Splash Screen Settings + disableSplashScreen: boolean; // When true, skip showing the splash screen overlay on startup + // Server Log Level Settings serverLogLevel: ServerLogLevel; // Log level for the API server (error, warn, info, debug) enableRequestLogging: boolean; // Enable HTTP request logging (Morgan) @@ -1183,6 +1186,9 @@ export interface AppActions { // Audio Settings actions setMuteDoneSound: (muted: boolean) => void; + // Splash Screen actions + setDisableSplashScreen: (disabled: boolean) => void; + // Server Log Level actions setServerLogLevel: (level: ServerLogLevel) => void; setEnableRequestLogging: (enabled: boolean) => void; @@ -1502,6 +1508,7 @@ const initialState: AppState = { worktreesByProject: {}, keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, // Default keyboard shortcuts muteDoneSound: false, // Default to sound enabled (not muted) + disableSplashScreen: false, // Default to showing splash screen serverLogLevel: 'info', // Default to info level for server logs enableRequestLogging: true, // Default to enabled for HTTP request logging showQueryDevtools: true, // Default to enabled (only shown in dev mode anyway) @@ -2626,6 +2633,9 @@ export const useAppStore = create()((set, get) => ({ // Audio Settings actions setMuteDoneSound: (muted) => set({ muteDoneSound: muted }), + // Splash Screen actions + setDisableSplashScreen: (disabled) => set({ disableSplashScreen: disabled }), + // Server Log Level actions setServerLogLevel: (level) => set({ serverLogLevel: level }), setEnableRequestLogging: (enabled) => set({ enableRequestLogging: enabled }), diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index e04110c5..a7070d7b 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -861,6 +861,10 @@ export interface GlobalSettings { /** Mute completion notification sound */ muteDoneSound: boolean; + // Splash Screen + /** Disable the splash screen overlay on app startup */ + disableSplashScreen: boolean; + // Server Logging Preferences /** Log level for the API server (error, warn, info, debug). Default: info */ serverLogLevel?: ServerLogLevel; @@ -1320,6 +1324,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { defaultRequirePlanApproval: false, defaultFeatureModel: { model: 'claude-opus' }, // Use canonical ID muteDoneSound: false, + disableSplashScreen: false, serverLogLevel: 'info', enableRequestLogging: true, showQueryDevtools: true, From 7bf02b64fa4348159a957d0bc8f3ff9f14ff7848 Mon Sep 17 00:00:00 2001 From: Shirone Date: Sat, 24 Jan 2026 15:06:14 +0100 Subject: [PATCH 02/15] fix: add proper margin between icon and green dot in auto mode menu item Fixes #672 Co-Authored-By: Claude Opus 4.5 --- .../worktree-panel/components/worktree-actions-dropdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx index 97d8da97..22710e6c 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx @@ -319,7 +319,7 @@ export function WorktreeActionsDropdown({ onToggleAutoMode(worktree)} className="text-xs"> - + Stop Auto Mode From 066ffe56397891ffb657491b517dfaf6fa6efbcb Mon Sep 17 00:00:00 2001 From: Shirone Date: Sat, 24 Jan 2026 15:26:47 +0100 Subject: [PATCH 03/15] fix: Improve spinner visibility on primary-colored backgrounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add variant prop to Spinner component to support different color contexts: - 'primary' (default): Uses text-primary for standard backgrounds - 'foreground': Uses text-primary-foreground for primary backgrounds - 'muted': Uses text-muted-foreground for subtle contexts Updated components where spinners were invisible against primary backgrounds: - TaskProgressPanel: Active task indicators now visible - Button: Auto-detects spinner variant based on button style - Various dialogs and setup views using buttons with loaders Fixes #670 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/ui/src/components/ui/button.tsx | 23 ++++++++++++++----- apps/ui/src/components/ui/spinner.tsx | 16 ++++++++++--- .../src/components/ui/task-progress-panel.tsx | 2 +- .../dialogs/merge-worktree-dialog.tsx | 2 +- .../dialogs/plan-approval-dialog.tsx | 2 +- .../src/components/views/interview-view.tsx | 2 +- apps/ui/src/components/views/login-view.tsx | 2 +- .../components/cli-installation-card.tsx | 2 +- .../setup-view/steps/claude-setup-step.tsx | 4 ++-- .../views/setup-view/steps/cli-setup-step.tsx | 4 ++-- .../setup-view/steps/cursor-setup-step.tsx | 2 +- .../setup-view/steps/opencode-setup-step.tsx | 2 +- .../setup-view/steps/providers-setup-step.tsx | 22 ++++++++++-------- 13 files changed, 55 insertions(+), 30 deletions(-) diff --git a/apps/ui/src/components/ui/button.tsx b/apps/ui/src/components/ui/button.tsx index a7163ed3..bce53665 100644 --- a/apps/ui/src/components/ui/button.tsx +++ b/apps/ui/src/components/ui/button.tsx @@ -3,7 +3,7 @@ import { Slot } from '@radix-ui/react-slot'; import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/lib/utils'; -import { Spinner } from '@/components/ui/spinner'; +import { Spinner, type SpinnerVariant } from '@/components/ui/spinner'; const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.98]", @@ -37,9 +37,19 @@ const buttonVariants = cva( } ); -// Loading spinner component -function ButtonSpinner({ className }: { className?: string }) { - return ; +/** Button variants that have colored backgrounds requiring foreground spinner color */ +const COLORED_BACKGROUND_VARIANTS = ['default', 'destructive'] as const; + +/** Get spinner variant based on button variant - use foreground for colored backgrounds */ +function getSpinnerVariant( + buttonVariant: VariantProps['variant'] +): SpinnerVariant { + // undefined defaults to 'default' variant which has a colored background + if (!buttonVariant || COLORED_BACKGROUND_VARIANTS.includes(buttonVariant as any)) { + return 'foreground'; + } + // outline, secondary, ghost, link, animated-outline use standard backgrounds + return 'primary'; } function Button({ @@ -57,6 +67,7 @@ function Button({ loading?: boolean; }) { const isDisabled = disabled || loading; + const spinnerVariant = getSpinnerVariant(variant); // Special handling for animated-outline variant if (variant === 'animated-outline' && !asChild) { @@ -83,7 +94,7 @@ function Button({ size === 'icon' && 'p-0 gap-0' )} > - {loading && } + {loading && } {children} @@ -99,7 +110,7 @@ function Button({ disabled={isDisabled} {...props} > - {loading && } + {loading && } {children} ); diff --git a/apps/ui/src/components/ui/spinner.tsx b/apps/ui/src/components/ui/spinner.tsx index c66b7684..d515dc7b 100644 --- a/apps/ui/src/components/ui/spinner.tsx +++ b/apps/ui/src/components/ui/spinner.tsx @@ -1,7 +1,8 @@ import { Loader2 } from 'lucide-react'; import { cn } from '@/lib/utils'; -type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'; +export type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'; +export type SpinnerVariant = 'primary' | 'foreground' | 'muted'; const sizeClasses: Record = { xs: 'h-3 w-3', @@ -11,9 +12,17 @@ const sizeClasses: Record = { xl: 'h-8 w-8', }; +const variantClasses: Record = { + primary: 'text-primary', + foreground: 'text-primary-foreground', + muted: 'text-muted-foreground', +}; + interface SpinnerProps { /** Size of the spinner */ size?: SpinnerSize; + /** Color variant - use 'foreground' when on primary backgrounds */ + variant?: SpinnerVariant; /** Additional class names */ className?: string; } @@ -21,11 +30,12 @@ interface SpinnerProps { /** * Themed spinner component using the primary brand color. * Use this for all loading indicators throughout the app for consistency. + * Use variant='foreground' when placing on primary-colored backgrounds. */ -export function Spinner({ size = 'md', className }: SpinnerProps) { +export function Spinner({ size = 'md', variant = 'primary', className }: SpinnerProps) { return (