Merge pull request #186 from AutoMaker-Org/theme-on-boarding

Show Theme Picker during On Boarding
This commit is contained in:
Web Dev Cody
2025-12-20 09:18:28 -05:00
committed by GitHub
53 changed files with 5561 additions and 1996 deletions

View File

@@ -8,7 +8,7 @@
>
> Automaker itself was built by a group of engineers using AI and agentic coding techniques to build features faster than ever. By leveraging tools like Cursor IDE and Claude Code CLI, the team orchestrated AI agents to implement complex functionality in days instead of weeks.
>
> **Learn how:** Master these same techniques and workflows in the [Agentic Jumpstart course](https://agenticjumpstart.com/?utm=automaker).
> **Learn how:** Master these same techniques and workflows in the [Agentic Jumpstart course](https://agenticjumpstart.com/?utm=automaker-gh).
# Automaker

View File

@@ -37,10 +37,12 @@ import {
isTerminalEnabled,
isTerminalPasswordRequired,
} from "./routes/terminal/index.js";
import { createSettingsRoutes } from "./routes/settings/index.js";
import { AgentService } from "./services/agent-service.js";
import { FeatureLoader } from "./services/feature-loader.js";
import { AutoModeService } from "./services/auto-mode-service.js";
import { getTerminalService } from "./services/terminal-service.js";
import { SettingsService } from "./services/settings-service.js";
import { createSpecRegenerationRoutes } from "./routes/app-spec/index.js";
// Load environment variables
@@ -108,6 +110,7 @@ const events: EventEmitter = createEventEmitter();
const agentService = new AgentService(DATA_DIR, events);
const featureLoader = new FeatureLoader();
const autoModeService = new AutoModeService(events);
const settingsService = new SettingsService(DATA_DIR);
// Initialize services
(async () => {
@@ -137,6 +140,7 @@ app.use("/api/running-agents", createRunningAgentsRoutes(autoModeService));
app.use("/api/workspace", createWorkspaceRoutes());
app.use("/api/templates", createTemplatesRoutes());
app.use("/api/terminal", createTerminalRoutes());
app.use("/api/settings", createSettingsRoutes(settingsService));
// Create HTTP server
const server = createServer(app);

View File

@@ -89,3 +89,38 @@ export async function ensureAutomakerDir(projectPath: string): Promise<string> {
await fs.mkdir(automakerDir, { recursive: true });
return automakerDir;
}
// ============================================================================
// Global Settings Paths (stored in DATA_DIR from app.getPath('userData'))
// ============================================================================
/**
* Get the global settings file path
* DATA_DIR is typically ~/Library/Application Support/automaker (macOS)
* or %APPDATA%\automaker (Windows) or ~/.config/automaker (Linux)
*/
export function getGlobalSettingsPath(dataDir: string): string {
return path.join(dataDir, "settings.json");
}
/**
* Get the credentials file path (separate from settings for security)
*/
export function getCredentialsPath(dataDir: string): string {
return path.join(dataDir, "credentials.json");
}
/**
* Get the project settings file path within a project's .automaker directory
*/
export function getProjectSettingsPath(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), "settings.json");
}
/**
* Ensure the global data directory exists
*/
export async function ensureDataDir(dataDir: string): Promise<string> {
await fs.mkdir(dataDir, { recursive: true });
return dataDir;
}

View File

@@ -0,0 +1,15 @@
/**
* Common utilities for settings routes
*/
import { createLogger } from "../../lib/logger.js";
import {
getErrorMessage as getErrorMessageShared,
createLogError,
} from "../common.js";
export const logger = createLogger("Settings");
// Re-export shared utilities
export { getErrorMessageShared as getErrorMessage };
export const logError = createLogError(logger);

View File

@@ -0,0 +1,38 @@
/**
* Settings routes - HTTP API for persistent file-based settings
*/
import { Router } from "express";
import type { SettingsService } from "../../services/settings-service.js";
import { createGetGlobalHandler } from "./routes/get-global.js";
import { createUpdateGlobalHandler } from "./routes/update-global.js";
import { createGetCredentialsHandler } from "./routes/get-credentials.js";
import { createUpdateCredentialsHandler } from "./routes/update-credentials.js";
import { createGetProjectHandler } from "./routes/get-project.js";
import { createUpdateProjectHandler } from "./routes/update-project.js";
import { createMigrateHandler } from "./routes/migrate.js";
import { createStatusHandler } from "./routes/status.js";
export function createSettingsRoutes(settingsService: SettingsService): Router {
const router = Router();
// Status endpoint (check if migration needed)
router.get("/status", createStatusHandler(settingsService));
// Global settings
router.get("/global", createGetGlobalHandler(settingsService));
router.put("/global", createUpdateGlobalHandler(settingsService));
// Credentials (separate for security)
router.get("/credentials", createGetCredentialsHandler(settingsService));
router.put("/credentials", createUpdateCredentialsHandler(settingsService));
// Project settings
router.post("/project", createGetProjectHandler(settingsService));
router.put("/project", createUpdateProjectHandler(settingsService));
// Migration from localStorage
router.post("/migrate", createMigrateHandler(settingsService));
return router;
}

View File

@@ -0,0 +1,23 @@
/**
* GET /api/settings/credentials - Get credentials (masked for security)
*/
import type { Request, Response } from "express";
import type { SettingsService } from "../../../services/settings-service.js";
import { getErrorMessage, logError } from "../common.js";
export function createGetCredentialsHandler(settingsService: SettingsService) {
return async (_req: Request, res: Response): Promise<void> => {
try {
const credentials = await settingsService.getMaskedCredentials();
res.json({
success: true,
credentials,
});
} catch (error) {
logError(error, "Get credentials failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,23 @@
/**
* GET /api/settings/global - Get global settings
*/
import type { Request, Response } from "express";
import type { SettingsService } from "../../../services/settings-service.js";
import { getErrorMessage, logError } from "../common.js";
export function createGetGlobalHandler(settingsService: SettingsService) {
return async (_req: Request, res: Response): Promise<void> => {
try {
const settings = await settingsService.getGlobalSettings();
res.json({
success: true,
settings,
});
} catch (error) {
logError(error, "Get global settings failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,34 @@
/**
* POST /api/settings/project - Get project settings
* Uses POST because projectPath may contain special characters
*/
import type { Request, Response } from "express";
import type { SettingsService } from "../../../services/settings-service.js";
import { getErrorMessage, logError } from "../common.js";
export function createGetProjectHandler(settingsService: SettingsService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath?: string };
if (!projectPath || typeof projectPath !== "string") {
res.status(400).json({
success: false,
error: "projectPath is required",
});
return;
}
const settings = await settingsService.getProjectSettings(projectPath);
res.json({
success: true,
settings,
});
} catch (error) {
logError(error, "Get project settings failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,54 @@
/**
* POST /api/settings/migrate - Migrate settings from localStorage
*/
import type { Request, Response } from "express";
import type { SettingsService } from "../../../services/settings-service.js";
import { getErrorMessage, logError, logger } from "../common.js";
export function createMigrateHandler(settingsService: SettingsService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { data } = req.body as {
data?: {
"automaker-storage"?: string;
"automaker-setup"?: string;
"worktree-panel-collapsed"?: string;
"file-browser-recent-folders"?: string;
"automaker:lastProjectDir"?: string;
};
};
if (!data || typeof data !== "object") {
res.status(400).json({
success: false,
error: "data object is required containing localStorage data",
});
return;
}
logger.info("Starting settings migration from localStorage");
const result = await settingsService.migrateFromLocalStorage(data);
if (result.success) {
logger.info(
`Migration successful: ${result.migratedProjectCount} projects migrated`
);
} else {
logger.warn(`Migration completed with errors: ${result.errors.join(", ")}`);
}
res.json({
success: result.success,
migratedGlobalSettings: result.migratedGlobalSettings,
migratedCredentials: result.migratedCredentials,
migratedProjectCount: result.migratedProjectCount,
errors: result.errors,
});
} catch (error) {
logError(error, "Migration failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,28 @@
/**
* GET /api/settings/status - Get settings migration status
* Returns whether settings files exist (to determine if migration is needed)
*/
import type { Request, Response } from "express";
import type { SettingsService } from "../../../services/settings-service.js";
import { getErrorMessage, logError } from "../common.js";
export function createStatusHandler(settingsService: SettingsService) {
return async (_req: Request, res: Response): Promise<void> => {
try {
const hasGlobalSettings = await settingsService.hasGlobalSettings();
const hasCredentials = await settingsService.hasCredentials();
res.json({
success: true,
hasGlobalSettings,
hasCredentials,
dataDir: settingsService.getDataDir(),
needsMigration: !hasGlobalSettings,
});
} catch (error) {
logError(error, "Get settings status failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,39 @@
/**
* PUT /api/settings/credentials - Update credentials
*/
import type { Request, Response } from "express";
import type { SettingsService } from "../../../services/settings-service.js";
import type { Credentials } from "../../../types/settings.js";
import { getErrorMessage, logError } from "../common.js";
export function createUpdateCredentialsHandler(
settingsService: SettingsService
) {
return async (req: Request, res: Response): Promise<void> => {
try {
const updates = req.body as Partial<Credentials>;
if (!updates || typeof updates !== "object") {
res.status(400).json({
success: false,
error: "Invalid request body - expected credentials object",
});
return;
}
await settingsService.updateCredentials(updates);
// Return masked credentials for confirmation
const masked = await settingsService.getMaskedCredentials();
res.json({
success: true,
credentials: masked,
});
} catch (error) {
logError(error, "Update credentials failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,34 @@
/**
* PUT /api/settings/global - Update global settings
*/
import type { Request, Response } from "express";
import type { SettingsService } from "../../../services/settings-service.js";
import type { GlobalSettings } from "../../../types/settings.js";
import { getErrorMessage, logError } from "../common.js";
export function createUpdateGlobalHandler(settingsService: SettingsService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const updates = req.body as Partial<GlobalSettings>;
if (!updates || typeof updates !== "object") {
res.status(400).json({
success: false,
error: "Invalid request body - expected settings object",
});
return;
}
const settings = await settingsService.updateGlobalSettings(updates);
res.json({
success: true,
settings,
});
} catch (error) {
logError(error, "Update global settings failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,48 @@
/**
* PUT /api/settings/project - Update project settings
*/
import type { Request, Response } from "express";
import type { SettingsService } from "../../../services/settings-service.js";
import type { ProjectSettings } from "../../../types/settings.js";
import { getErrorMessage, logError } from "../common.js";
export function createUpdateProjectHandler(settingsService: SettingsService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, updates } = req.body as {
projectPath?: string;
updates?: Partial<ProjectSettings>;
};
if (!projectPath || typeof projectPath !== "string") {
res.status(400).json({
success: false,
error: "projectPath is required",
});
return;
}
if (!updates || typeof updates !== "object") {
res.status(400).json({
success: false,
error: "updates object is required",
});
return;
}
const settings = await settingsService.updateProjectSettings(
projectPath,
updates
);
res.json({
success: true,
settings,
});
} catch (error) {
logError(error, "Update project settings failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,542 @@
/**
* Settings Service - Handles reading/writing settings to JSON files
*
* Provides persistent storage for:
* - Global settings (DATA_DIR/settings.json)
* - Credentials (DATA_DIR/credentials.json)
* - Per-project settings ({projectPath}/.automaker/settings.json)
*/
import fs from "fs/promises";
import path from "path";
import { createLogger } from "../lib/logger.js";
import {
getGlobalSettingsPath,
getCredentialsPath,
getProjectSettingsPath,
ensureDataDir,
ensureAutomakerDir,
} from "../lib/automaker-paths.js";
import type {
GlobalSettings,
Credentials,
ProjectSettings,
KeyboardShortcuts,
AIProfile,
ProjectRef,
TrashedProjectRef,
BoardBackgroundSettings,
WorktreeInfo,
} from "../types/settings.js";
import {
DEFAULT_GLOBAL_SETTINGS,
DEFAULT_CREDENTIALS,
DEFAULT_PROJECT_SETTINGS,
SETTINGS_VERSION,
CREDENTIALS_VERSION,
PROJECT_SETTINGS_VERSION,
} from "../types/settings.js";
const logger = createLogger("SettingsService");
/**
* Atomic file write - write to temp file then rename
*/
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
const tempPath = `${filePath}.tmp.${Date.now()}`;
const content = JSON.stringify(data, null, 2);
try {
await fs.writeFile(tempPath, content, "utf-8");
await fs.rename(tempPath, filePath);
} catch (error) {
// Clean up temp file if it exists
try {
await fs.unlink(tempPath);
} catch {
// Ignore cleanup errors
}
throw error;
}
}
/**
* Safely read JSON file with fallback to default
*/
async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
try {
const content = await fs.readFile(filePath, "utf-8");
return JSON.parse(content) as T;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return defaultValue;
}
logger.error(`Error reading ${filePath}:`, error);
return defaultValue;
}
}
/**
* Check if a file exists
*/
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
export class SettingsService {
private dataDir: string;
constructor(dataDir: string) {
this.dataDir = dataDir;
}
// ============================================================================
// Global Settings
// ============================================================================
/**
* Get global settings
*/
async getGlobalSettings(): Promise<GlobalSettings> {
const settingsPath = getGlobalSettingsPath(this.dataDir);
const settings = await readJsonFile<GlobalSettings>(
settingsPath,
DEFAULT_GLOBAL_SETTINGS
);
// Apply any missing defaults (for backwards compatibility)
return {
...DEFAULT_GLOBAL_SETTINGS,
...settings,
keyboardShortcuts: {
...DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
...settings.keyboardShortcuts,
},
};
}
/**
* Update global settings (partial update)
*/
async updateGlobalSettings(
updates: Partial<GlobalSettings>
): Promise<GlobalSettings> {
await ensureDataDir(this.dataDir);
const settingsPath = getGlobalSettingsPath(this.dataDir);
const current = await this.getGlobalSettings();
const updated: GlobalSettings = {
...current,
...updates,
version: SETTINGS_VERSION,
};
// Deep merge keyboard shortcuts if provided
if (updates.keyboardShortcuts) {
updated.keyboardShortcuts = {
...current.keyboardShortcuts,
...updates.keyboardShortcuts,
};
}
await atomicWriteJson(settingsPath, updated);
logger.info("Global settings updated");
return updated;
}
/**
* Check if global settings file exists
*/
async hasGlobalSettings(): Promise<boolean> {
const settingsPath = getGlobalSettingsPath(this.dataDir);
return fileExists(settingsPath);
}
// ============================================================================
// Credentials
// ============================================================================
/**
* Get credentials
*/
async getCredentials(): Promise<Credentials> {
const credentialsPath = getCredentialsPath(this.dataDir);
const credentials = await readJsonFile<Credentials>(
credentialsPath,
DEFAULT_CREDENTIALS
);
return {
...DEFAULT_CREDENTIALS,
...credentials,
apiKeys: {
...DEFAULT_CREDENTIALS.apiKeys,
...credentials.apiKeys,
},
};
}
/**
* Update credentials (partial update)
*/
async updateCredentials(
updates: Partial<Credentials>
): Promise<Credentials> {
await ensureDataDir(this.dataDir);
const credentialsPath = getCredentialsPath(this.dataDir);
const current = await this.getCredentials();
const updated: Credentials = {
...current,
...updates,
version: CREDENTIALS_VERSION,
};
// Deep merge api keys if provided
if (updates.apiKeys) {
updated.apiKeys = {
...current.apiKeys,
...updates.apiKeys,
};
}
await atomicWriteJson(credentialsPath, updated);
logger.info("Credentials updated");
return updated;
}
/**
* Get masked credentials (for UI display - don't expose full keys)
*/
async getMaskedCredentials(): Promise<{
anthropic: { configured: boolean; masked: string };
google: { configured: boolean; masked: string };
openai: { configured: boolean; masked: string };
}> {
const credentials = await this.getCredentials();
const maskKey = (key: string): string => {
if (!key || key.length < 8) return "";
return `${key.substring(0, 4)}...${key.substring(key.length - 4)}`;
};
return {
anthropic: {
configured: !!credentials.apiKeys.anthropic,
masked: maskKey(credentials.apiKeys.anthropic),
},
google: {
configured: !!credentials.apiKeys.google,
masked: maskKey(credentials.apiKeys.google),
},
openai: {
configured: !!credentials.apiKeys.openai,
masked: maskKey(credentials.apiKeys.openai),
},
};
}
/**
* Check if credentials file exists
*/
async hasCredentials(): Promise<boolean> {
const credentialsPath = getCredentialsPath(this.dataDir);
return fileExists(credentialsPath);
}
// ============================================================================
// Project Settings
// ============================================================================
/**
* Get project settings
*/
async getProjectSettings(projectPath: string): Promise<ProjectSettings> {
const settingsPath = getProjectSettingsPath(projectPath);
const settings = await readJsonFile<ProjectSettings>(
settingsPath,
DEFAULT_PROJECT_SETTINGS
);
return {
...DEFAULT_PROJECT_SETTINGS,
...settings,
};
}
/**
* Update project settings (partial update)
*/
async updateProjectSettings(
projectPath: string,
updates: Partial<ProjectSettings>
): Promise<ProjectSettings> {
await ensureAutomakerDir(projectPath);
const settingsPath = getProjectSettingsPath(projectPath);
const current = await this.getProjectSettings(projectPath);
const updated: ProjectSettings = {
...current,
...updates,
version: PROJECT_SETTINGS_VERSION,
};
// Deep merge board background if provided
if (updates.boardBackground) {
updated.boardBackground = {
...current.boardBackground,
...updates.boardBackground,
};
}
await atomicWriteJson(settingsPath, updated);
logger.info(`Project settings updated for ${projectPath}`);
return updated;
}
/**
* Check if project settings file exists
*/
async hasProjectSettings(projectPath: string): Promise<boolean> {
const settingsPath = getProjectSettingsPath(projectPath);
return fileExists(settingsPath);
}
// ============================================================================
// Migration
// ============================================================================
/**
* Migrate settings from localStorage data
* This is called when the UI detects it has localStorage data but no settings files
*/
async migrateFromLocalStorage(localStorageData: {
"automaker-storage"?: string;
"automaker-setup"?: string;
"worktree-panel-collapsed"?: string;
"file-browser-recent-folders"?: string;
"automaker:lastProjectDir"?: string;
}): Promise<{
success: boolean;
migratedGlobalSettings: boolean;
migratedCredentials: boolean;
migratedProjectCount: number;
errors: string[];
}> {
const errors: string[] = [];
let migratedGlobalSettings = false;
let migratedCredentials = false;
let migratedProjectCount = 0;
try {
// Parse the main automaker-storage
let appState: Record<string, unknown> = {};
if (localStorageData["automaker-storage"]) {
try {
const parsed = JSON.parse(localStorageData["automaker-storage"]);
appState = parsed.state || parsed;
} catch (e) {
errors.push(`Failed to parse automaker-storage: ${e}`);
}
}
// Extract global settings
const globalSettings: Partial<GlobalSettings> = {
theme: (appState.theme as GlobalSettings["theme"]) || "dark",
sidebarOpen:
appState.sidebarOpen !== undefined
? (appState.sidebarOpen as boolean)
: true,
chatHistoryOpen: (appState.chatHistoryOpen as boolean) || false,
kanbanCardDetailLevel:
(appState.kanbanCardDetailLevel as GlobalSettings["kanbanCardDetailLevel"]) ||
"standard",
maxConcurrency: (appState.maxConcurrency as number) || 3,
defaultSkipTests:
appState.defaultSkipTests !== undefined
? (appState.defaultSkipTests as boolean)
: true,
enableDependencyBlocking:
appState.enableDependencyBlocking !== undefined
? (appState.enableDependencyBlocking as boolean)
: true,
useWorktrees: (appState.useWorktrees as boolean) || false,
showProfilesOnly: (appState.showProfilesOnly as boolean) || false,
defaultPlanningMode:
(appState.defaultPlanningMode as GlobalSettings["defaultPlanningMode"]) ||
"skip",
defaultRequirePlanApproval:
(appState.defaultRequirePlanApproval as boolean) || false,
defaultAIProfileId:
(appState.defaultAIProfileId as string | null) || null,
muteDoneSound: (appState.muteDoneSound as boolean) || false,
enhancementModel:
(appState.enhancementModel as GlobalSettings["enhancementModel"]) ||
"sonnet",
keyboardShortcuts:
(appState.keyboardShortcuts as KeyboardShortcuts) ||
DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
aiProfiles: (appState.aiProfiles as AIProfile[]) || [],
projects: (appState.projects as ProjectRef[]) || [],
trashedProjects:
(appState.trashedProjects as TrashedProjectRef[]) || [],
projectHistory: (appState.projectHistory as string[]) || [],
projectHistoryIndex: (appState.projectHistoryIndex as number) || -1,
lastSelectedSessionByProject:
(appState.lastSelectedSessionByProject as Record<string, string>) ||
{},
};
// Add direct localStorage values
if (localStorageData["automaker:lastProjectDir"]) {
globalSettings.lastProjectDir =
localStorageData["automaker:lastProjectDir"];
}
if (localStorageData["file-browser-recent-folders"]) {
try {
globalSettings.recentFolders = JSON.parse(
localStorageData["file-browser-recent-folders"]
);
} catch {
globalSettings.recentFolders = [];
}
}
if (localStorageData["worktree-panel-collapsed"]) {
globalSettings.worktreePanelCollapsed =
localStorageData["worktree-panel-collapsed"] === "true";
}
// Save global settings
await this.updateGlobalSettings(globalSettings);
migratedGlobalSettings = true;
logger.info("Migrated global settings from localStorage");
// Extract and save credentials
if (appState.apiKeys) {
const apiKeys = appState.apiKeys as {
anthropic?: string;
google?: string;
openai?: string;
};
await this.updateCredentials({
apiKeys: {
anthropic: apiKeys.anthropic || "",
google: apiKeys.google || "",
openai: apiKeys.openai || "",
},
});
migratedCredentials = true;
logger.info("Migrated credentials from localStorage");
}
// Migrate per-project settings
const boardBackgroundByProject = appState.boardBackgroundByProject as
| Record<string, BoardBackgroundSettings>
| undefined;
const currentWorktreeByProject = appState.currentWorktreeByProject as
| Record<string, { path: string | null; branch: string }>
| undefined;
const worktreesByProject = appState.worktreesByProject as
| Record<string, WorktreeInfo[]>
| undefined;
// Get unique project paths that have per-project settings
const projectPaths = new Set<string>();
if (boardBackgroundByProject) {
Object.keys(boardBackgroundByProject).forEach((p) =>
projectPaths.add(p)
);
}
if (currentWorktreeByProject) {
Object.keys(currentWorktreeByProject).forEach((p) =>
projectPaths.add(p)
);
}
if (worktreesByProject) {
Object.keys(worktreesByProject).forEach((p) => projectPaths.add(p));
}
// Also check projects list for theme settings
const projects = (appState.projects as ProjectRef[]) || [];
for (const project of projects) {
if (project.theme) {
projectPaths.add(project.path);
}
}
// Migrate each project's settings
for (const projectPath of projectPaths) {
try {
const projectSettings: Partial<ProjectSettings> = {};
// Get theme from project object
const project = projects.find((p) => p.path === projectPath);
if (project?.theme) {
projectSettings.theme =
project.theme as ProjectSettings["theme"];
}
if (boardBackgroundByProject?.[projectPath]) {
projectSettings.boardBackground =
boardBackgroundByProject[projectPath];
}
if (currentWorktreeByProject?.[projectPath]) {
projectSettings.currentWorktree =
currentWorktreeByProject[projectPath];
}
if (worktreesByProject?.[projectPath]) {
projectSettings.worktrees = worktreesByProject[projectPath];
}
if (Object.keys(projectSettings).length > 0) {
await this.updateProjectSettings(projectPath, projectSettings);
migratedProjectCount++;
}
} catch (e) {
errors.push(`Failed to migrate project settings for ${projectPath}: ${e}`);
}
}
logger.info(
`Migration complete: ${migratedProjectCount} projects migrated`
);
return {
success: errors.length === 0,
migratedGlobalSettings,
migratedCredentials,
migratedProjectCount,
errors,
};
} catch (error) {
logger.error("Migration failed:", error);
errors.push(`Migration failed: ${error}`);
return {
success: false,
migratedGlobalSettings,
migratedCredentials,
migratedProjectCount,
errors,
};
}
}
/**
* Get the DATA_DIR path (for debugging/info)
*/
getDataDir(): string {
return this.dataDir;
}
}

View File

@@ -0,0 +1,269 @@
/**
* Settings Types - Shared types for file-based settings storage
*/
// Theme modes (matching UI ThemeMode type)
export type ThemeMode =
| "light"
| "dark"
| "system"
| "retro"
| "dracula"
| "nord"
| "monokai"
| "tokyonight"
| "solarized"
| "gruvbox"
| "catppuccin"
| "onedark"
| "synthwave"
| "red"
| "cream"
| "sunset"
| "gray";
export type KanbanCardDetailLevel = "minimal" | "standard" | "detailed";
export type AgentModel = "opus" | "sonnet" | "haiku";
export type PlanningMode = "skip" | "lite" | "spec" | "full";
export type ThinkingLevel = "none" | "low" | "medium" | "high" | "ultrathink";
export type ModelProvider = "claude";
// Keyboard Shortcuts
export interface KeyboardShortcuts {
board: string;
agent: string;
spec: string;
context: string;
settings: string;
profiles: string;
terminal: string;
toggleSidebar: string;
addFeature: string;
addContextFile: string;
startNext: string;
newSession: string;
openProject: string;
projectPicker: string;
cyclePrevProject: string;
cycleNextProject: string;
addProfile: string;
splitTerminalRight: string;
splitTerminalDown: string;
closeTerminal: string;
}
// AI Profile
export interface AIProfile {
id: string;
name: string;
description: string;
model: AgentModel;
thinkingLevel: ThinkingLevel;
provider: ModelProvider;
isBuiltIn: boolean;
icon?: string;
}
// Project reference (minimal info stored in global settings)
export interface ProjectRef {
id: string;
name: string;
path: string;
lastOpened?: string;
theme?: string;
}
// Trashed project reference
export interface TrashedProjectRef extends ProjectRef {
trashedAt: string;
deletedFromDisk?: boolean;
}
// Chat session (minimal info, full content can be loaded separately)
export interface ChatSessionRef {
id: string;
title: string;
projectId: string;
createdAt: string;
updatedAt: string;
archived: boolean;
}
/**
* Global Settings - stored in {DATA_DIR}/settings.json
*/
export interface GlobalSettings {
version: number;
// Theme
theme: ThemeMode;
// UI State
sidebarOpen: boolean;
chatHistoryOpen: boolean;
kanbanCardDetailLevel: KanbanCardDetailLevel;
// Feature Defaults
maxConcurrency: number;
defaultSkipTests: boolean;
enableDependencyBlocking: boolean;
useWorktrees: boolean;
showProfilesOnly: boolean;
defaultPlanningMode: PlanningMode;
defaultRequirePlanApproval: boolean;
defaultAIProfileId: string | null;
// Audio
muteDoneSound: boolean;
// Enhancement
enhancementModel: AgentModel;
// Keyboard Shortcuts
keyboardShortcuts: KeyboardShortcuts;
// AI Profiles
aiProfiles: AIProfile[];
// Projects
projects: ProjectRef[];
trashedProjects: TrashedProjectRef[];
projectHistory: string[];
projectHistoryIndex: number;
// UI Preferences (previously in direct localStorage)
lastProjectDir?: string;
recentFolders: string[];
worktreePanelCollapsed: boolean;
// Session tracking (per-project, keyed by project path)
lastSelectedSessionByProject: Record<string, string>;
}
/**
* Credentials - stored in {DATA_DIR}/credentials.json
*/
export interface Credentials {
version: number;
apiKeys: {
anthropic: string;
google: string;
openai: string;
};
}
/**
* Board Background Settings
*/
export interface BoardBackgroundSettings {
imagePath: string | null;
imageVersion?: number;
cardOpacity: number;
columnOpacity: number;
columnBorderEnabled: boolean;
cardGlassmorphism: boolean;
cardBorderEnabled: boolean;
cardBorderOpacity: number;
hideScrollbar: boolean;
}
/**
* Worktree Info
*/
export interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
/**
* Per-Project Settings - stored in {projectPath}/.automaker/settings.json
*/
export interface ProjectSettings {
version: number;
// Theme override (null = use global)
theme?: ThemeMode;
// Worktree settings
useWorktrees?: boolean;
currentWorktree?: { path: string | null; branch: string };
worktrees?: WorktreeInfo[];
// Board background
boardBackground?: BoardBackgroundSettings;
// Last selected session
lastSelectedSessionId?: string;
}
// Default values
export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
board: "K",
agent: "A",
spec: "D",
context: "C",
settings: "S",
profiles: "M",
terminal: "T",
toggleSidebar: "`",
addFeature: "N",
addContextFile: "N",
startNext: "G",
newSession: "N",
openProject: "O",
projectPicker: "P",
cyclePrevProject: "Q",
cycleNextProject: "E",
addProfile: "N",
splitTerminalRight: "Alt+D",
splitTerminalDown: "Alt+S",
closeTerminal: "Alt+W",
};
export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
version: 1,
theme: "dark",
sidebarOpen: true,
chatHistoryOpen: false,
kanbanCardDetailLevel: "standard",
maxConcurrency: 3,
defaultSkipTests: true,
enableDependencyBlocking: true,
useWorktrees: false,
showProfilesOnly: false,
defaultPlanningMode: "skip",
defaultRequirePlanApproval: false,
defaultAIProfileId: null,
muteDoneSound: false,
enhancementModel: "sonnet",
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
aiProfiles: [],
projects: [],
trashedProjects: [],
projectHistory: [],
projectHistoryIndex: -1,
lastProjectDir: undefined,
recentFolders: [],
worktreePanelCollapsed: false,
lastSelectedSessionByProject: {},
};
export const DEFAULT_CREDENTIALS: Credentials = {
version: 1,
apiKeys: {
anthropic: "",
google: "",
openai: "",
},
};
export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
version: 1,
};
export const SETTINGS_VERSION = 1;
export const CREDENTIALS_VERSION = 1;
export const PROJECT_SETTINGS_VERSION = 1;

View File

@@ -13,6 +13,10 @@ import {
getAppSpecPath,
getBranchTrackingPath,
ensureAutomakerDir,
getGlobalSettingsPath,
getCredentialsPath,
getProjectSettingsPath,
ensureDataDir,
} from "@/lib/automaker-paths.js";
describe("automaker-paths.ts", () => {
@@ -136,4 +140,91 @@ describe("automaker-paths.ts", () => {
expect(result).toBe(automakerDir);
});
});
describe("getGlobalSettingsPath", () => {
it("should return path to settings.json in data directory", () => {
const dataDir = "/test/data";
const result = getGlobalSettingsPath(dataDir);
expect(result).toBe(path.join(dataDir, "settings.json"));
});
it("should handle paths with trailing slashes", () => {
const dataDir = "/test/data" + path.sep;
const result = getGlobalSettingsPath(dataDir);
expect(result).toBe(path.join(dataDir, "settings.json"));
});
});
describe("getCredentialsPath", () => {
it("should return path to credentials.json in data directory", () => {
const dataDir = "/test/data";
const result = getCredentialsPath(dataDir);
expect(result).toBe(path.join(dataDir, "credentials.json"));
});
it("should handle paths with trailing slashes", () => {
const dataDir = "/test/data" + path.sep;
const result = getCredentialsPath(dataDir);
expect(result).toBe(path.join(dataDir, "credentials.json"));
});
});
describe("getProjectSettingsPath", () => {
it("should return path to settings.json in project .automaker directory", () => {
const projectPath = "/test/project";
const result = getProjectSettingsPath(projectPath);
expect(result).toBe(
path.join(projectPath, ".automaker", "settings.json")
);
});
it("should handle paths with trailing slashes", () => {
const projectPath = "/test/project" + path.sep;
const result = getProjectSettingsPath(projectPath);
expect(result).toBe(
path.join(projectPath, ".automaker", "settings.json")
);
});
});
describe("ensureDataDir", () => {
let testDir: string;
beforeEach(async () => {
testDir = path.join(os.tmpdir(), `data-dir-test-${Date.now()}`);
});
afterEach(async () => {
try {
await fs.rm(testDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
it("should create data directory and return path", async () => {
const result = await ensureDataDir(testDir);
expect(result).toBe(testDir);
const stats = await fs.stat(testDir);
expect(stats.isDirectory()).toBe(true);
});
it("should succeed if directory already exists", async () => {
await fs.mkdir(testDir, { recursive: true });
const result = await ensureDataDir(testDir);
expect(result).toBe(testDir);
});
it("should create nested directories", async () => {
const nestedDir = path.join(testDir, "nested", "deep");
const result = await ensureDataDir(nestedDir);
expect(result).toBe(nestedDir);
const stats = await fs.stat(nestedDir);
expect(stats.isDirectory()).toBe(true);
});
});
});

View File

@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
import {
isAbortError,
isAuthenticationError,
isCancellationError,
classifyError,
getUserFriendlyErrorMessage,
type ErrorType,
@@ -32,6 +33,34 @@ describe("error-handler.ts", () => {
});
});
describe("isCancellationError", () => {
it("should detect 'cancelled' message", () => {
expect(isCancellationError("Operation was cancelled")).toBe(true);
});
it("should detect 'canceled' message", () => {
expect(isCancellationError("Request was canceled")).toBe(true);
});
it("should detect 'stopped' message", () => {
expect(isCancellationError("Process was stopped")).toBe(true);
});
it("should detect 'aborted' message", () => {
expect(isCancellationError("Task was aborted")).toBe(true);
});
it("should be case insensitive", () => {
expect(isCancellationError("CANCELLED")).toBe(true);
expect(isCancellationError("Canceled")).toBe(true);
});
it("should return false for non-cancellation errors", () => {
expect(isCancellationError("File not found")).toBe(false);
expect(isCancellationError("Network error")).toBe(false);
});
});
describe("isAuthenticationError", () => {
it("should detect 'Authentication failed' message", () => {
expect(isAuthenticationError("Authentication failed")).toBe(true);
@@ -91,6 +120,42 @@ describe("error-handler.ts", () => {
expect(result.isAbort).toBe(true); // Still detected as abort too
});
it("should classify cancellation errors", () => {
const error = new Error("Operation was cancelled");
const result = classifyError(error);
expect(result.type).toBe("cancellation");
expect(result.isCancellation).toBe(true);
expect(result.isAbort).toBe(false);
expect(result.isAuth).toBe(false);
});
it("should prioritize abort over cancellation if both match", () => {
const error = new Error("Operation aborted");
error.name = "AbortError";
const result = classifyError(error);
expect(result.type).toBe("abort");
expect(result.isAbort).toBe(true);
expect(result.isCancellation).toBe(true); // Still detected as cancellation too
});
it("should classify cancellation errors with 'canceled' spelling", () => {
const error = new Error("Request was canceled");
const result = classifyError(error);
expect(result.type).toBe("cancellation");
expect(result.isCancellation).toBe(true);
});
it("should classify cancellation errors with 'stopped' message", () => {
const error = new Error("Process was stopped");
const result = classifyError(error);
expect(result.type).toBe("cancellation");
expect(result.isCancellation).toBe(true);
});
it("should classify generic Error as execution error", () => {
const error = new Error("Something went wrong");
const result = classifyError(error);

View File

@@ -144,6 +144,40 @@ describe("sdk-options.ts", () => {
expect(options.maxTurns).toBe(MAX_TURNS.extended);
expect(options.allowedTools).toEqual([...TOOL_PRESETS.readOnly]);
});
it("should include systemPrompt when provided", async () => {
const { createSuggestionsOptions } = await import("@/lib/sdk-options.js");
const options = createSuggestionsOptions({
cwd: "/test/path",
systemPrompt: "Custom prompt",
});
expect(options.systemPrompt).toBe("Custom prompt");
});
it("should include abortController when provided", async () => {
const { createSuggestionsOptions } = await import("@/lib/sdk-options.js");
const abortController = new AbortController();
const options = createSuggestionsOptions({
cwd: "/test/path",
abortController,
});
expect(options.abortController).toBe(abortController);
});
it("should include outputFormat when provided", async () => {
const { createSuggestionsOptions } = await import("@/lib/sdk-options.js");
const options = createSuggestionsOptions({
cwd: "/test/path",
outputFormat: { type: "json" },
});
expect(options.outputFormat).toEqual({ type: "json" });
});
});
describe("createChatOptions", () => {
@@ -205,6 +239,29 @@ describe("sdk-options.ts", () => {
autoAllowBashIfSandboxed: true,
});
});
it("should include systemPrompt when provided", async () => {
const { createAutoModeOptions } = await import("@/lib/sdk-options.js");
const options = createAutoModeOptions({
cwd: "/test/path",
systemPrompt: "Custom prompt",
});
expect(options.systemPrompt).toBe("Custom prompt");
});
it("should include abortController when provided", async () => {
const { createAutoModeOptions } = await import("@/lib/sdk-options.js");
const abortController = new AbortController();
const options = createAutoModeOptions({
cwd: "/test/path",
abortController,
});
expect(options.abortController).toBe(abortController);
});
});
describe("createCustomOptions", () => {
@@ -234,5 +291,42 @@ describe("sdk-options.ts", () => {
expect(options.maxTurns).toBe(MAX_TURNS.maximum);
expect(options.allowedTools).toEqual([...TOOL_PRESETS.readOnly]);
});
it("should include sandbox when provided", async () => {
const { createCustomOptions } = await import("@/lib/sdk-options.js");
const options = createCustomOptions({
cwd: "/test/path",
sandbox: { enabled: true, autoAllowBashIfSandboxed: false },
});
expect(options.sandbox).toEqual({
enabled: true,
autoAllowBashIfSandboxed: false,
});
});
it("should include systemPrompt when provided", async () => {
const { createCustomOptions } = await import("@/lib/sdk-options.js");
const options = createCustomOptions({
cwd: "/test/path",
systemPrompt: "Custom prompt",
});
expect(options.systemPrompt).toBe("Custom prompt");
});
it("should include abortController when provided", async () => {
const { createCustomOptions } = await import("@/lib/sdk-options.js");
const abortController = new AbortController();
const options = createCustomOptions({
cwd: "/test/path",
abortController,
});
expect(options.abortController).toBe(abortController);
});
});
});

View File

@@ -53,9 +53,24 @@ describe("security.ts", () => {
expect(allowed).toContain(path.resolve("/data/dir"));
});
it("should include WORKSPACE_DIR if set", async () => {
process.env.ALLOWED_PROJECT_DIRS = "";
process.env.DATA_DIR = "";
process.env.WORKSPACE_DIR = "/workspace/dir";
const { initAllowedPaths, getAllowedPaths } = await import(
"@/lib/security.js"
);
initAllowedPaths();
const allowed = getAllowedPaths();
expect(allowed).toContain(path.resolve("/workspace/dir"));
});
it("should handle empty ALLOWED_PROJECT_DIRS", async () => {
process.env.ALLOWED_PROJECT_DIRS = "";
process.env.DATA_DIR = "/data";
delete process.env.WORKSPACE_DIR;
const { initAllowedPaths, getAllowedPaths } = await import(
"@/lib/security.js"
@@ -70,6 +85,7 @@ describe("security.ts", () => {
it("should skip empty entries in comma list", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/path1,,/path2, ,/path3";
process.env.DATA_DIR = "";
delete process.env.WORKSPACE_DIR;
const { initAllowedPaths, getAllowedPaths } = await import(
"@/lib/security.js"

View File

@@ -264,9 +264,66 @@ describe("subprocess-manager.ts", () => {
);
});
// Note: Timeout behavior tests are omitted from unit tests as they involve
// complex timing interactions that are difficult to mock reliably.
// These scenarios are better covered by integration tests with real subprocesses.
// Note: Timeout behavior is difficult to test reliably with mocks due to
// timing interactions. The timeout functionality is covered by integration tests.
// The error handling path (lines 117-118) is tested below.
it("should reset timeout when output is received", async () => {
vi.useFakeTimers();
const mockProcess = createMockProcess({
stdoutLines: [
'{"type":"first"}',
'{"type":"second"}',
'{"type":"third"}',
],
exitCode: 0,
delayMs: 50,
});
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
const generator = spawnJSONLProcess({
...baseOptions,
timeout: 200,
});
const promise = collectAsyncGenerator(generator);
// Advance time but not enough to trigger timeout
await vi.advanceTimersByTimeAsync(150);
// Process should not be killed yet
expect(mockProcess.kill).not.toHaveBeenCalled();
vi.useRealTimers();
await promise;
});
it("should handle errors when reading stdout", async () => {
const mockProcess = new EventEmitter() as any;
const stdout = new Readable({
read() {
// Emit an error after a short delay
setTimeout(() => {
this.emit("error", new Error("Read error"));
}, 10);
},
});
const stderr = new Readable({ read() {} });
mockProcess.stdout = stdout;
mockProcess.stderr = stderr;
mockProcess.kill = vi.fn();
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
const generator = spawnJSONLProcess(baseOptions);
await expect(collectAsyncGenerator(generator)).rejects.toThrow("Read error");
expect(consoleSpy.error).toHaveBeenCalledWith(
expect.stringContaining("Error reading stdout"),
expect.any(Error)
);
});
it("should spawn process with correct arguments", async () => {
const mockProcess = createMockProcess({ exitCode: 0 });

View File

@@ -66,6 +66,32 @@ describe("worktree-metadata.ts", () => {
const result = await readWorktreeMetadata(testProjectPath, branch);
expect(result).toEqual(metadata);
});
it("should handle empty branch name", async () => {
const branch = "";
const metadata: WorktreeMetadata = {
branch: "branch",
createdAt: new Date().toISOString(),
};
// Empty branch name should be sanitized to "_branch"
await writeWorktreeMetadata(testProjectPath, branch, metadata);
const result = await readWorktreeMetadata(testProjectPath, branch);
expect(result).toEqual(metadata);
});
it("should handle branch name that becomes empty after sanitization", async () => {
// Test branch that would become empty after removing invalid chars
const branch = "///";
const metadata: WorktreeMetadata = {
branch: "branch",
createdAt: new Date().toISOString(),
};
await writeWorktreeMetadata(testProjectPath, branch, metadata);
const result = await readWorktreeMetadata(testProjectPath, branch);
expect(result).toEqual(metadata);
});
});
describe("readWorktreeMetadata", () => {

View File

@@ -234,6 +234,30 @@ describe("claude-provider.ts", () => {
}),
});
});
it("should handle errors during execution and rethrow", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const testError = new Error("SDK execution failed");
vi.mocked(sdk.query).mockReturnValue(
(async function* () {
throw testError;
})()
);
const generator = provider.executeQuery({
prompt: "Test",
cwd: "/test",
});
await expect(collectAsyncGenerator(generator)).rejects.toThrow("SDK execution failed");
expect(consoleErrorSpy).toHaveBeenCalledWith(
"[ClaudeProvider] executeQuery() error during execution:",
testError
);
consoleErrorSpy.mockRestore();
});
});
describe("detectInstallation", () => {

View File

@@ -0,0 +1,643 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import fs from "fs/promises";
import path from "path";
import os from "os";
import { SettingsService } from "@/services/settings-service.js";
import {
DEFAULT_GLOBAL_SETTINGS,
DEFAULT_CREDENTIALS,
DEFAULT_PROJECT_SETTINGS,
SETTINGS_VERSION,
CREDENTIALS_VERSION,
PROJECT_SETTINGS_VERSION,
type GlobalSettings,
type Credentials,
type ProjectSettings,
} from "@/types/settings.js";
describe("settings-service.ts", () => {
let testDataDir: string;
let testProjectDir: string;
let settingsService: SettingsService;
beforeEach(async () => {
testDataDir = path.join(os.tmpdir(), `settings-test-${Date.now()}`);
testProjectDir = path.join(os.tmpdir(), `project-test-${Date.now()}`);
await fs.mkdir(testDataDir, { recursive: true });
await fs.mkdir(testProjectDir, { recursive: true });
settingsService = new SettingsService(testDataDir);
});
afterEach(async () => {
try {
await fs.rm(testDataDir, { recursive: true, force: true });
await fs.rm(testProjectDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe("getGlobalSettings", () => {
it("should return default settings when file does not exist", async () => {
const settings = await settingsService.getGlobalSettings();
expect(settings).toEqual(DEFAULT_GLOBAL_SETTINGS);
});
it("should read and return existing settings", async () => {
const customSettings: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS,
theme: "light",
sidebarOpen: false,
maxConcurrency: 5,
};
const settingsPath = path.join(testDataDir, "settings.json");
await fs.writeFile(settingsPath, JSON.stringify(customSettings, null, 2));
const settings = await settingsService.getGlobalSettings();
expect(settings.theme).toBe("light");
expect(settings.sidebarOpen).toBe(false);
expect(settings.maxConcurrency).toBe(5);
});
it("should merge with defaults for missing properties", async () => {
const partialSettings = {
version: SETTINGS_VERSION,
theme: "dark",
};
const settingsPath = path.join(testDataDir, "settings.json");
await fs.writeFile(settingsPath, JSON.stringify(partialSettings, null, 2));
const settings = await settingsService.getGlobalSettings();
expect(settings.theme).toBe("dark");
expect(settings.sidebarOpen).toBe(DEFAULT_GLOBAL_SETTINGS.sidebarOpen);
expect(settings.maxConcurrency).toBe(DEFAULT_GLOBAL_SETTINGS.maxConcurrency);
});
it("should merge keyboard shortcuts deeply", async () => {
const customSettings: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS,
keyboardShortcuts: {
...DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
board: "B",
},
};
const settingsPath = path.join(testDataDir, "settings.json");
await fs.writeFile(settingsPath, JSON.stringify(customSettings, null, 2));
const settings = await settingsService.getGlobalSettings();
expect(settings.keyboardShortcuts.board).toBe("B");
expect(settings.keyboardShortcuts.agent).toBe(
DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts.agent
);
});
});
describe("updateGlobalSettings", () => {
it("should create settings file with updates", async () => {
const updates: Partial<GlobalSettings> = {
theme: "light",
sidebarOpen: false,
};
const updated = await settingsService.updateGlobalSettings(updates);
expect(updated.theme).toBe("light");
expect(updated.sidebarOpen).toBe(false);
expect(updated.version).toBe(SETTINGS_VERSION);
const settingsPath = path.join(testDataDir, "settings.json");
const fileContent = await fs.readFile(settingsPath, "utf-8");
const saved = JSON.parse(fileContent);
expect(saved.theme).toBe("light");
expect(saved.sidebarOpen).toBe(false);
});
it("should merge updates with existing settings", async () => {
const initial: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS,
theme: "dark",
maxConcurrency: 3,
};
const settingsPath = path.join(testDataDir, "settings.json");
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
const updates: Partial<GlobalSettings> = {
theme: "light",
};
const updated = await settingsService.updateGlobalSettings(updates);
expect(updated.theme).toBe("light");
expect(updated.maxConcurrency).toBe(3); // Preserved from initial
});
it("should deep merge keyboard shortcuts", async () => {
const updates: Partial<GlobalSettings> = {
keyboardShortcuts: {
board: "B",
},
};
const updated = await settingsService.updateGlobalSettings(updates);
expect(updated.keyboardShortcuts.board).toBe("B");
expect(updated.keyboardShortcuts.agent).toBe(
DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts.agent
);
});
it("should create data directory if it does not exist", async () => {
const newDataDir = path.join(os.tmpdir(), `new-data-dir-${Date.now()}`);
const newService = new SettingsService(newDataDir);
await newService.updateGlobalSettings({ theme: "light" });
const stats = await fs.stat(newDataDir);
expect(stats.isDirectory()).toBe(true);
await fs.rm(newDataDir, { recursive: true, force: true });
});
});
describe("hasGlobalSettings", () => {
it("should return false when settings file does not exist", async () => {
const exists = await settingsService.hasGlobalSettings();
expect(exists).toBe(false);
});
it("should return true when settings file exists", async () => {
await settingsService.updateGlobalSettings({ theme: "light" });
const exists = await settingsService.hasGlobalSettings();
expect(exists).toBe(true);
});
});
describe("getCredentials", () => {
it("should return default credentials when file does not exist", async () => {
const credentials = await settingsService.getCredentials();
expect(credentials).toEqual(DEFAULT_CREDENTIALS);
});
it("should read and return existing credentials", async () => {
const customCredentials: Credentials = {
...DEFAULT_CREDENTIALS,
apiKeys: {
anthropic: "sk-test-key",
google: "",
openai: "",
},
};
const credentialsPath = path.join(testDataDir, "credentials.json");
await fs.writeFile(credentialsPath, JSON.stringify(customCredentials, null, 2));
const credentials = await settingsService.getCredentials();
expect(credentials.apiKeys.anthropic).toBe("sk-test-key");
});
it("should merge with defaults for missing api keys", async () => {
const partialCredentials = {
version: CREDENTIALS_VERSION,
apiKeys: {
anthropic: "sk-test",
},
};
const credentialsPath = path.join(testDataDir, "credentials.json");
await fs.writeFile(credentialsPath, JSON.stringify(partialCredentials, null, 2));
const credentials = await settingsService.getCredentials();
expect(credentials.apiKeys.anthropic).toBe("sk-test");
expect(credentials.apiKeys.google).toBe("");
expect(credentials.apiKeys.openai).toBe("");
});
});
describe("updateCredentials", () => {
it("should create credentials file with updates", async () => {
const updates: Partial<Credentials> = {
apiKeys: {
anthropic: "sk-test-key",
google: "",
openai: "",
},
};
const updated = await settingsService.updateCredentials(updates);
expect(updated.apiKeys.anthropic).toBe("sk-test-key");
expect(updated.version).toBe(CREDENTIALS_VERSION);
const credentialsPath = path.join(testDataDir, "credentials.json");
const fileContent = await fs.readFile(credentialsPath, "utf-8");
const saved = JSON.parse(fileContent);
expect(saved.apiKeys.anthropic).toBe("sk-test-key");
});
it("should merge updates with existing credentials", async () => {
const initial: Credentials = {
...DEFAULT_CREDENTIALS,
apiKeys: {
anthropic: "sk-initial",
google: "google-key",
openai: "",
},
};
const credentialsPath = path.join(testDataDir, "credentials.json");
await fs.writeFile(credentialsPath, JSON.stringify(initial, null, 2));
const updates: Partial<Credentials> = {
apiKeys: {
anthropic: "sk-updated",
},
};
const updated = await settingsService.updateCredentials(updates);
expect(updated.apiKeys.anthropic).toBe("sk-updated");
expect(updated.apiKeys.google).toBe("google-key"); // Preserved
});
it("should deep merge api keys", async () => {
const initial: Credentials = {
...DEFAULT_CREDENTIALS,
apiKeys: {
anthropic: "sk-anthropic",
google: "google-key",
openai: "openai-key",
},
};
const credentialsPath = path.join(testDataDir, "credentials.json");
await fs.writeFile(credentialsPath, JSON.stringify(initial, null, 2));
const updates: Partial<Credentials> = {
apiKeys: {
openai: "new-openai-key",
},
};
const updated = await settingsService.updateCredentials(updates);
expect(updated.apiKeys.anthropic).toBe("sk-anthropic");
expect(updated.apiKeys.google).toBe("google-key");
expect(updated.apiKeys.openai).toBe("new-openai-key");
});
});
describe("getMaskedCredentials", () => {
it("should return masked credentials for empty keys", async () => {
const masked = await settingsService.getMaskedCredentials();
expect(masked.anthropic.configured).toBe(false);
expect(masked.anthropic.masked).toBe("");
expect(masked.google.configured).toBe(false);
expect(masked.openai.configured).toBe(false);
});
it("should mask keys correctly", async () => {
await settingsService.updateCredentials({
apiKeys: {
anthropic: "sk-ant-api03-1234567890abcdef",
google: "AIzaSy1234567890abcdef",
openai: "sk-1234567890abcdef",
},
});
const masked = await settingsService.getMaskedCredentials();
expect(masked.anthropic.configured).toBe(true);
expect(masked.anthropic.masked).toBe("sk-a...cdef");
expect(masked.google.configured).toBe(true);
expect(masked.google.masked).toBe("AIza...cdef");
expect(masked.openai.configured).toBe(true);
expect(masked.openai.masked).toBe("sk-1...cdef");
});
it("should handle short keys", async () => {
await settingsService.updateCredentials({
apiKeys: {
anthropic: "short",
google: "",
openai: "",
},
});
const masked = await settingsService.getMaskedCredentials();
expect(masked.anthropic.configured).toBe(true);
expect(masked.anthropic.masked).toBe("");
});
});
describe("hasCredentials", () => {
it("should return false when credentials file does not exist", async () => {
const exists = await settingsService.hasCredentials();
expect(exists).toBe(false);
});
it("should return true when credentials file exists", async () => {
await settingsService.updateCredentials({
apiKeys: { anthropic: "test", google: "", openai: "" },
});
const exists = await settingsService.hasCredentials();
expect(exists).toBe(true);
});
});
describe("getProjectSettings", () => {
it("should return default settings when file does not exist", async () => {
const settings = await settingsService.getProjectSettings(testProjectDir);
expect(settings).toEqual(DEFAULT_PROJECT_SETTINGS);
});
it("should read and return existing project settings", async () => {
const customSettings: ProjectSettings = {
...DEFAULT_PROJECT_SETTINGS,
theme: "light",
useWorktrees: true,
};
const automakerDir = path.join(testProjectDir, ".automaker");
await fs.mkdir(automakerDir, { recursive: true });
const settingsPath = path.join(automakerDir, "settings.json");
await fs.writeFile(settingsPath, JSON.stringify(customSettings, null, 2));
const settings = await settingsService.getProjectSettings(testProjectDir);
expect(settings.theme).toBe("light");
expect(settings.useWorktrees).toBe(true);
});
it("should merge with defaults for missing properties", async () => {
const partialSettings = {
version: PROJECT_SETTINGS_VERSION,
theme: "dark",
};
const automakerDir = path.join(testProjectDir, ".automaker");
await fs.mkdir(automakerDir, { recursive: true });
const settingsPath = path.join(automakerDir, "settings.json");
await fs.writeFile(settingsPath, JSON.stringify(partialSettings, null, 2));
const settings = await settingsService.getProjectSettings(testProjectDir);
expect(settings.theme).toBe("dark");
expect(settings.version).toBe(PROJECT_SETTINGS_VERSION);
});
});
describe("updateProjectSettings", () => {
it("should create project settings file with updates", async () => {
const updates: Partial<ProjectSettings> = {
theme: "light",
useWorktrees: true,
};
const updated = await settingsService.updateProjectSettings(testProjectDir, updates);
expect(updated.theme).toBe("light");
expect(updated.useWorktrees).toBe(true);
expect(updated.version).toBe(PROJECT_SETTINGS_VERSION);
const automakerDir = path.join(testProjectDir, ".automaker");
const settingsPath = path.join(automakerDir, "settings.json");
const fileContent = await fs.readFile(settingsPath, "utf-8");
const saved = JSON.parse(fileContent);
expect(saved.theme).toBe("light");
expect(saved.useWorktrees).toBe(true);
});
it("should merge updates with existing project settings", async () => {
const initial: ProjectSettings = {
...DEFAULT_PROJECT_SETTINGS,
theme: "dark",
useWorktrees: false,
};
const automakerDir = path.join(testProjectDir, ".automaker");
await fs.mkdir(automakerDir, { recursive: true });
const settingsPath = path.join(automakerDir, "settings.json");
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
const updates: Partial<ProjectSettings> = {
theme: "light",
};
const updated = await settingsService.updateProjectSettings(testProjectDir, updates);
expect(updated.theme).toBe("light");
expect(updated.useWorktrees).toBe(false); // Preserved
});
it("should deep merge board background", async () => {
const initial: ProjectSettings = {
...DEFAULT_PROJECT_SETTINGS,
boardBackground: {
imagePath: "/path/to/image.jpg",
cardOpacity: 0.8,
columnOpacity: 0.9,
columnBorderEnabled: true,
cardGlassmorphism: false,
cardBorderEnabled: true,
cardBorderOpacity: 0.5,
hideScrollbar: false,
},
};
const automakerDir = path.join(testProjectDir, ".automaker");
await fs.mkdir(automakerDir, { recursive: true });
const settingsPath = path.join(automakerDir, "settings.json");
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
const updates: Partial<ProjectSettings> = {
boardBackground: {
cardOpacity: 0.9,
},
};
const updated = await settingsService.updateProjectSettings(testProjectDir, updates);
expect(updated.boardBackground?.imagePath).toBe("/path/to/image.jpg");
expect(updated.boardBackground?.cardOpacity).toBe(0.9);
expect(updated.boardBackground?.columnOpacity).toBe(0.9);
});
it("should create .automaker directory if it does not exist", async () => {
const newProjectDir = path.join(os.tmpdir(), `new-project-${Date.now()}`);
await settingsService.updateProjectSettings(newProjectDir, { theme: "light" });
const automakerDir = path.join(newProjectDir, ".automaker");
const stats = await fs.stat(automakerDir);
expect(stats.isDirectory()).toBe(true);
await fs.rm(newProjectDir, { recursive: true, force: true });
});
});
describe("hasProjectSettings", () => {
it("should return false when project settings file does not exist", async () => {
const exists = await settingsService.hasProjectSettings(testProjectDir);
expect(exists).toBe(false);
});
it("should return true when project settings file exists", async () => {
await settingsService.updateProjectSettings(testProjectDir, { theme: "light" });
const exists = await settingsService.hasProjectSettings(testProjectDir);
expect(exists).toBe(true);
});
});
describe("migrateFromLocalStorage", () => {
it("should migrate global settings from localStorage data", async () => {
const localStorageData = {
"automaker-storage": JSON.stringify({
state: {
theme: "light",
sidebarOpen: false,
maxConcurrency: 5,
},
}),
};
const result = await settingsService.migrateFromLocalStorage(localStorageData);
expect(result.success).toBe(true);
expect(result.migratedGlobalSettings).toBe(true);
expect(result.migratedCredentials).toBe(false);
expect(result.migratedProjectCount).toBe(0);
const settings = await settingsService.getGlobalSettings();
expect(settings.theme).toBe("light");
expect(settings.sidebarOpen).toBe(false);
expect(settings.maxConcurrency).toBe(5);
});
it("should migrate credentials from localStorage data", async () => {
const localStorageData = {
"automaker-storage": JSON.stringify({
state: {
apiKeys: {
anthropic: "sk-test-key",
google: "google-key",
openai: "openai-key",
},
},
}),
};
const result = await settingsService.migrateFromLocalStorage(localStorageData);
expect(result.success).toBe(true);
expect(result.migratedCredentials).toBe(true);
const credentials = await settingsService.getCredentials();
expect(credentials.apiKeys.anthropic).toBe("sk-test-key");
expect(credentials.apiKeys.google).toBe("google-key");
expect(credentials.apiKeys.openai).toBe("openai-key");
});
it("should migrate project settings from localStorage data", async () => {
const localStorageData = {
"automaker-storage": JSON.stringify({
state: {
projects: [
{
id: "proj1",
name: "Project 1",
path: testProjectDir,
theme: "light",
},
],
boardBackgroundByProject: {
[testProjectDir]: {
imagePath: "/path/to/image.jpg",
cardOpacity: 0.8,
columnOpacity: 0.9,
columnBorderEnabled: true,
cardGlassmorphism: false,
cardBorderEnabled: true,
cardBorderOpacity: 0.5,
hideScrollbar: false,
},
},
},
}),
};
const result = await settingsService.migrateFromLocalStorage(localStorageData);
expect(result.success).toBe(true);
expect(result.migratedProjectCount).toBe(1);
const projectSettings = await settingsService.getProjectSettings(testProjectDir);
expect(projectSettings.theme).toBe("light");
expect(projectSettings.boardBackground?.imagePath).toBe("/path/to/image.jpg");
});
it("should handle direct localStorage values", async () => {
const localStorageData = {
"automaker:lastProjectDir": "/path/to/project",
"file-browser-recent-folders": JSON.stringify(["/path1", "/path2"]),
"worktree-panel-collapsed": "true",
};
const result = await settingsService.migrateFromLocalStorage(localStorageData);
expect(result.success).toBe(true);
const settings = await settingsService.getGlobalSettings();
expect(settings.lastProjectDir).toBe("/path/to/project");
expect(settings.recentFolders).toEqual(["/path1", "/path2"]);
expect(settings.worktreePanelCollapsed).toBe(true);
});
it("should handle invalid JSON gracefully", async () => {
const localStorageData = {
"automaker-storage": "invalid json",
"file-browser-recent-folders": "invalid json",
};
const result = await settingsService.migrateFromLocalStorage(localStorageData);
expect(result.success).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
});
it("should handle migration errors gracefully", async () => {
// Create a read-only directory to cause write errors
const readOnlyDir = path.join(os.tmpdir(), `readonly-${Date.now()}`);
await fs.mkdir(readOnlyDir, { recursive: true });
await fs.chmod(readOnlyDir, 0o444);
const readOnlyService = new SettingsService(readOnlyDir);
const localStorageData = {
"automaker-storage": JSON.stringify({
state: { theme: "light" },
}),
};
const result = await readOnlyService.migrateFromLocalStorage(localStorageData);
expect(result.success).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
await fs.chmod(readOnlyDir, 0o755);
await fs.rm(readOnlyDir, { recursive: true, force: true });
});
});
describe("getDataDir", () => {
it("should return the data directory path", () => {
const dataDir = settingsService.getDataDir();
expect(dataDir).toBe(testDataDir);
});
});
describe("atomicWriteJson", () => {
it("should handle write errors and clean up temp file", async () => {
// Create a read-only directory to cause write errors
const readOnlyDir = path.join(os.tmpdir(), `readonly-${Date.now()}`);
await fs.mkdir(readOnlyDir, { recursive: true });
await fs.chmod(readOnlyDir, 0o444);
const readOnlyService = new SettingsService(readOnlyDir);
await expect(
readOnlyService.updateGlobalSettings({ theme: "light" })
).rejects.toThrow();
await fs.chmod(readOnlyDir, 0o755);
await fs.rm(readOnlyDir, { recursive: true, force: true });
});
});
});

View File

@@ -1,7 +1,35 @@
import { useState, useCallback } from "react";
import { RouterProvider } from "@tanstack/react-router";
import { router } from "./utils/router";
import { SplashScreen } from "./components/splash-screen";
import { useSettingsMigration } from "./hooks/use-settings-migration";
import "./styles/global.css";
import "./styles/theme-imports";
export default function App() {
return <RouterProvider router={router} />;
const [showSplash, setShowSplash] = useState(() => {
// Only show splash once per session
if (sessionStorage.getItem("automaker-splash-shown")) {
return false;
}
return true;
});
// Run settings migration on startup (localStorage -> file storage)
const migrationState = useSettingsMigration();
if (migrationState.migrated) {
console.log("[App] Settings migrated to file storage");
}
const handleSplashComplete = useCallback(() => {
sessionStorage.setItem("automaker-splash-shown", "true");
setShowSplash(false);
}, []);
return (
<>
<RouterProvider router={router} />
{showSplash && <SplashScreen onComplete={handleSplashComplete} />}
</>
);
}

View File

@@ -0,0 +1,309 @@
import { useEffect, useState, useMemo } from "react";
const TOTAL_DURATION = 2300; // Total animation duration in ms (tightened from 4000)
const LOGO_ENTER_DURATION = 500; // Tightened from 1200
const PARTICLES_ENTER_DELAY = 100; // Tightened from 400
const EXIT_START = 1800; // Adjusted for shorter duration
interface Particle {
id: number;
x: number;
y: number;
size: number;
delay: number;
angle: number;
distance: number;
opacity: number;
floatDuration: number;
}
function generateParticles(count: number): Particle[] {
return Array.from({ length: count }, (_, i) => {
const angle = (i / count) * 360 + Math.random() * 30;
const distance = 60 + Math.random() * 80; // Increased spread
return {
id: i,
x: Math.cos((angle * Math.PI) / 180) * distance,
y: Math.sin((angle * Math.PI) / 180) * distance,
size: 3 + Math.random() * 6, // Slightly smaller range for more subtle look
delay: Math.random() * 400,
angle,
distance: 300 + Math.random() * 200,
opacity: 0.4 + Math.random() * 0.6,
floatDuration: 3000 + Math.random() * 4000,
};
});
}
export function SplashScreen({ onComplete }: { onComplete: () => void }) {
const [phase, setPhase] = useState<"enter" | "hold" | "exit" | "done">(
"enter"
);
const particles = useMemo(() => generateParticles(50), []);
useEffect(() => {
const timers: NodeJS.Timeout[] = [];
// Phase transitions
timers.push(setTimeout(() => setPhase("hold"), LOGO_ENTER_DURATION));
timers.push(setTimeout(() => setPhase("exit"), EXIT_START));
timers.push(
setTimeout(() => {
setPhase("done");
onComplete();
}, TOTAL_DURATION)
);
return () => timers.forEach(clearTimeout);
}, [onComplete]);
if (phase === "done") return null;
return (
<div
className={`
fixed inset-0 z-[9999] flex items-center justify-center
bg-background
transition-opacity duration-500 ease-out
${phase === "exit" ? "opacity-0" : "opacity-100"}
`}
style={{
pointerEvents: phase === "exit" ? "none" : "auto",
}}
>
<style>{`
@keyframes spin-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes spin-slow-reverse {
from { transform: rotate(360deg); }
to { transform: rotate(0deg); }
}
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(6px, -6px); }
}
`}</style>
{/* Subtle gradient background */}
<div
className="absolute inset-0 opacity-30"
style={{
background:
"radial-gradient(circle at center, var(--brand-500) 0%, transparent 70%)",
}}
/>
{/* Particle container 1 - Clockwise */}
<div
className="absolute inset-0 flex items-center justify-center overflow-hidden"
style={{ animation: "spin-slow 60s linear infinite" }}
>
{particles.slice(0, 25).map((particle) => (
<div
key={particle.id}
className="absolute"
style={{
transform:
phase === "exit"
? `translate(${Math.cos((particle.angle * Math.PI) / 180) * particle.distance}px, ${Math.sin((particle.angle * Math.PI) / 180) * particle.distance}px)`
: `translate(${particle.x}px, ${particle.y}px)`,
transition:
phase === "enter"
? `all 600ms ease-out ${PARTICLES_ENTER_DELAY + particle.delay}ms`
: phase === "exit"
? `all 800ms cubic-bezier(0.4, 0, 1, 1) ${particle.delay * 0.3}ms`
: "all 300ms ease-out",
}}
>
<div
className="rounded-full"
style={{
width: particle.size,
height: particle.size,
background: `linear-gradient(135deg, var(--brand-400), var(--brand-600))`,
boxShadow: `0 0 ${particle.size * 2}px var(--brand-500)`,
opacity:
phase === "enter"
? 0
: phase === "hold"
? particle.opacity
: 0,
transform: phase === "exit" ? "scale(0)" : "scale(1)",
animation: `float ${particle.floatDuration}ms ease-in-out infinite`,
transition: "opacity 300ms ease-out, transform 300ms ease-out",
}}
/>
</div>
))}
</div>
{/* Particle container 2 - Counter-Clockwise */}
<div
className="absolute inset-0 flex items-center justify-center overflow-hidden"
style={{ animation: "spin-slow-reverse 75s linear infinite" }}
>
{particles.slice(25).map((particle) => (
<div
key={particle.id}
className="absolute"
style={{
transform:
phase === "exit"
? `translate(${Math.cos((particle.angle * Math.PI) / 180) * particle.distance}px, ${Math.sin((particle.angle * Math.PI) / 180) * particle.distance}px)`
: `translate(${particle.x}px, ${particle.y}px)`,
transition:
phase === "enter"
? `all 600ms ease-out ${PARTICLES_ENTER_DELAY + particle.delay}ms`
: phase === "exit"
? `all 800ms cubic-bezier(0.4, 0, 1, 1) ${particle.delay * 0.3}ms`
: "all 300ms ease-out",
}}
>
<div
className="rounded-full"
style={{
width: particle.size,
height: particle.size,
background: `linear-gradient(135deg, var(--brand-400), var(--brand-600))`,
boxShadow: `0 0 ${particle.size * 2}px var(--brand-500)`,
opacity:
phase === "enter"
? 0
: phase === "hold"
? particle.opacity
: 0,
transform: phase === "exit" ? "scale(0)" : "scale(1)",
animation: `float ${particle.floatDuration}ms ease-in-out infinite`,
animationDelay: `${particle.delay}ms`,
transition: "opacity 300ms ease-out, transform 300ms ease-out",
}}
/>
</div>
))}
</div>
{/* Logo container */}
<div
className="relative z-10"
style={{
opacity: phase === "enter" ? 0 : phase === "exit" ? 0 : 1,
transform:
phase === "enter"
? "scale(0.3) rotate(-20deg)"
: phase === "exit"
? "scale(2.5) translateY(-100px)"
: "scale(1) rotate(0deg)",
transition:
phase === "enter"
? `all ${LOGO_ENTER_DURATION}ms cubic-bezier(0.34, 1.56, 0.64, 1)`
: phase === "exit"
? "all 600ms cubic-bezier(0.4, 0, 1, 1)"
: "all 300ms ease-out",
}}
>
{/* Glow effect behind logo */}
<div
className="absolute inset-0 blur-3xl"
style={{
background:
"radial-gradient(circle, var(--brand-500) 0%, transparent 70%)",
transform: "scale(2.5)",
opacity: phase === "hold" ? 0.6 : 0,
transition: "opacity 500ms ease-out",
}}
/>
{/* The logo */}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
role="img"
aria-label="Automaker Logo"
className="relative z-10"
style={{
width: 120,
height: 120,
filter: "drop-shadow(0 0 30px var(--brand-500))",
}}
>
<defs>
<linearGradient
id="splash-bg"
x1="0"
y1="0"
x2="256"
y2="256"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" style={{ stopColor: "var(--brand-400)" }} />
<stop offset="100%" style={{ stopColor: "var(--brand-600)" }} />
</linearGradient>
<filter
id="splash-shadow"
x="-20%"
y="-20%"
width="140%"
height="140%"
>
<feDropShadow
dx="0"
dy="4"
stdDeviation="4"
floodColor="#000000"
floodOpacity="0.25"
/>
</filter>
</defs>
<rect
x="16"
y="16"
width="224"
height="224"
rx="56"
fill="url(#splash-bg)"
/>
<g
fill="none"
stroke="#FFFFFF"
strokeWidth="20"
strokeLinecap="round"
strokeLinejoin="round"
filter="url(#splash-shadow)"
>
<path d="M92 92 L52 128 L92 164" />
<path d="M144 72 L116 184" />
<path d="M164 92 L204 128 L164 164" />
</g>
</svg>
</div>
{/* Automaker text that fades in below the logo */}
<div
className="absolute flex items-center gap-1"
style={{
top: "calc(50% + 80px)",
opacity: phase === "enter" ? 0 : phase === "exit" ? 0 : 1,
transform:
phase === "enter"
? "translateY(20px)"
: phase === "exit"
? "translateY(-30px) scale(1.2)"
: "translateY(0)",
transition:
phase === "enter"
? `all 600ms ease-out ${LOGO_ENTER_DURATION - 200}ms`
: phase === "exit"
? "all 500ms cubic-bezier(0.4, 0, 1, 1)"
: "all 300ms ease-out",
}}
>
<span className="font-bold text-foreground text-4xl tracking-tight leading-none">
automaker<span className="text-brand-500">.</span>
</span>
</div>
</div>
);
}

View File

@@ -1,8 +1,8 @@
import { useSetupStore } from "@/store/setup-store";
import { StepIndicator } from "./setup-view/components";
import {
WelcomeStep,
ThemeStep,
CompleteStep,
ClaudeSetupStep,
GitHubSetupStep,
@@ -11,20 +11,17 @@ import { useNavigate } from "@tanstack/react-router";
// Main Setup View
export function SetupView() {
const {
currentStep,
setCurrentStep,
completeSetup,
setSkipClaudeSetup,
} = useSetupStore();
const { currentStep, setCurrentStep, completeSetup, setSkipClaudeSetup } =
useSetupStore();
const navigate = useNavigate();
const steps = ["welcome", "claude", "github", "complete"] as const;
const steps = ["welcome", "theme", "claude", "github", "complete"] as const;
type StepName = (typeof steps)[number];
const getStepName = (): StepName => {
if (currentStep === "claude_detect" || currentStep === "claude_auth")
return "claude";
if (currentStep === "welcome") return "welcome";
if (currentStep === "theme") return "theme";
if (currentStep === "github") return "github";
return "complete";
};
@@ -39,6 +36,10 @@ export function SetupView() {
);
switch (from) {
case "welcome":
console.log("[Setup Flow] Moving to theme step");
setCurrentStep("theme");
break;
case "theme":
console.log("[Setup Flow] Moving to claude_detect step");
setCurrentStep("claude_detect");
break;
@@ -56,9 +57,12 @@ export function SetupView() {
const handleBack = (from: string) => {
console.log("[Setup Flow] handleBack called from:", from);
switch (from) {
case "claude":
case "theme":
setCurrentStep("welcome");
break;
case "claude":
setCurrentStep("theme");
break;
case "github":
setCurrentStep("claude_detect");
break;
@@ -98,42 +102,47 @@ export function SetupView() {
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="p-8">
<div className="w-full max-w-2xl mx-auto">
<div className="mb-8">
<StepIndicator
currentStep={currentIndex}
totalSteps={steps.length}
<div className="flex-1 overflow-y-auto min-h-0 flex items-center justify-center">
<div className="w-full max-w-2xl mx-auto px-8">
<div className="mb-8">
<StepIndicator
currentStep={currentIndex}
totalSteps={steps.length}
/>
</div>
<div>
{currentStep === "welcome" && (
<WelcomeStep onNext={() => handleNext("welcome")} />
)}
{currentStep === "theme" && (
<ThemeStep
onNext={() => handleNext("theme")}
onBack={() => handleBack("theme")}
/>
</div>
)}
<div className="py-8">
{currentStep === "welcome" && (
<WelcomeStep onNext={() => handleNext("welcome")} />
)}
{(currentStep === "claude_detect" ||
currentStep === "claude_auth") && (
<ClaudeSetupStep
onNext={() => handleNext("claude")}
onBack={() => handleBack("claude")}
onSkip={handleSkipClaude}
/>
)}
{(currentStep === "claude_detect" ||
currentStep === "claude_auth") && (
<ClaudeSetupStep
onNext={() => handleNext("claude")}
onBack={() => handleBack("claude")}
onSkip={handleSkipClaude}
/>
)}
{currentStep === "github" && (
<GitHubSetupStep
onNext={() => handleNext("github")}
onBack={() => handleBack("github")}
onSkip={handleSkipGithub}
/>
)}
{currentStep === "github" && (
<GitHubSetupStep
onNext={() => handleNext("github")}
onBack={() => handleBack("github")}
onSkip={handleSkipGithub}
/>
)}
{currentStep === "complete" && (
<CompleteStep onFinish={handleFinish} />
)}
</div>
{currentStep === "complete" && (
<CompleteStep onFinish={handleFinish} />
)}
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
// Re-export all setup step components for easier imports
export { WelcomeStep } from "./welcome-step";
export { ThemeStep } from "./theme-step";
export { CompleteStep } from "./complete-step";
export { ClaudeSetupStep } from "./claude-setup-step";
export { GitHubSetupStep } from "./github-setup-step";

View File

@@ -0,0 +1,90 @@
import { Button } from "@/components/ui/button";
import { ArrowRight, ArrowLeft, Check } from "lucide-react";
import { themeOptions } from "@/config/theme-options";
import { useAppStore } from "@/store/app-store";
import { cn } from "@/lib/utils";
interface ThemeStepProps {
onNext: () => void;
onBack: () => void;
}
export function ThemeStep({ onNext, onBack }: ThemeStepProps) {
const { theme, setTheme, setPreviewTheme } = useAppStore();
const handleThemeHover = (themeValue: string) => {
setPreviewTheme(themeValue as typeof theme);
};
const handleThemeLeave = () => {
setPreviewTheme(null);
};
const handleThemeClick = (themeValue: string) => {
setTheme(themeValue as typeof theme);
setPreviewTheme(null);
};
return (
<div className="space-y-6">
<div className="text-center">
<h2 className="text-3xl font-bold text-foreground mb-3">
Choose Your Theme
</h2>
<p className="text-muted-foreground max-w-md mx-auto">
Pick a theme that suits your style. Hover to preview, click to select.
</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{themeOptions.map((option) => {
const Icon = option.Icon;
const isSelected = theme === option.value;
return (
<button
key={option.value}
data-testid={option.testId}
onMouseEnter={() => handleThemeHover(option.value)}
onMouseLeave={handleThemeLeave}
onClick={() => handleThemeClick(option.value)}
className={cn(
"relative flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all duration-200",
"hover:scale-105 hover:shadow-lg",
isSelected
? "border-brand-500 bg-brand-500/10"
: "border-border hover:border-brand-400 bg-card"
)}
>
{isSelected && (
<div className="absolute top-2 right-2">
<Check className="w-4 h-4 text-brand-500" />
</div>
)}
<Icon className="w-6 h-6 text-foreground" />
<span className="text-sm font-medium text-foreground">
{option.label}
</span>
</button>
);
})}
</div>
<div className="flex justify-between pt-4">
<Button variant="ghost" onClick={onBack}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
<Button
size="lg"
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white"
onClick={onNext}
data-testid="theme-continue-button"
>
Continue
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,261 @@
/**
* Settings Migration Hook
*
* This hook handles migrating settings from localStorage to file-based storage.
* It runs on app startup and:
* 1. Checks if server has settings files
* 2. If not, migrates localStorage data to server
* 3. Clears old localStorage keys after successful migration
*
* This approach keeps localStorage as a fast cache while ensuring
* settings are persisted to files that survive app updates.
*/
import { useEffect, useState, useRef } from "react";
import { getHttpApiClient } from "@/lib/http-api-client";
import { isElectron } from "@/lib/electron";
interface MigrationState {
checked: boolean;
migrated: boolean;
error: string | null;
}
// localStorage keys to migrate
const LOCALSTORAGE_KEYS = [
"automaker-storage",
"automaker-setup",
"worktree-panel-collapsed",
"file-browser-recent-folders",
"automaker:lastProjectDir",
] as const;
// Keys to clear after migration (not automaker-storage as it's still used by Zustand)
const KEYS_TO_CLEAR_AFTER_MIGRATION = [
"worktree-panel-collapsed",
"file-browser-recent-folders",
"automaker:lastProjectDir",
// Legacy keys
"automaker_projects",
"automaker_current_project",
"automaker_trashed_projects",
] as const;
/**
* Hook to handle settings migration from localStorage to file-based storage
*/
export function useSettingsMigration(): MigrationState {
const [state, setState] = useState<MigrationState>({
checked: false,
migrated: false,
error: null,
});
const migrationAttempted = useRef(false);
useEffect(() => {
// Only run once
if (migrationAttempted.current) return;
migrationAttempted.current = true;
async function checkAndMigrate() {
// Only run migration in Electron mode (web mode uses different storage)
if (!isElectron()) {
setState({ checked: true, migrated: false, error: null });
return;
}
try {
const api = getHttpApiClient();
// Check if server has settings files
const status = await api.settings.getStatus();
if (!status.success) {
console.error("[Settings Migration] Failed to get status:", status);
setState({
checked: true,
migrated: false,
error: "Failed to check settings status",
});
return;
}
// If settings files already exist, no migration needed
if (!status.needsMigration) {
console.log(
"[Settings Migration] Settings files exist, no migration needed"
);
setState({ checked: true, migrated: false, error: null });
return;
}
// Check if we have localStorage data to migrate
const automakerStorage = localStorage.getItem("automaker-storage");
if (!automakerStorage) {
console.log(
"[Settings Migration] No localStorage data to migrate"
);
setState({ checked: true, migrated: false, error: null });
return;
}
console.log("[Settings Migration] Starting migration...");
// Collect all localStorage data
const localStorageData: Record<string, string> = {};
for (const key of LOCALSTORAGE_KEYS) {
const value = localStorage.getItem(key);
if (value) {
localStorageData[key] = value;
}
}
// Send to server for migration
const result = await api.settings.migrate(localStorageData);
if (result.success) {
console.log("[Settings Migration] Migration successful:", {
globalSettings: result.migratedGlobalSettings,
credentials: result.migratedCredentials,
projects: result.migratedProjectCount,
});
// Clear old localStorage keys (but keep automaker-storage for Zustand)
for (const key of KEYS_TO_CLEAR_AFTER_MIGRATION) {
localStorage.removeItem(key);
}
setState({ checked: true, migrated: true, error: null });
} else {
console.warn(
"[Settings Migration] Migration had errors:",
result.errors
);
setState({
checked: true,
migrated: false,
error: result.errors.join(", "),
});
}
} catch (error) {
console.error("[Settings Migration] Migration failed:", error);
setState({
checked: true,
migrated: false,
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
checkAndMigrate();
}, []);
return state;
}
/**
* Sync current settings to the server
* Call this when important settings change
*/
export async function syncSettingsToServer(): Promise<boolean> {
if (!isElectron()) return false;
try {
const api = getHttpApiClient();
const automakerStorage = localStorage.getItem("automaker-storage");
if (!automakerStorage) {
return false;
}
const parsed = JSON.parse(automakerStorage);
const state = parsed.state || parsed;
// Extract settings to sync
const updates = {
theme: state.theme,
sidebarOpen: state.sidebarOpen,
chatHistoryOpen: state.chatHistoryOpen,
kanbanCardDetailLevel: state.kanbanCardDetailLevel,
maxConcurrency: state.maxConcurrency,
defaultSkipTests: state.defaultSkipTests,
enableDependencyBlocking: state.enableDependencyBlocking,
useWorktrees: state.useWorktrees,
showProfilesOnly: state.showProfilesOnly,
defaultPlanningMode: state.defaultPlanningMode,
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
defaultAIProfileId: state.defaultAIProfileId,
muteDoneSound: state.muteDoneSound,
enhancementModel: state.enhancementModel,
keyboardShortcuts: state.keyboardShortcuts,
aiProfiles: state.aiProfiles,
projects: state.projects,
trashedProjects: state.trashedProjects,
projectHistory: state.projectHistory,
projectHistoryIndex: state.projectHistoryIndex,
lastSelectedSessionByProject: state.lastSelectedSessionByProject,
};
const result = await api.settings.updateGlobal(updates);
return result.success;
} catch (error) {
console.error("[Settings Sync] Failed to sync settings:", error);
return false;
}
}
/**
* Sync credentials to the server
* Call this when API keys change
*/
export async function syncCredentialsToServer(apiKeys: {
anthropic?: string;
google?: string;
openai?: string;
}): Promise<boolean> {
if (!isElectron()) return false;
try {
const api = getHttpApiClient();
const result = await api.settings.updateCredentials({ apiKeys });
return result.success;
} catch (error) {
console.error("[Settings Sync] Failed to sync credentials:", error);
return false;
}
}
/**
* Sync project settings to the server
* Call this when project-specific settings change
*/
export async function syncProjectSettingsToServer(
projectPath: string,
updates: {
theme?: string;
useWorktrees?: boolean;
boardBackground?: Record<string, unknown>;
currentWorktree?: { path: string | null; branch: string };
worktrees?: Array<{
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}>;
}
): Promise<boolean> {
if (!isElectron()) return false;
try {
const api = getHttpApiClient();
const result = await api.settings.updateProject(projectPath, updates);
return result.success;
} catch (error) {
console.error(
"[Settings Sync] Failed to sync project settings:",
error
);
return false;
}
}

View File

@@ -508,7 +508,15 @@ const mockFileSystem: Record<string, string> = {};
// Check if we're in Electron (for UI indicators only)
export const isElectron = (): boolean => {
return typeof window !== "undefined" && window.isElectron === true;
if (typeof window === "undefined") {
return false;
}
if ((window as any).isElectron === true) {
return true;
}
return window.electronAPI?.isElectron === true;
};
// Check if backend server is available

View File

@@ -837,6 +837,135 @@ export class HttpApiClient implements ElectronAPI {
this.post("/api/templates/clone", { repoUrl, projectName, parentDir }),
};
// Settings API - persistent file-based settings
settings = {
// Get settings status (check if migration needed)
getStatus: (): Promise<{
success: boolean;
hasGlobalSettings: boolean;
hasCredentials: boolean;
dataDir: string;
needsMigration: boolean;
}> => this.get("/api/settings/status"),
// Global settings
getGlobal: (): Promise<{
success: boolean;
settings?: {
version: number;
theme: string;
sidebarOpen: boolean;
chatHistoryOpen: boolean;
kanbanCardDetailLevel: string;
maxConcurrency: number;
defaultSkipTests: boolean;
enableDependencyBlocking: boolean;
useWorktrees: boolean;
showProfilesOnly: boolean;
defaultPlanningMode: string;
defaultRequirePlanApproval: boolean;
defaultAIProfileId: string | null;
muteDoneSound: boolean;
enhancementModel: string;
keyboardShortcuts: Record<string, string>;
aiProfiles: unknown[];
projects: unknown[];
trashedProjects: unknown[];
projectHistory: string[];
projectHistoryIndex: number;
lastProjectDir?: string;
recentFolders: string[];
worktreePanelCollapsed: boolean;
lastSelectedSessionByProject: Record<string, string>;
};
error?: string;
}> => this.get("/api/settings/global"),
updateGlobal: (updates: Record<string, unknown>): Promise<{
success: boolean;
settings?: Record<string, unknown>;
error?: string;
}> => this.put("/api/settings/global", updates),
// Credentials (masked for security)
getCredentials: (): Promise<{
success: boolean;
credentials?: {
anthropic: { configured: boolean; masked: string };
google: { configured: boolean; masked: string };
openai: { configured: boolean; masked: string };
};
error?: string;
}> => this.get("/api/settings/credentials"),
updateCredentials: (updates: {
apiKeys?: { anthropic?: string; google?: string; openai?: string };
}): Promise<{
success: boolean;
credentials?: {
anthropic: { configured: boolean; masked: string };
google: { configured: boolean; masked: string };
openai: { configured: boolean; masked: string };
};
error?: string;
}> => this.put("/api/settings/credentials", updates),
// Project settings
getProject: (projectPath: string): Promise<{
success: boolean;
settings?: {
version: number;
theme?: string;
useWorktrees?: boolean;
currentWorktree?: { path: string | null; branch: string };
worktrees?: Array<{
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}>;
boardBackground?: {
imagePath: string | null;
imageVersion?: number;
cardOpacity: number;
columnOpacity: number;
columnBorderEnabled: boolean;
cardGlassmorphism: boolean;
cardBorderEnabled: boolean;
cardBorderOpacity: number;
hideScrollbar: boolean;
};
lastSelectedSessionId?: string;
};
error?: string;
}> => this.post("/api/settings/project", { projectPath }),
updateProject: (
projectPath: string,
updates: Record<string, unknown>
): Promise<{
success: boolean;
settings?: Record<string, unknown>;
error?: string;
}> => this.put("/api/settings/project", { projectPath, updates }),
// Migration from localStorage
migrate: (data: {
"automaker-storage"?: string;
"automaker-setup"?: string;
"worktree-panel-collapsed"?: string;
"file-browser-recent-folders"?: string;
"automaker:lastProjectDir"?: string;
}): Promise<{
success: boolean;
migratedGlobalSettings: boolean;
migratedCredentials: boolean;
migratedProjectCount: number;
errors: string[];
}> => this.post("/api/settings/migrate", { data }),
};
// Sessions API
sessions = {
list: (

View File

@@ -304,6 +304,20 @@ function createWindow(): void {
// App lifecycle
app.whenReady().then(async () => {
// Ensure userData path is consistent across dev/prod so files land in Automaker dir
try {
const desiredUserDataPath = path.join(app.getPath("appData"), "Automaker");
if (app.getPath("userData") !== desiredUserDataPath) {
app.setPath("userData", desiredUserDataPath);
console.log("[Electron] userData path set to:", desiredUserDataPath);
}
} catch (error) {
console.warn(
"[Electron] Failed to set userData path:",
(error as Error).message
);
}
if (process.platform === "darwin" && app.dock) {
const iconPath = getIconPath();
if (iconPath) {

View File

@@ -3,6 +3,7 @@ import { useEffect, useState, useCallback } from "react";
import { Sidebar } from "@/components/layout/sidebar";
import { FileBrowserProvider, useFileBrowser, setGlobalFileBrowser } from "@/contexts/file-browser-context";
import { useAppStore } from "@/store/app-store";
import { useSetupStore } from "@/store/setup-store";
import { getElectronAPI } from "@/lib/electron";
import { Toaster } from "sonner";
import { ThemeOption, themeOptions } from "@/config/theme-options";
@@ -16,9 +17,13 @@ function RootLayoutContent() {
previewTheme,
getEffectiveTheme,
} = useAppStore();
const { setupComplete } = useSetupStore();
const navigate = useNavigate();
const [isMounted, setIsMounted] = useState(false);
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
const [setupHydrated, setSetupHydrated] = useState(() =>
useSetupStore.persist?.hasHydrated?.() ?? false
);
const { openFileBrowser } = useFileBrowser();
// Hidden streamer panel - opens with "\" key
@@ -61,6 +66,35 @@ function RootLayoutContent() {
setIsMounted(true);
}, []);
// Wait for setup store hydration before enforcing routing rules
useEffect(() => {
if (useSetupStore.persist?.hasHydrated?.()) {
setSetupHydrated(true);
return;
}
const unsubscribe = useSetupStore.persist?.onFinishHydration?.(() => {
setSetupHydrated(true);
});
return () => {
if (typeof unsubscribe === "function") {
unsubscribe();
}
};
}, []);
// Redirect first-run users (or anyone who reopened the wizard) to /setup
useEffect(() => {
if (!setupHydrated) return;
if (!setupComplete && location.pathname !== "/setup") {
navigate({ to: "/setup" });
} else if (setupComplete && location.pathname === "/setup") {
navigate({ to: "/" });
}
}, [setupComplete, setupHydrated, location.pathname, navigate]);
useEffect(() => {
setGlobalFileBrowser(openFileBrowser);
}, [openFileBrowser]);

View File

@@ -2356,3 +2356,205 @@ export const useAppStore = create<AppState & AppActions>()(
}
)
);
// ============================================================================
// Settings Sync to Server (file-based storage)
// ============================================================================
// Debounced sync function to avoid excessive server calls
let syncTimeoutId: NodeJS.Timeout | null = null;
const SYNC_DEBOUNCE_MS = 2000; // Wait 2 seconds after last change before syncing
/**
* Schedule a sync of current settings to the server
* This is debounced to avoid excessive API calls
*/
function scheduleSyncToServer() {
// Only sync in Electron mode
if (typeof window === "undefined") return;
// Clear any pending sync
if (syncTimeoutId) {
clearTimeout(syncTimeoutId);
}
// Schedule new sync
syncTimeoutId = setTimeout(async () => {
try {
// Dynamic import to avoid circular dependencies
const { syncSettingsToServer } = await import(
"@/hooks/use-settings-migration"
);
await syncSettingsToServer();
} catch (error) {
console.error("[AppStore] Failed to sync settings to server:", error);
}
}, SYNC_DEBOUNCE_MS);
}
// Subscribe to store changes and sync to server
// Only sync when important settings change (not every state change)
let previousState: Partial<AppState> | null = null;
let previousProjectSettings: Record<
string,
{
theme?: string;
boardBackground?: typeof initialState.boardBackgroundByProject[string];
currentWorktree?: typeof initialState.currentWorktreeByProject[string];
worktrees?: typeof initialState.worktreesByProject[string];
}
> = {};
// Track pending project syncs (debounced per project)
const projectSyncTimeouts: Record<string, NodeJS.Timeout> = {};
const PROJECT_SYNC_DEBOUNCE_MS = 2000;
/**
* Schedule sync of project settings to server
*/
function scheduleProjectSettingsSync(
projectPath: string,
updates: Record<string, unknown>
) {
// Only sync in Electron mode
if (typeof window === "undefined") return;
// Clear any pending sync for this project
if (projectSyncTimeouts[projectPath]) {
clearTimeout(projectSyncTimeouts[projectPath]);
}
// Schedule new sync
projectSyncTimeouts[projectPath] = setTimeout(async () => {
try {
const { syncProjectSettingsToServer } = await import(
"@/hooks/use-settings-migration"
);
await syncProjectSettingsToServer(projectPath, updates);
} catch (error) {
console.error(
`[AppStore] Failed to sync project settings for ${projectPath}:`,
error
);
}
delete projectSyncTimeouts[projectPath];
}, PROJECT_SYNC_DEBOUNCE_MS);
}
useAppStore.subscribe((state) => {
// Skip if this is the initial load
if (!previousState) {
previousState = {
theme: state.theme,
projects: state.projects,
trashedProjects: state.trashedProjects,
keyboardShortcuts: state.keyboardShortcuts,
aiProfiles: state.aiProfiles,
maxConcurrency: state.maxConcurrency,
defaultSkipTests: state.defaultSkipTests,
enableDependencyBlocking: state.enableDependencyBlocking,
useWorktrees: state.useWorktrees,
showProfilesOnly: state.showProfilesOnly,
muteDoneSound: state.muteDoneSound,
enhancementModel: state.enhancementModel,
defaultPlanningMode: state.defaultPlanningMode,
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
defaultAIProfileId: state.defaultAIProfileId,
};
// Initialize project settings tracking
for (const project of state.projects) {
previousProjectSettings[project.path] = {
theme: project.theme,
boardBackground: state.boardBackgroundByProject[project.path],
currentWorktree: state.currentWorktreeByProject[project.path],
worktrees: state.worktreesByProject[project.path],
};
}
return;
}
// Check if any important global settings changed
const importantSettingsChanged =
state.theme !== previousState.theme ||
state.projects !== previousState.projects ||
state.trashedProjects !== previousState.trashedProjects ||
state.keyboardShortcuts !== previousState.keyboardShortcuts ||
state.aiProfiles !== previousState.aiProfiles ||
state.maxConcurrency !== previousState.maxConcurrency ||
state.defaultSkipTests !== previousState.defaultSkipTests ||
state.enableDependencyBlocking !== previousState.enableDependencyBlocking ||
state.useWorktrees !== previousState.useWorktrees ||
state.showProfilesOnly !== previousState.showProfilesOnly ||
state.muteDoneSound !== previousState.muteDoneSound ||
state.enhancementModel !== previousState.enhancementModel ||
state.defaultPlanningMode !== previousState.defaultPlanningMode ||
state.defaultRequirePlanApproval !== previousState.defaultRequirePlanApproval ||
state.defaultAIProfileId !== previousState.defaultAIProfileId;
if (importantSettingsChanged) {
// Update previous state
previousState = {
theme: state.theme,
projects: state.projects,
trashedProjects: state.trashedProjects,
keyboardShortcuts: state.keyboardShortcuts,
aiProfiles: state.aiProfiles,
maxConcurrency: state.maxConcurrency,
defaultSkipTests: state.defaultSkipTests,
enableDependencyBlocking: state.enableDependencyBlocking,
useWorktrees: state.useWorktrees,
showProfilesOnly: state.showProfilesOnly,
muteDoneSound: state.muteDoneSound,
enhancementModel: state.enhancementModel,
defaultPlanningMode: state.defaultPlanningMode,
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
defaultAIProfileId: state.defaultAIProfileId,
};
// Schedule sync to server
scheduleSyncToServer();
}
// Check for per-project settings changes
for (const project of state.projects) {
const projectPath = project.path;
const prev = previousProjectSettings[projectPath] || {};
const updates: Record<string, unknown> = {};
// Check if project theme changed
if (project.theme !== prev.theme) {
updates.theme = project.theme;
}
// Check if board background changed
const currentBg = state.boardBackgroundByProject[projectPath];
if (currentBg !== prev.boardBackground) {
updates.boardBackground = currentBg;
}
// Check if current worktree changed
const currentWt = state.currentWorktreeByProject[projectPath];
if (currentWt !== prev.currentWorktree) {
updates.currentWorktree = currentWt;
}
// Check if worktrees list changed
const worktrees = state.worktreesByProject[projectPath];
if (worktrees !== prev.worktrees) {
updates.worktrees = worktrees;
}
// If any project settings changed, sync them
if (Object.keys(updates).length > 0) {
scheduleProjectSettingsSync(projectPath, updates);
// Update tracking
previousProjectSettings[projectPath] = {
theme: project.theme,
boardBackground: currentBg,
currentWorktree: currentWt,
worktrees: worktrees,
};
}
}
});

View File

@@ -53,6 +53,7 @@ export interface InstallProgress {
export type SetupStep =
| "welcome"
| "theme"
| "claude_detect"
| "claude_auth"
| "github"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
/**
* Bundles all individual theme styles so the build pipeline
* doesn't tree-shake their CSS when imported dynamically.
*/
import "./themes/dark.css";
import "./themes/light.css";
import "./themes/retro.css";
import "./themes/dracula.css";
import "./themes/nord.css";
import "./themes/monokai.css";
import "./themes/tokyonight.css";
import "./themes/solarized.css";
import "./themes/gruvbox.css";
import "./themes/catppuccin.css";
import "./themes/onedark.css";
import "./themes/synthwave.css";
import "./themes/red.css";
import "./themes/cream.css";
import "./themes/sunset.css";
import "./themes/gray.css";

View File

@@ -0,0 +1,144 @@
/* Catppuccin Theme */
.catppuccin {
--background: oklch(0.18 0.02 260); /* #1e1e2e base */
--background-50: oklch(0.18 0.02 260 / 0.5);
--background-80: oklch(0.18 0.02 260 / 0.8);
--foreground: oklch(0.9 0.01 280); /* #cdd6f4 text */
--foreground-secondary: oklch(0.75 0.02 280); /* #bac2de subtext1 */
--foreground-muted: oklch(0.6 0.03 280); /* #a6adc8 subtext0 */
--card: oklch(0.22 0.02 260); /* #313244 surface0 */
--card-foreground: oklch(0.9 0.01 280);
--popover: oklch(0.2 0.02 260);
--popover-foreground: oklch(0.9 0.01 280);
--primary: oklch(0.75 0.15 280); /* #cba6f7 mauve */
--primary-foreground: oklch(0.18 0.02 260);
--brand-400: oklch(0.8 0.15 280);
--brand-500: oklch(0.75 0.15 280); /* Mauve */
--brand-600: oklch(0.7 0.17 280);
--secondary: oklch(0.26 0.02 260); /* #45475a surface1 */
--secondary-foreground: oklch(0.9 0.01 280);
--muted: oklch(0.26 0.02 260);
--muted-foreground: oklch(0.6 0.03 280);
--accent: oklch(0.3 0.03 260); /* #585b70 surface2 */
--accent-foreground: oklch(0.9 0.01 280);
--destructive: oklch(0.65 0.2 15); /* #f38ba8 red */
--border: oklch(0.35 0.03 260);
--border-glass: oklch(0.75 0.15 280 / 0.3);
--input: oklch(0.22 0.02 260);
--ring: oklch(0.75 0.15 280);
--chart-1: oklch(0.75 0.15 280); /* Mauve */
--chart-2: oklch(0.75 0.15 220); /* Blue #89b4fa */
--chart-3: oklch(0.8 0.15 160); /* Green #a6e3a1 */
--chart-4: oklch(0.8 0.15 350); /* Pink #f5c2e7 */
--chart-5: oklch(0.85 0.12 90); /* Yellow #f9e2af */
--sidebar: oklch(0.16 0.02 260); /* #181825 mantle */
--sidebar-foreground: oklch(0.9 0.01 280);
--sidebar-primary: oklch(0.75 0.15 280);
--sidebar-primary-foreground: oklch(0.18 0.02 260);
--sidebar-accent: oklch(0.26 0.02 260);
--sidebar-accent-foreground: oklch(0.9 0.01 280);
--sidebar-border: oklch(0.35 0.03 260);
--sidebar-ring: oklch(0.75 0.15 280);
/* Action button colors - Catppuccin mauve/pink theme */
--action-view: oklch(0.75 0.15 280); /* Mauve */
--action-view-hover: oklch(0.7 0.17 280);
--action-followup: oklch(0.75 0.15 220); /* Blue */
--action-followup-hover: oklch(0.7 0.17 220);
--action-commit: oklch(0.8 0.15 160); /* Green */
--action-commit-hover: oklch(0.75 0.17 160);
--action-verify: oklch(0.8 0.15 160); /* Green */
--action-verify-hover: oklch(0.75 0.17 160);
/* Running indicator - Mauve */
--running-indicator: oklch(0.75 0.15 280);
--running-indicator-text: oklch(0.8 0.13 280);
}
/* ========================================
ONE DARK THEME
Atom's iconic One Dark theme
======================================== */
/* Theme-specific overrides */
.catppuccin .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #cba6f7 0%, #f5c2e7 50%, #cba6f7 100%);
}
.catppuccin .animated-outline-inner {
background: oklch(0.18 0.02 260) !important;
color: #cba6f7 !important;
}
.catppuccin [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
background: oklch(0.24 0.03 260) !important;
color: #f5c2e7 !important;
}
.catppuccin .slider-track {
background: oklch(0.26 0.02 260);
}
.catppuccin .slider-range {
background: linear-gradient(to right, #cba6f7, #89b4fa);
}
.catppuccin .slider-thumb {
background: oklch(0.22 0.02 260);
border-color: #cba6f7;
}
.catppuccin .xml-highlight {
color: oklch(0.9 0.01 280); /* #cdd6f4 */
}
.catppuccin .xml-tag-bracket {
color: oklch(0.65 0.2 15); /* #f38ba8 red */
}
.catppuccin .xml-tag-name {
color: oklch(0.65 0.2 15); /* Red for tags */
}
.catppuccin .xml-attribute-name {
color: oklch(0.75 0.15 280); /* #cba6f7 mauve */
}
.catppuccin .xml-attribute-equals {
color: oklch(0.75 0.02 280); /* Subtext */
}
.catppuccin .xml-attribute-value {
color: oklch(0.8 0.15 160); /* #a6e3a1 green */
}
.catppuccin .xml-comment {
color: oklch(0.5 0.04 280); /* Overlay */
font-style: italic;
}
.catppuccin .xml-cdata {
color: oklch(0.75 0.15 220); /* #89b4fa blue */
}
.catppuccin .xml-doctype {
color: oklch(0.8 0.15 350); /* #f5c2e7 pink */
}
.catppuccin .xml-text {
color: oklch(0.9 0.01 280); /* Text */
}

View File

@@ -0,0 +1,116 @@
/* Cream Theme */
.cream {
/* Cream Theme - Warm, soft, easy on the eyes */
--background: oklch(0.95 0.01 70); /* Warm cream background */
--background-50: oklch(0.95 0.01 70 / 0.5);
--background-80: oklch(0.95 0.01 70 / 0.8);
--foreground: oklch(0.25 0.02 60); /* Dark warm brown */
--foreground-secondary: oklch(0.45 0.02 60); /* Medium brown */
--foreground-muted: oklch(0.55 0.02 60); /* Light brown */
--card: oklch(0.98 0.005 70); /* Slightly lighter cream */
--card-foreground: oklch(0.25 0.02 60);
--popover: oklch(0.97 0.008 70);
--popover-foreground: oklch(0.25 0.02 60);
--primary: oklch(0.5 0.12 45); /* Warm terracotta/rust */
--primary-foreground: oklch(0.98 0.005 70);
--brand-400: oklch(0.55 0.12 45);
--brand-500: oklch(0.5 0.12 45); /* Terracotta */
--brand-600: oklch(0.45 0.13 45);
--secondary: oklch(0.88 0.02 70);
--secondary-foreground: oklch(0.25 0.02 60);
--muted: oklch(0.9 0.015 70);
--muted-foreground: oklch(0.45 0.02 60);
--accent: oklch(0.85 0.025 70);
--accent-foreground: oklch(0.25 0.02 60);
--destructive: oklch(0.55 0.22 25); /* Warm red */
--border: oklch(0.85 0.015 70);
--border-glass: oklch(0.5 0.12 45 / 0.2);
--input: oklch(0.98 0.005 70);
--ring: oklch(0.5 0.12 45);
--chart-1: oklch(0.5 0.12 45); /* Terracotta */
--chart-2: oklch(0.55 0.15 35); /* Burnt orange */
--chart-3: oklch(0.6 0.12 100); /* Olive */
--chart-4: oklch(0.5 0.15 20); /* Deep rust */
--chart-5: oklch(0.65 0.1 80); /* Golden */
--sidebar: oklch(0.93 0.012 70);
--sidebar-foreground: oklch(0.25 0.02 60);
--sidebar-primary: oklch(0.5 0.12 45);
--sidebar-primary-foreground: oklch(0.98 0.005 70);
--sidebar-accent: oklch(0.88 0.02 70);
--sidebar-accent-foreground: oklch(0.25 0.02 60);
--sidebar-border: oklch(0.85 0.015 70);
--sidebar-ring: oklch(0.5 0.12 45);
/* Action button colors - Warm earth tones */
--action-view: oklch(0.5 0.12 45); /* Terracotta */
--action-view-hover: oklch(0.45 0.13 45);
--action-followup: oklch(0.55 0.15 35); /* Burnt orange */
--action-followup-hover: oklch(0.5 0.16 35);
--action-commit: oklch(0.55 0.12 130); /* Sage green */
--action-commit-hover: oklch(0.5 0.13 130);
--action-verify: oklch(0.55 0.12 130); /* Sage green */
--action-verify-hover: oklch(0.5 0.13 130);
/* Running indicator - Terracotta */
--running-indicator: oklch(0.5 0.12 45);
--running-indicator-text: oklch(0.55 0.12 45);
/* Status colors - Cream theme */
--status-success: oklch(0.55 0.15 130);
--status-success-bg: oklch(0.55 0.15 130 / 0.15);
--status-warning: oklch(0.6 0.15 70);
--status-warning-bg: oklch(0.6 0.15 70 / 0.15);
--status-error: oklch(0.55 0.22 25);
--status-error-bg: oklch(0.55 0.22 25 / 0.15);
--status-info: oklch(0.5 0.15 230);
--status-info-bg: oklch(0.5 0.15 230 / 0.15);
--status-backlog: oklch(0.6 0.02 60);
--status-in-progress: oklch(0.6 0.15 70);
--status-waiting: oklch(0.58 0.13 50);
}
/* Theme-specific overrides */
/* Cream theme scrollbar */
.cream ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.cream ::-webkit-scrollbar-thumb,
.cream .scrollbar-visible::-webkit-scrollbar-thumb {
background: oklch(0.7 0.03 60);
border-radius: 4px;
}
.cream ::-webkit-scrollbar-thumb:hover,
.cream .scrollbar-visible::-webkit-scrollbar-thumb:hover {
background: oklch(0.6 0.04 60);
}
.cream ::-webkit-scrollbar-track,
.cream .scrollbar-visible::-webkit-scrollbar-track {
background: oklch(0.9 0.015 70);
}
.cream .scrollbar-styled::-webkit-scrollbar-thumb {
background: oklch(0.7 0.03 60);
}
.cream .scrollbar-styled::-webkit-scrollbar-thumb:hover {
background: oklch(0.6 0.04 60);
}

View File

@@ -0,0 +1,166 @@
/* Dark Theme */
.dark {
/* Deep dark backgrounds - zinc-950 family */
--background: oklch(0.04 0 0); /* zinc-950 */
--background-50: oklch(0.04 0 0 / 0.5); /* zinc-950/50 */
--background-80: oklch(0.04 0 0 / 0.8); /* zinc-950/80 */
/* Text colors following hierarchy */
--foreground: oklch(1 0 0); /* text-white */
--foreground-secondary: oklch(0.588 0 0); /* text-zinc-400 */
--foreground-muted: oklch(0.525 0 0); /* text-zinc-500 */
/* Card and popover backgrounds */
--card: oklch(0.14 0 0); /* slightly lighter than background for contrast */
--card-foreground: oklch(1 0 0);
--popover: oklch(0.10 0 0); /* slightly lighter than background */
--popover-foreground: oklch(1 0 0);
/* Brand colors - purple/violet theme */
--primary: oklch(0.55 0.25 265); /* brand-500 */
--primary-foreground: oklch(1 0 0);
--brand-400: oklch(0.6 0.22 265);
--brand-500: oklch(0.55 0.25 265);
--brand-600: oklch(0.5 0.28 270); /* purple-600 for gradients */
/* Glass morphism borders and accents */
--secondary: oklch(1 0 0 / 0.05); /* bg-white/5 */
--secondary-foreground: oklch(1 0 0);
--muted: oklch(0.176 0 0); /* zinc-800 */
--muted-foreground: oklch(0.588 0 0); /* text-zinc-400 */
--accent: oklch(1 0 0 / 0.1); /* bg-white/10 for hover */
--accent-foreground: oklch(1 0 0);
/* Borders with transparency for glass effect */
--border: oklch(0.176 0 0); /* zinc-800 */
--border-glass: oklch(1 0 0 / 0.1); /* white/10 for glass morphism */
--destructive: oklch(0.6 0.25 25);
--input: oklch(0.04 0 0 / 0.8); /* Semi-transparent dark */
--ring: oklch(0.55 0.25 265);
/* Chart colors with brand theme */
--chart-1: oklch(0.55 0.25 265);
--chart-2: oklch(0.65 0.2 160);
--chart-3: oklch(0.75 0.2 70);
--chart-4: oklch(0.6 0.25 300);
--chart-5: oklch(0.6 0.25 20);
/* Sidebar with glass morphism */
--sidebar: oklch(0.04 0 0 / 0.5); /* zinc-950/50 with backdrop blur */
--sidebar-foreground: oklch(1 0 0);
--sidebar-primary: oklch(0.55 0.25 265);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(1 0 0 / 0.05); /* bg-white/5 */
--sidebar-accent-foreground: oklch(1 0 0);
--sidebar-border: oklch(1 0 0 / 0.1); /* white/10 for glass borders */
--sidebar-ring: oklch(0.55 0.25 265);
/* Action button colors */
--action-view: oklch(0.6 0.25 265); /* Purple */
--action-view-hover: oklch(0.55 0.27 270);
--action-followup: oklch(0.6 0.2 230); /* Blue */
--action-followup-hover: oklch(0.55 0.22 230);
--action-commit: oklch(0.55 0.2 140); /* Green */
--action-commit-hover: oklch(0.5 0.22 140);
--action-verify: oklch(0.55 0.2 140); /* Green */
--action-verify-hover: oklch(0.5 0.22 140);
/* Running indicator - Purple */
--running-indicator: oklch(0.6 0.25 265);
--running-indicator-text: oklch(0.65 0.22 265);
/* Status colors - Dark mode */
--status-success: oklch(0.65 0.2 140);
--status-success-bg: oklch(0.65 0.2 140 / 0.2);
--status-warning: oklch(0.75 0.15 70);
--status-warning-bg: oklch(0.75 0.15 70 / 0.2);
--status-error: oklch(0.65 0.22 25);
--status-error-bg: oklch(0.65 0.22 25 / 0.2);
--status-info: oklch(0.65 0.2 230);
--status-info-bg: oklch(0.65 0.2 230 / 0.2);
--status-backlog: oklch(0.6 0 0);
--status-in-progress: oklch(0.75 0.15 70);
--status-waiting: oklch(0.7 0.18 50);
/* Shadow tokens - darker for dark mode */
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -2px rgba(0, 0, 0, 0.2);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.3);
}
/* Theme-specific overrides */
.dark .content-bg {
background: linear-gradient(135deg, oklch(0.04 0 0), oklch(0.08 0 0), oklch(0.04 0 0));
}
.dark .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #a855f7 0%, #3b82f6 50%, #a855f7 100%);
}
.dark .animated-outline-inner {
background: oklch(0.15 0 0) !important;
color: #c084fc !important;
}
.dark [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
background: oklch(0.2 0.02 270) !important;
color: #e9d5ff !important;
}
.dark .slider-track {
background: oklch(0.2 0 0);
}
.dark .slider-range {
background: linear-gradient(to right, #a855f7, #3b82f6);
}
.dark .slider-thumb {
background: oklch(0.25 0 0);
border-color: oklch(0.4 0 0);
}
.dark .xml-highlight {
color: oklch(0.9 0 0); /* Default light text */
}
.dark .xml-tag-bracket {
color: oklch(0.7 0.12 220); /* Soft blue for < > */
}
.dark .xml-tag-name {
color: oklch(0.75 0.2 25); /* Coral/salmon for tag names */
}
.dark .xml-attribute-name {
color: oklch(0.8 0.15 280); /* Light purple for attributes */
}
.dark .xml-attribute-equals {
color: oklch(0.6 0 0); /* Gray for = */
}
.dark .xml-attribute-value {
color: oklch(0.8 0.18 145); /* Bright green for strings */
}
.dark .xml-comment {
color: oklch(0.55 0.05 100); /* Muted for comments */
font-style: italic;
}
.dark .xml-cdata {
color: oklch(0.7 0.12 200); /* Teal for CDATA */
}
.dark .xml-doctype {
color: oklch(0.7 0.15 280); /* Purple for DOCTYPE */
}
.dark .xml-text {
color: oklch(0.85 0 0); /* Off-white for text */
}

View File

@@ -0,0 +1,144 @@
/* Dracula Theme */
.dracula {
--background: oklch(0.18 0.02 280); /* #282a36 */
--background-50: oklch(0.18 0.02 280 / 0.5);
--background-80: oklch(0.18 0.02 280 / 0.8);
--foreground: oklch(0.95 0.01 280); /* #f8f8f2 */
--foreground-secondary: oklch(0.7 0.05 280);
--foreground-muted: oklch(0.55 0.08 280); /* #6272a4 */
--card: oklch(0.22 0.02 280); /* #44475a */
--card-foreground: oklch(0.95 0.01 280);
--popover: oklch(0.2 0.02 280);
--popover-foreground: oklch(0.95 0.01 280);
--primary: oklch(0.7 0.2 320); /* #bd93f9 purple */
--primary-foreground: oklch(0.18 0.02 280);
--brand-400: oklch(0.75 0.2 320);
--brand-500: oklch(0.7 0.2 320); /* #bd93f9 */
--brand-600: oklch(0.65 0.22 320);
--secondary: oklch(0.28 0.03 280); /* #44475a */
--secondary-foreground: oklch(0.95 0.01 280);
--muted: oklch(0.28 0.03 280);
--muted-foreground: oklch(0.55 0.08 280); /* #6272a4 */
--accent: oklch(0.32 0.04 280);
--accent-foreground: oklch(0.95 0.01 280);
--destructive: oklch(0.65 0.25 15); /* #ff5555 */
--border: oklch(0.35 0.05 280);
--border-glass: oklch(0.7 0.2 320 / 0.3);
--input: oklch(0.22 0.02 280);
--ring: oklch(0.7 0.2 320);
--chart-1: oklch(0.7 0.2 320); /* Purple */
--chart-2: oklch(0.75 0.2 180); /* Cyan #8be9fd */
--chart-3: oklch(0.8 0.2 130); /* Green #50fa7b */
--chart-4: oklch(0.7 0.25 350); /* Pink #ff79c6 */
--chart-5: oklch(0.85 0.2 90); /* Yellow #f1fa8c */
--sidebar: oklch(0.16 0.02 280);
--sidebar-foreground: oklch(0.95 0.01 280);
--sidebar-primary: oklch(0.7 0.2 320);
--sidebar-primary-foreground: oklch(0.18 0.02 280);
--sidebar-accent: oklch(0.28 0.03 280);
--sidebar-accent-foreground: oklch(0.95 0.01 280);
--sidebar-border: oklch(0.35 0.05 280);
--sidebar-ring: oklch(0.7 0.2 320);
/* Action button colors - Dracula purple/pink theme */
--action-view: oklch(0.7 0.2 320); /* Purple */
--action-view-hover: oklch(0.65 0.22 320);
--action-followup: oklch(0.65 0.25 350); /* Pink */
--action-followup-hover: oklch(0.6 0.27 350);
--action-commit: oklch(0.75 0.2 130); /* Green */
--action-commit-hover: oklch(0.7 0.22 130);
--action-verify: oklch(0.75 0.2 130); /* Green */
--action-verify-hover: oklch(0.7 0.22 130);
/* Running indicator - Purple */
--running-indicator: oklch(0.7 0.2 320);
--running-indicator-text: oklch(0.75 0.18 320);
}
/* ========================================
NORD THEME
Inspired by the Arctic, north-bluish color palette
======================================== */
/* Theme-specific overrides */
.dracula .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #bd93f9 0%, #ff79c6 50%, #bd93f9 100%);
}
.dracula .animated-outline-inner {
background: oklch(0.18 0.02 280) !important;
color: #bd93f9 !important;
}
.dracula [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
background: oklch(0.24 0.03 280) !important;
color: #ff79c6 !important;
}
.dracula .slider-track {
background: oklch(0.28 0.03 280);
}
.dracula .slider-range {
background: linear-gradient(to right, #bd93f9, #ff79c6);
}
.dracula .slider-thumb {
background: oklch(0.22 0.02 280);
border-color: #bd93f9;
}
.dracula .xml-highlight {
color: oklch(0.95 0.01 280); /* #f8f8f2 */
}
.dracula .xml-tag-bracket {
color: oklch(0.7 0.25 350); /* Pink #ff79c6 */
}
.dracula .xml-tag-name {
color: oklch(0.7 0.25 350); /* Pink for tags */
}
.dracula .xml-attribute-name {
color: oklch(0.8 0.2 130); /* Green #50fa7b */
}
.dracula .xml-attribute-equals {
color: oklch(0.95 0.01 280); /* White */
}
.dracula .xml-attribute-value {
color: oklch(0.85 0.2 90); /* Yellow #f1fa8c */
}
.dracula .xml-comment {
color: oklch(0.55 0.08 280); /* #6272a4 */
font-style: italic;
}
.dracula .xml-cdata {
color: oklch(0.75 0.2 180); /* Cyan */
}
.dracula .xml-doctype {
color: oklch(0.7 0.2 320); /* Purple #bd93f9 */
}
.dracula .xml-text {
color: oklch(0.95 0.01 280); /* White */
}

View File

@@ -0,0 +1,110 @@
/* Gray Theme */
.gray {
/* Gray Theme - Modern, minimal gray scheme inspired by Cursor */
--background: oklch(0.2 0.005 250); /* Medium-dark neutral gray */
--background-50: oklch(0.2 0.005 250 / 0.5);
--background-80: oklch(0.2 0.005 250 / 0.8);
--foreground: oklch(0.9 0.005 250); /* Light gray */
--foreground-secondary: oklch(0.65 0.005 250);
--foreground-muted: oklch(0.5 0.005 250);
--card: oklch(0.24 0.005 250);
--card-foreground: oklch(0.9 0.005 250);
--popover: oklch(0.22 0.005 250);
--popover-foreground: oklch(0.9 0.005 250);
--primary: oklch(0.6 0.08 250); /* Subtle blue-gray */
--primary-foreground: oklch(0.95 0.005 250);
--brand-400: oklch(0.65 0.08 250);
--brand-500: oklch(0.6 0.08 250); /* Blue-gray */
--brand-600: oklch(0.55 0.09 250);
--secondary: oklch(0.28 0.005 250);
--secondary-foreground: oklch(0.9 0.005 250);
--muted: oklch(0.3 0.005 250);
--muted-foreground: oklch(0.6 0.005 250);
--accent: oklch(0.35 0.01 250);
--accent-foreground: oklch(0.9 0.005 250);
--destructive: oklch(0.6 0.2 25); /* Muted red */
--border: oklch(0.32 0.005 250);
--border-glass: oklch(0.6 0.08 250 / 0.2);
--input: oklch(0.24 0.005 250);
--ring: oklch(0.6 0.08 250);
--chart-1: oklch(0.6 0.08 250); /* Blue-gray */
--chart-2: oklch(0.65 0.1 210); /* Cyan */
--chart-3: oklch(0.7 0.12 160); /* Teal */
--chart-4: oklch(0.65 0.1 280); /* Purple */
--chart-5: oklch(0.7 0.08 300); /* Violet */
--sidebar: oklch(0.18 0.005 250);
--sidebar-foreground: oklch(0.9 0.005 250);
--sidebar-primary: oklch(0.6 0.08 250);
--sidebar-primary-foreground: oklch(0.95 0.005 250);
--sidebar-accent: oklch(0.28 0.005 250);
--sidebar-accent-foreground: oklch(0.9 0.005 250);
--sidebar-border: oklch(0.32 0.005 250);
--sidebar-ring: oklch(0.6 0.08 250);
/* Action button colors - Subtle modern colors */
--action-view: oklch(0.6 0.08 250); /* Blue-gray */
--action-view-hover: oklch(0.55 0.09 250);
--action-followup: oklch(0.65 0.1 210); /* Cyan */
--action-followup-hover: oklch(0.6 0.11 210);
--action-commit: oklch(0.65 0.12 150); /* Teal-green */
--action-commit-hover: oklch(0.6 0.13 150);
--action-verify: oklch(0.65 0.12 150); /* Teal-green */
--action-verify-hover: oklch(0.6 0.13 150);
/* Running indicator - Blue-gray */
--running-indicator: oklch(0.6 0.08 250);
--running-indicator-text: oklch(0.65 0.08 250);
/* Status colors - Gray theme */
--status-success: oklch(0.65 0.12 150);
--status-success-bg: oklch(0.65 0.12 150 / 0.2);
--status-warning: oklch(0.7 0.15 70);
--status-warning-bg: oklch(0.7 0.15 70 / 0.2);
--status-error: oklch(0.6 0.2 25);
--status-error-bg: oklch(0.6 0.2 25 / 0.2);
--status-info: oklch(0.65 0.1 210);
--status-info-bg: oklch(0.65 0.1 210 / 0.2);
--status-backlog: oklch(0.6 0.005 250);
--status-in-progress: oklch(0.7 0.15 70);
--status-waiting: oklch(0.68 0.1 220);
}
/* Theme-specific overrides */
/* Gray theme scrollbar */
.gray ::-webkit-scrollbar-thumb,
.gray .scrollbar-visible::-webkit-scrollbar-thumb {
background: oklch(0.4 0.01 250);
border-radius: 4px;
}
.gray ::-webkit-scrollbar-thumb:hover,
.gray .scrollbar-visible::-webkit-scrollbar-thumb:hover {
background: oklch(0.5 0.02 250);
}
.gray ::-webkit-scrollbar-track,
.gray .scrollbar-visible::-webkit-scrollbar-track {
background: oklch(0.25 0.005 250);
}
.gray .scrollbar-styled::-webkit-scrollbar-thumb {
background: oklch(0.4 0.01 250);
}
.gray .scrollbar-styled::-webkit-scrollbar-thumb:hover {
background: oklch(0.5 0.02 250);
}

View File

@@ -0,0 +1,144 @@
/* Gruvbox Theme */
.gruvbox {
--background: oklch(0.18 0.02 60); /* #282828 bg */
--background-50: oklch(0.18 0.02 60 / 0.5);
--background-80: oklch(0.18 0.02 60 / 0.8);
--foreground: oklch(0.85 0.05 85); /* #ebdbb2 fg */
--foreground-secondary: oklch(0.7 0.04 85); /* #d5c4a1 */
--foreground-muted: oklch(0.55 0.04 85); /* #928374 */
--card: oklch(0.22 0.02 60); /* #3c3836 bg1 */
--card-foreground: oklch(0.85 0.05 85);
--popover: oklch(0.2 0.02 60);
--popover-foreground: oklch(0.85 0.05 85);
--primary: oklch(0.7 0.18 55); /* #fabd2f yellow */
--primary-foreground: oklch(0.18 0.02 60);
--brand-400: oklch(0.75 0.18 55);
--brand-500: oklch(0.7 0.18 55); /* Yellow */
--brand-600: oklch(0.65 0.2 55);
--secondary: oklch(0.26 0.02 60); /* #504945 bg2 */
--secondary-foreground: oklch(0.85 0.05 85);
--muted: oklch(0.26 0.02 60);
--muted-foreground: oklch(0.55 0.04 85);
--accent: oklch(0.3 0.03 60);
--accent-foreground: oklch(0.85 0.05 85);
--destructive: oklch(0.55 0.22 25); /* #fb4934 red */
--border: oklch(0.35 0.03 60);
--border-glass: oklch(0.7 0.18 55 / 0.3);
--input: oklch(0.22 0.02 60);
--ring: oklch(0.7 0.18 55);
--chart-1: oklch(0.7 0.18 55); /* Yellow */
--chart-2: oklch(0.65 0.2 140); /* Green #b8bb26 */
--chart-3: oklch(0.7 0.15 200); /* Aqua #8ec07c */
--chart-4: oklch(0.6 0.2 30); /* Orange #fe8019 */
--chart-5: oklch(0.6 0.2 320); /* Purple #d3869b */
--sidebar: oklch(0.16 0.02 60);
--sidebar-foreground: oklch(0.85 0.05 85);
--sidebar-primary: oklch(0.7 0.18 55);
--sidebar-primary-foreground: oklch(0.18 0.02 60);
--sidebar-accent: oklch(0.26 0.02 60);
--sidebar-accent-foreground: oklch(0.85 0.05 85);
--sidebar-border: oklch(0.35 0.03 60);
--sidebar-ring: oklch(0.7 0.18 55);
/* Action button colors - Gruvbox yellow/orange theme */
--action-view: oklch(0.7 0.18 55); /* Yellow */
--action-view-hover: oklch(0.65 0.2 55);
--action-followup: oklch(0.7 0.15 200); /* Aqua */
--action-followup-hover: oklch(0.65 0.17 200);
--action-commit: oklch(0.65 0.2 140); /* Green */
--action-commit-hover: oklch(0.6 0.22 140);
--action-verify: oklch(0.65 0.2 140); /* Green */
--action-verify-hover: oklch(0.6 0.22 140);
/* Running indicator - Yellow */
--running-indicator: oklch(0.7 0.18 55);
--running-indicator-text: oklch(0.75 0.16 55);
}
/* ========================================
CATPPUCCIN MOCHA THEME
Soothing pastel theme for the high-spirited
======================================== */
/* Theme-specific overrides */
.gruvbox .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #fabd2f 0%, #fe8019 50%, #fabd2f 100%);
}
.gruvbox .animated-outline-inner {
background: oklch(0.18 0.02 60) !important;
color: #fabd2f !important;
}
.gruvbox [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
background: oklch(0.24 0.03 60) !important;
color: #fe8019 !important;
}
.gruvbox .slider-track {
background: oklch(0.26 0.02 60);
}
.gruvbox .slider-range {
background: linear-gradient(to right, #fabd2f, #fe8019);
}
.gruvbox .slider-thumb {
background: oklch(0.22 0.02 60);
border-color: #fabd2f;
}
.gruvbox .xml-highlight {
color: oklch(0.85 0.05 85); /* #ebdbb2 */
}
.gruvbox .xml-tag-bracket {
color: oklch(0.55 0.22 25); /* #fb4934 red */
}
.gruvbox .xml-tag-name {
color: oklch(0.55 0.22 25); /* Red for tags */
}
.gruvbox .xml-attribute-name {
color: oklch(0.7 0.15 200); /* #8ec07c aqua */
}
.gruvbox .xml-attribute-equals {
color: oklch(0.7 0.04 85); /* Dim text */
}
.gruvbox .xml-attribute-value {
color: oklch(0.65 0.2 140); /* #b8bb26 green */
}
.gruvbox .xml-comment {
color: oklch(0.55 0.04 85); /* #928374 gray */
font-style: italic;
}
.gruvbox .xml-cdata {
color: oklch(0.7 0.15 200); /* Aqua */
}
.gruvbox .xml-doctype {
color: oklch(0.6 0.2 320); /* #d3869b purple */
}
.gruvbox .xml-text {
color: oklch(0.85 0.05 85); /* Foreground */
}

View File

@@ -0,0 +1,103 @@
/* Light Theme Overrides */
.light .scrollbar-visible::-webkit-scrollbar-track {
background: oklch(0.95 0 0);
}
.light .scrollbar-visible::-webkit-scrollbar-thumb {
background: oklch(0.7 0 0);
}
.light .scrollbar-visible::-webkit-scrollbar-thumb:hover {
background: oklch(0.6 0 0);
}
.light .scrollbar-styled::-webkit-scrollbar-thumb {
background: oklch(0.75 0 0);
}
.light .scrollbar-styled::-webkit-scrollbar-thumb:hover {
background: oklch(0.65 0 0);
}
.light .bg-glass {
background: oklch(1 0 0 / 0.8);
}
.light .bg-glass-80 {
background: oklch(1 0 0 / 0.95);
}
.light .content-bg {
background: linear-gradient(135deg, oklch(0.99 0 0), oklch(0.98 0 0), oklch(0.99 0 0));
}
.light .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #7c3aed 0%, #2563eb 50%, #7c3aed 100%);
}
.light .animated-outline-inner {
background: oklch(100% 0 0) !important;
color: #7c3aed !important;
border: 1px solid oklch(92% 0 0);
}
.light [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
background: oklch(97% 0.02 270) !important;
color: #5b21b6 !important;
}
.light .slider-track {
background: oklch(90% 0 0);
}
.light .slider-range {
background: linear-gradient(to right, #7c3aed, #2563eb);
}
.light .slider-thumb {
background: oklch(100% 0 0);
border-color: oklch(80% 0 0);
}
.light .xml-highlight {
color: oklch(0.3 0 0); /* Default text */
}
.light .xml-tag-bracket {
color: oklch(0.45 0.15 250); /* Blue-gray for < > */
}
.light .xml-tag-name {
color: oklch(0.45 0.22 25); /* Red/maroon for tag names */
}
.light .xml-attribute-name {
color: oklch(0.45 0.18 280); /* Purple for attributes */
}
.light .xml-attribute-equals {
color: oklch(0.4 0 0); /* Dark gray for = */
}
.light .xml-attribute-value {
color: oklch(0.45 0.18 145); /* Green for string values */
}
.light .xml-comment {
color: oklch(0.55 0.05 100); /* Muted olive for comments */
font-style: italic;
}
.light .xml-cdata {
color: oklch(0.5 0.1 200); /* Teal for CDATA */
}
.light .xml-doctype {
color: oklch(0.5 0.15 280); /* Purple for DOCTYPE */
}
.light .xml-text {
color: oklch(0.25 0 0); /* Near-black for text content */
}

View File

@@ -0,0 +1,144 @@
/* Monokai Theme */
.monokai {
--background: oklch(0.17 0.01 90); /* #272822 */
--background-50: oklch(0.17 0.01 90 / 0.5);
--background-80: oklch(0.17 0.01 90 / 0.8);
--foreground: oklch(0.95 0.02 100); /* #f8f8f2 */
--foreground-secondary: oklch(0.8 0.02 100);
--foreground-muted: oklch(0.55 0.04 100); /* #75715e */
--card: oklch(0.22 0.01 90); /* #3e3d32 */
--card-foreground: oklch(0.95 0.02 100);
--popover: oklch(0.2 0.01 90);
--popover-foreground: oklch(0.95 0.02 100);
--primary: oklch(0.8 0.2 350); /* #f92672 pink */
--primary-foreground: oklch(0.17 0.01 90);
--brand-400: oklch(0.85 0.2 350);
--brand-500: oklch(0.8 0.2 350); /* #f92672 */
--brand-600: oklch(0.75 0.22 350);
--secondary: oklch(0.25 0.02 90);
--secondary-foreground: oklch(0.95 0.02 100);
--muted: oklch(0.25 0.02 90);
--muted-foreground: oklch(0.55 0.04 100);
--accent: oklch(0.3 0.02 90);
--accent-foreground: oklch(0.95 0.02 100);
--destructive: oklch(0.65 0.25 15); /* red */
--border: oklch(0.35 0.03 90);
--border-glass: oklch(0.8 0.2 350 / 0.3);
--input: oklch(0.22 0.01 90);
--ring: oklch(0.8 0.2 350);
--chart-1: oklch(0.8 0.2 350); /* Pink #f92672 */
--chart-2: oklch(0.85 0.2 90); /* Yellow #e6db74 */
--chart-3: oklch(0.8 0.2 140); /* Green #a6e22e */
--chart-4: oklch(0.75 0.2 200); /* Cyan #66d9ef */
--chart-5: oklch(0.75 0.2 30); /* Orange #fd971f */
--sidebar: oklch(0.15 0.01 90);
--sidebar-foreground: oklch(0.95 0.02 100);
--sidebar-primary: oklch(0.8 0.2 350);
--sidebar-primary-foreground: oklch(0.17 0.01 90);
--sidebar-accent: oklch(0.25 0.02 90);
--sidebar-accent-foreground: oklch(0.95 0.02 100);
--sidebar-border: oklch(0.35 0.03 90);
--sidebar-ring: oklch(0.8 0.2 350);
/* Action button colors - Monokai pink/yellow theme */
--action-view: oklch(0.8 0.2 350); /* Pink */
--action-view-hover: oklch(0.75 0.22 350);
--action-followup: oklch(0.75 0.2 200); /* Cyan */
--action-followup-hover: oklch(0.7 0.22 200);
--action-commit: oklch(0.8 0.2 140); /* Green */
--action-commit-hover: oklch(0.75 0.22 140);
--action-verify: oklch(0.8 0.2 140); /* Green */
--action-verify-hover: oklch(0.75 0.22 140);
/* Running indicator - Pink */
--running-indicator: oklch(0.8 0.2 350);
--running-indicator-text: oklch(0.85 0.18 350);
}
/* ========================================
TOKYO NIGHT THEME
A clean dark theme celebrating Tokyo at night
======================================== */
/* Theme-specific overrides */
.monokai .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #f92672 0%, #e6db74 50%, #f92672 100%);
}
.monokai .animated-outline-inner {
background: oklch(0.17 0.01 90) !important;
color: #f92672 !important;
}
.monokai [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
background: oklch(0.22 0.02 90) !important;
color: #e6db74 !important;
}
.monokai .slider-track {
background: oklch(0.25 0.02 90);
}
.monokai .slider-range {
background: linear-gradient(to right, #f92672, #fd971f);
}
.monokai .slider-thumb {
background: oklch(0.22 0.01 90);
border-color: #f92672;
}
.monokai .xml-highlight {
color: oklch(0.95 0.02 100); /* #f8f8f2 */
}
.monokai .xml-tag-bracket {
color: oklch(0.95 0.02 100); /* White */
}
.monokai .xml-tag-name {
color: oklch(0.8 0.2 350); /* #f92672 pink */
}
.monokai .xml-attribute-name {
color: oklch(0.8 0.2 140); /* #a6e22e green */
}
.monokai .xml-attribute-equals {
color: oklch(0.95 0.02 100); /* White */
}
.monokai .xml-attribute-value {
color: oklch(0.85 0.2 90); /* #e6db74 yellow */
}
.monokai .xml-comment {
color: oklch(0.55 0.04 100); /* #75715e */
font-style: italic;
}
.monokai .xml-cdata {
color: oklch(0.75 0.2 200); /* Cyan #66d9ef */
}
.monokai .xml-doctype {
color: oklch(0.75 0.2 200); /* Cyan */
}
.monokai .xml-text {
color: oklch(0.95 0.02 100); /* White */
}

View File

@@ -0,0 +1,144 @@
/* Nord Theme */
.nord {
--background: oklch(0.23 0.02 240); /* #2e3440 */
--background-50: oklch(0.23 0.02 240 / 0.5);
--background-80: oklch(0.23 0.02 240 / 0.8);
--foreground: oklch(0.9 0.01 230); /* #eceff4 */
--foreground-secondary: oklch(0.75 0.02 230); /* #d8dee9 */
--foreground-muted: oklch(0.6 0.03 230); /* #4c566a */
--card: oklch(0.27 0.02 240); /* #3b4252 */
--card-foreground: oklch(0.9 0.01 230);
--popover: oklch(0.25 0.02 240);
--popover-foreground: oklch(0.9 0.01 230);
--primary: oklch(0.7 0.12 220); /* #88c0d0 frost */
--primary-foreground: oklch(0.23 0.02 240);
--brand-400: oklch(0.75 0.12 220);
--brand-500: oklch(0.7 0.12 220); /* #88c0d0 */
--brand-600: oklch(0.65 0.14 220); /* #81a1c1 */
--secondary: oklch(0.31 0.02 240); /* #434c5e */
--secondary-foreground: oklch(0.9 0.01 230);
--muted: oklch(0.31 0.02 240);
--muted-foreground: oklch(0.55 0.03 230);
--accent: oklch(0.35 0.03 240); /* #4c566a */
--accent-foreground: oklch(0.9 0.01 230);
--destructive: oklch(0.65 0.2 15); /* #bf616a */
--border: oklch(0.35 0.03 240);
--border-glass: oklch(0.7 0.12 220 / 0.3);
--input: oklch(0.27 0.02 240);
--ring: oklch(0.7 0.12 220);
--chart-1: oklch(0.7 0.12 220); /* Frost blue */
--chart-2: oklch(0.65 0.14 220); /* #81a1c1 */
--chart-3: oklch(0.7 0.15 140); /* #a3be8c green */
--chart-4: oklch(0.7 0.2 320); /* #b48ead purple */
--chart-5: oklch(0.75 0.15 70); /* #ebcb8b yellow */
--sidebar: oklch(0.21 0.02 240);
--sidebar-foreground: oklch(0.9 0.01 230);
--sidebar-primary: oklch(0.7 0.12 220);
--sidebar-primary-foreground: oklch(0.23 0.02 240);
--sidebar-accent: oklch(0.31 0.02 240);
--sidebar-accent-foreground: oklch(0.9 0.01 230);
--sidebar-border: oklch(0.35 0.03 240);
--sidebar-ring: oklch(0.7 0.12 220);
/* Action button colors - Nord frost blue theme */
--action-view: oklch(0.7 0.12 220); /* Frost blue */
--action-view-hover: oklch(0.65 0.14 220);
--action-followup: oklch(0.65 0.14 220); /* Darker frost */
--action-followup-hover: oklch(0.6 0.16 220);
--action-commit: oklch(0.7 0.15 140); /* Green */
--action-commit-hover: oklch(0.65 0.17 140);
--action-verify: oklch(0.7 0.15 140); /* Green */
--action-verify-hover: oklch(0.65 0.17 140);
/* Running indicator - Frost blue */
--running-indicator: oklch(0.7 0.12 220);
--running-indicator-text: oklch(0.75 0.1 220);
}
/* ========================================
MONOKAI THEME
The classic Monokai color scheme
======================================== */
/* Theme-specific overrides */
.nord .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #88c0d0 0%, #81a1c1 50%, #88c0d0 100%);
}
.nord .animated-outline-inner {
background: oklch(0.23 0.02 240) !important;
color: #88c0d0 !important;
}
.nord [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
background: oklch(0.28 0.03 240) !important;
color: #8fbcbb !important;
}
.nord .slider-track {
background: oklch(0.31 0.02 240);
}
.nord .slider-range {
background: linear-gradient(to right, #88c0d0, #81a1c1);
}
.nord .slider-thumb {
background: oklch(0.27 0.02 240);
border-color: #88c0d0;
}
.nord .xml-highlight {
color: oklch(0.9 0.01 230); /* #eceff4 */
}
.nord .xml-tag-bracket {
color: oklch(0.65 0.14 220); /* #81a1c1 */
}
.nord .xml-tag-name {
color: oklch(0.65 0.14 220); /* Frost blue for tags */
}
.nord .xml-attribute-name {
color: oklch(0.7 0.12 220); /* #88c0d0 */
}
.nord .xml-attribute-equals {
color: oklch(0.75 0.02 230); /* Dim white */
}
.nord .xml-attribute-value {
color: oklch(0.7 0.15 140); /* #a3be8c green */
}
.nord .xml-comment {
color: oklch(0.5 0.04 230); /* Dim text */
font-style: italic;
}
.nord .xml-cdata {
color: oklch(0.7 0.12 220); /* Frost blue */
}
.nord .xml-doctype {
color: oklch(0.7 0.2 320); /* #b48ead purple */
}
.nord .xml-text {
color: oklch(0.9 0.01 230); /* Snow white */
}

View File

@@ -0,0 +1,144 @@
/* Onedark Theme */
.onedark {
--background: oklch(0.19 0.01 250); /* #282c34 */
--background-50: oklch(0.19 0.01 250 / 0.5);
--background-80: oklch(0.19 0.01 250 / 0.8);
--foreground: oklch(0.85 0.02 240); /* #abb2bf */
--foreground-secondary: oklch(0.7 0.02 240);
--foreground-muted: oklch(0.5 0.03 240); /* #5c6370 */
--card: oklch(0.23 0.01 250); /* #21252b */
--card-foreground: oklch(0.85 0.02 240);
--popover: oklch(0.21 0.01 250);
--popover-foreground: oklch(0.85 0.02 240);
--primary: oklch(0.7 0.18 230); /* #61afef blue */
--primary-foreground: oklch(0.19 0.01 250);
--brand-400: oklch(0.75 0.18 230);
--brand-500: oklch(0.7 0.18 230); /* Blue */
--brand-600: oklch(0.65 0.2 230);
--secondary: oklch(0.25 0.01 250);
--secondary-foreground: oklch(0.85 0.02 240);
--muted: oklch(0.25 0.01 250);
--muted-foreground: oklch(0.5 0.03 240);
--accent: oklch(0.28 0.02 250);
--accent-foreground: oklch(0.85 0.02 240);
--destructive: oklch(0.6 0.2 20); /* #e06c75 red */
--border: oklch(0.35 0.02 250);
--border-glass: oklch(0.7 0.18 230 / 0.3);
--input: oklch(0.23 0.01 250);
--ring: oklch(0.7 0.18 230);
--chart-1: oklch(0.7 0.18 230); /* Blue */
--chart-2: oklch(0.75 0.15 320); /* Magenta #c678dd */
--chart-3: oklch(0.75 0.18 150); /* Green #98c379 */
--chart-4: oklch(0.8 0.15 80); /* Yellow #e5c07b */
--chart-5: oklch(0.7 0.15 180); /* Cyan #56b6c2 */
--sidebar: oklch(0.17 0.01 250);
--sidebar-foreground: oklch(0.85 0.02 240);
--sidebar-primary: oklch(0.7 0.18 230);
--sidebar-primary-foreground: oklch(0.19 0.01 250);
--sidebar-accent: oklch(0.25 0.01 250);
--sidebar-accent-foreground: oklch(0.85 0.02 240);
--sidebar-border: oklch(0.35 0.02 250);
--sidebar-ring: oklch(0.7 0.18 230);
/* Action button colors - One Dark blue/magenta theme */
--action-view: oklch(0.7 0.18 230); /* Blue */
--action-view-hover: oklch(0.65 0.2 230);
--action-followup: oklch(0.75 0.15 320); /* Magenta */
--action-followup-hover: oklch(0.7 0.17 320);
--action-commit: oklch(0.75 0.18 150); /* Green */
--action-commit-hover: oklch(0.7 0.2 150);
--action-verify: oklch(0.75 0.18 150); /* Green */
--action-verify-hover: oklch(0.7 0.2 150);
/* Running indicator - Blue */
--running-indicator: oklch(0.7 0.18 230);
--running-indicator-text: oklch(0.75 0.16 230);
}
/* ========================================
SYNTHWAVE '84 THEME
Neon dreams of the 80s
======================================== */
/* Theme-specific overrides */
.onedark .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #61afef 0%, #c678dd 50%, #61afef 100%);
}
.onedark .animated-outline-inner {
background: oklch(0.19 0.01 250) !important;
color: #61afef !important;
}
.onedark [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
background: oklch(0.25 0.02 250) !important;
color: #c678dd !important;
}
.onedark .slider-track {
background: oklch(0.25 0.01 250);
}
.onedark .slider-range {
background: linear-gradient(to right, #61afef, #c678dd);
}
.onedark .slider-thumb {
background: oklch(0.23 0.01 250);
border-color: #61afef;
}
.onedark .xml-highlight {
color: oklch(0.85 0.02 240); /* #abb2bf */
}
.onedark .xml-tag-bracket {
color: oklch(0.6 0.2 20); /* #e06c75 red */
}
.onedark .xml-tag-name {
color: oklch(0.6 0.2 20); /* Red for tags */
}
.onedark .xml-attribute-name {
color: oklch(0.8 0.15 80); /* #e5c07b yellow */
}
.onedark .xml-attribute-equals {
color: oklch(0.7 0.02 240); /* Dim text */
}
.onedark .xml-attribute-value {
color: oklch(0.75 0.18 150); /* #98c379 green */
}
.onedark .xml-comment {
color: oklch(0.5 0.03 240); /* #5c6370 */
font-style: italic;
}
.onedark .xml-cdata {
color: oklch(0.7 0.15 180); /* #56b6c2 cyan */
}
.onedark .xml-doctype {
color: oklch(0.75 0.15 320); /* #c678dd magenta */
}
.onedark .xml-text {
color: oklch(0.85 0.02 240); /* Text */
}

View File

@@ -0,0 +1,70 @@
/* Red Theme */
.red {
--background: oklch(0.12 0.03 15); /* Deep dark red-tinted black */
--background-50: oklch(0.12 0.03 15 / 0.5);
--background-80: oklch(0.12 0.03 15 / 0.8);
--foreground: oklch(0.95 0.01 15); /* Off-white with warm tint */
--foreground-secondary: oklch(0.7 0.02 15);
--foreground-muted: oklch(0.5 0.03 15);
--card: oklch(0.18 0.04 15); /* Slightly lighter dark red */
--card-foreground: oklch(0.95 0.01 15);
--popover: oklch(0.15 0.035 15);
--popover-foreground: oklch(0.95 0.01 15);
--primary: oklch(0.55 0.25 25); /* Vibrant crimson red */
--primary-foreground: oklch(0.98 0 0);
--brand-400: oklch(0.6 0.23 25);
--brand-500: oklch(0.55 0.25 25); /* Crimson */
--brand-600: oklch(0.5 0.27 25);
--secondary: oklch(0.22 0.05 15);
--secondary-foreground: oklch(0.95 0.01 15);
--muted: oklch(0.22 0.05 15);
--muted-foreground: oklch(0.5 0.03 15);
--accent: oklch(0.28 0.06 15);
--accent-foreground: oklch(0.95 0.01 15);
--destructive: oklch(0.6 0.28 30); /* Bright orange-red for destructive */
--border: oklch(0.35 0.08 15);
--border-glass: oklch(0.55 0.25 25 / 0.3);
--input: oklch(0.18 0.04 15);
--ring: oklch(0.55 0.25 25);
--chart-1: oklch(0.55 0.25 25); /* Crimson */
--chart-2: oklch(0.7 0.2 50); /* Orange */
--chart-3: oklch(0.8 0.18 80); /* Gold */
--chart-4: oklch(0.6 0.22 0); /* Pure red */
--chart-5: oklch(0.65 0.2 350); /* Pink-red */
--sidebar: oklch(0.1 0.025 15);
--sidebar-foreground: oklch(0.95 0.01 15);
--sidebar-primary: oklch(0.55 0.25 25);
--sidebar-primary-foreground: oklch(0.98 0 0);
--sidebar-accent: oklch(0.22 0.05 15);
--sidebar-accent-foreground: oklch(0.95 0.01 15);
--sidebar-border: oklch(0.35 0.08 15);
--sidebar-ring: oklch(0.55 0.25 25);
/* Action button colors - Red theme */
--action-view: oklch(0.55 0.25 25); /* Crimson */
--action-view-hover: oklch(0.5 0.27 25);
--action-followup: oklch(0.7 0.2 50); /* Orange */
--action-followup-hover: oklch(0.65 0.22 50);
--action-commit: oklch(0.6 0.2 140); /* Green for positive actions */
--action-commit-hover: oklch(0.55 0.22 140);
--action-verify: oklch(0.6 0.2 140); /* Green */
--action-verify-hover: oklch(0.55 0.22 140);
/* Running indicator - Crimson */
--running-indicator: oklch(0.55 0.25 25);
--running-indicator-text: oklch(0.6 0.23 25);
}

View File

@@ -0,0 +1,227 @@
/* Retro Theme */
.retro {
/* Retro / Cyberpunk Theme */
--background: oklch(0 0 0); /* Pure Black */
--background-50: oklch(0 0 0 / 0.5);
--background-80: oklch(0 0 0 / 0.8);
/* Neon Green Text */
--foreground: oklch(0.85 0.25 145); /* Neon Green */
--foreground-secondary: oklch(0.7 0.2 145);
--foreground-muted: oklch(0.5 0.15 145);
/* Hard Edges */
--radius: 0px;
/* UI Elements */
--card: oklch(0 0 0); /* Black card */
--card-foreground: oklch(0.85 0.25 145);
--popover: oklch(0.05 0.05 145);
--popover-foreground: oklch(0.85 0.25 145);
--primary: oklch(0.85 0.25 145); /* Neon Green */
--primary-foreground: oklch(0 0 0); /* Black text on green */
--brand-400: oklch(0.85 0.25 145);
--brand-500: oklch(0.85 0.25 145);
--brand-600: oklch(0.75 0.25 145);
--secondary: oklch(0.1 0.1 145); /* Dark Green bg */
--secondary-foreground: oklch(0.85 0.25 145);
--muted: oklch(0.1 0.05 145);
--muted-foreground: oklch(0.5 0.15 145);
--accent: oklch(0.2 0.2 145); /* Brighter green accent */
--accent-foreground: oklch(0.85 0.25 145);
--destructive: oklch(0.6 0.25 25); /* Keep red for destructive */
--border: oklch(0.3 0.15 145); /* Visible Green Border */
--border-glass: oklch(0.85 0.25 145 / 0.3);
--input: oklch(0.1 0.1 145);
--ring: oklch(0.85 0.25 145);
/* Charts - various neons */
--chart-1: oklch(0.85 0.25 145); /* Green */
--chart-2: oklch(0.8 0.25 300); /* Purple Neon */
--chart-3: oklch(0.8 0.25 200); /* Cyan Neon */
--chart-4: oklch(0.8 0.25 60); /* Yellow Neon */
--chart-5: oklch(0.8 0.25 20); /* Red Neon */
/* Sidebar */
--sidebar: oklch(0 0 0);
--sidebar-foreground: oklch(0.85 0.25 145);
--sidebar-primary: oklch(0.85 0.25 145);
--sidebar-primary-foreground: oklch(0 0 0);
--sidebar-accent: oklch(0.1 0.1 145);
--sidebar-accent-foreground: oklch(0.85 0.25 145);
--sidebar-border: oklch(0.3 0.15 145);
--sidebar-ring: oklch(0.85 0.25 145);
/* Fonts */
--font-sans: var(--font-geist-mono); /* Force Mono everywhere */
/* Action button colors - All green neon for retro theme */
--action-view: oklch(0.85 0.25 145); /* Neon Green */
--action-view-hover: oklch(0.9 0.25 145);
--action-followup: oklch(0.85 0.25 145); /* Neon Green */
--action-followup-hover: oklch(0.9 0.25 145);
--action-commit: oklch(0.85 0.25 145); /* Neon Green */
--action-commit-hover: oklch(0.9 0.25 145);
--action-verify: oklch(0.85 0.25 145); /* Neon Green */
--action-verify-hover: oklch(0.9 0.25 145);
/* Running indicator - Neon Green for retro */
--running-indicator: oklch(0.85 0.25 145);
--running-indicator-text: oklch(0.85 0.25 145);
}
/* ========================================
DRACULA THEME
Inspired by the popular Dracula VS Code theme
======================================== */
/* Theme-specific overrides */
.retro .scrollbar-visible::-webkit-scrollbar-thumb {
background: var(--primary);
border-radius: 0;
}
.retro .scrollbar-visible::-webkit-scrollbar-track {
background: var(--background);
border-radius: 0;
}
.retro .scrollbar-styled::-webkit-scrollbar-thumb {
background: var(--primary);
border-radius: 0;
}
.retro .scrollbar-styled::-webkit-scrollbar-track {
background: var(--background);
border-radius: 0;
}
.retro .glass,
.retro .glass-subtle,
.retro .glass-strong,
.retro .bg-glass,
.retro .bg-glass-80 {
backdrop-filter: none;
background: var(--background);
border: 1px solid var(--border);
}
.retro .gradient-brand {
background: var(--primary);
color: var(--primary-foreground);
}
.retro .content-bg {
background:
linear-gradient(rgba(0, 255, 65, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 255, 65, 0.03) 1px, transparent 1px),
var(--background);
background-size: 20px 20px;
}
.retro .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #00ff41 0%, #00ffff 25%, #ff00ff 50%, #00ffff 75%, #00ff41 100%);
animation: spin 2s linear infinite, retro-glow 1s ease-in-out infinite alternate;
}
.retro [data-slot="button"][class*="animated-outline"] {
border-radius: 0 !important;
}
.retro .animated-outline-inner {
background: oklch(0 0 0) !important;
color: #00ff41 !important;
border-radius: 0 !important;
text-shadow: 0 0 5px #00ff41;
font-family: var(--font-geist-mono), monospace;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.retro [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
background: oklch(0.1 0.1 145) !important;
color: #00ff41 !important;
box-shadow:
0 0 10px #00ff41,
0 0 20px #00ff41,
inset 0 0 10px rgba(0, 255, 65, 0.1);
text-shadow: 0 0 10px #00ff41, 0 0 20px #00ff41;
}
.retro .slider-track {
background: oklch(0.15 0.05 145);
border: 1px solid #00ff41;
border-radius: 0 !important;
}
.retro .slider-range {
background: #00ff41;
box-shadow: 0 0 10px #00ff41, 0 0 5px #00ff41;
border-radius: 0 !important;
}
.retro .slider-thumb {
background: oklch(0 0 0);
border: 2px solid #00ff41;
border-radius: 0 !important;
box-shadow: 0 0 8px #00ff41;
}
.retro .slider-thumb:hover {
background: oklch(0.1 0.1 145);
box-shadow: 0 0 12px #00ff41, 0 0 20px #00ff41;
}
.retro .xml-highlight {
color: oklch(0.85 0.25 145); /* Neon green default */
}
.retro .xml-tag-bracket {
color: oklch(0.8 0.25 200); /* Cyan for brackets */
}
.retro .xml-tag-name {
color: oklch(0.85 0.25 145); /* Bright green for tags */
text-shadow: 0 0 5px oklch(0.85 0.25 145 / 0.5);
}
.retro .xml-attribute-name {
color: oklch(0.8 0.25 300); /* Purple neon for attrs */
}
.retro .xml-attribute-equals {
color: oklch(0.6 0.15 145); /* Dim green for = */
}
.retro .xml-attribute-value {
color: oklch(0.8 0.25 60); /* Yellow neon for strings */
}
.retro .xml-comment {
color: oklch(0.5 0.15 145); /* Dim green for comments */
font-style: italic;
}
.retro .xml-cdata {
color: oklch(0.75 0.2 200); /* Cyan for CDATA */
}
.retro .xml-doctype {
color: oklch(0.75 0.2 300); /* Purple for DOCTYPE */
}
.retro .xml-text {
color: oklch(0.7 0.2 145); /* Green text */
}

View File

@@ -0,0 +1,144 @@
/* Solarized Theme */
.solarized {
--background: oklch(0.2 0.02 230); /* #002b36 base03 */
--background-50: oklch(0.2 0.02 230 / 0.5);
--background-80: oklch(0.2 0.02 230 / 0.8);
--foreground: oklch(0.75 0.02 90); /* #839496 base0 */
--foreground-secondary: oklch(0.6 0.03 200); /* #657b83 base00 */
--foreground-muted: oklch(0.5 0.04 200); /* #586e75 base01 */
--card: oklch(0.23 0.02 230); /* #073642 base02 */
--card-foreground: oklch(0.75 0.02 90);
--popover: oklch(0.22 0.02 230);
--popover-foreground: oklch(0.75 0.02 90);
--primary: oklch(0.65 0.15 220); /* #268bd2 blue */
--primary-foreground: oklch(0.2 0.02 230);
--brand-400: oklch(0.7 0.15 220);
--brand-500: oklch(0.65 0.15 220); /* #268bd2 */
--brand-600: oklch(0.6 0.17 220);
--secondary: oklch(0.25 0.02 230);
--secondary-foreground: oklch(0.75 0.02 90);
--muted: oklch(0.25 0.02 230);
--muted-foreground: oklch(0.5 0.04 200);
--accent: oklch(0.28 0.03 230);
--accent-foreground: oklch(0.75 0.02 90);
--destructive: oklch(0.55 0.2 25); /* #dc322f red */
--border: oklch(0.35 0.03 230);
--border-glass: oklch(0.65 0.15 220 / 0.3);
--input: oklch(0.23 0.02 230);
--ring: oklch(0.65 0.15 220);
--chart-1: oklch(0.65 0.15 220); /* Blue */
--chart-2: oklch(0.6 0.18 180); /* Cyan #2aa198 */
--chart-3: oklch(0.65 0.2 140); /* Green #859900 */
--chart-4: oklch(0.7 0.18 55); /* Yellow #b58900 */
--chart-5: oklch(0.6 0.2 30); /* Orange #cb4b16 */
--sidebar: oklch(0.18 0.02 230);
--sidebar-foreground: oklch(0.75 0.02 90);
--sidebar-primary: oklch(0.65 0.15 220);
--sidebar-primary-foreground: oklch(0.2 0.02 230);
--sidebar-accent: oklch(0.25 0.02 230);
--sidebar-accent-foreground: oklch(0.75 0.02 90);
--sidebar-border: oklch(0.35 0.03 230);
--sidebar-ring: oklch(0.65 0.15 220);
/* Action button colors - Solarized blue/cyan theme */
--action-view: oklch(0.65 0.15 220); /* Blue */
--action-view-hover: oklch(0.6 0.17 220);
--action-followup: oklch(0.6 0.18 180); /* Cyan */
--action-followup-hover: oklch(0.55 0.2 180);
--action-commit: oklch(0.65 0.2 140); /* Green */
--action-commit-hover: oklch(0.6 0.22 140);
--action-verify: oklch(0.65 0.2 140); /* Green */
--action-verify-hover: oklch(0.6 0.22 140);
/* Running indicator - Blue */
--running-indicator: oklch(0.65 0.15 220);
--running-indicator-text: oklch(0.7 0.13 220);
}
/* ========================================
GRUVBOX THEME
Retro groove color scheme
======================================== */
/* Theme-specific overrides */
.solarized .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #268bd2 0%, #2aa198 50%, #268bd2 100%);
}
.solarized .animated-outline-inner {
background: oklch(0.2 0.02 230) !important;
color: #268bd2 !important;
}
.solarized [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
background: oklch(0.25 0.03 230) !important;
color: #2aa198 !important;
}
.solarized .slider-track {
background: oklch(0.25 0.02 230);
}
.solarized .slider-range {
background: linear-gradient(to right, #268bd2, #2aa198);
}
.solarized .slider-thumb {
background: oklch(0.23 0.02 230);
border-color: #268bd2;
}
.solarized .xml-highlight {
color: oklch(0.75 0.02 90); /* #839496 */
}
.solarized .xml-tag-bracket {
color: oklch(0.65 0.15 220); /* #268bd2 blue */
}
.solarized .xml-tag-name {
color: oklch(0.65 0.15 220); /* Blue for tags */
}
.solarized .xml-attribute-name {
color: oklch(0.6 0.18 180); /* #2aa198 cyan */
}
.solarized .xml-attribute-equals {
color: oklch(0.75 0.02 90); /* Base text */
}
.solarized .xml-attribute-value {
color: oklch(0.65 0.2 140); /* #859900 green */
}
.solarized .xml-comment {
color: oklch(0.5 0.04 200); /* #586e75 */
font-style: italic;
}
.solarized .xml-cdata {
color: oklch(0.6 0.18 180); /* Cyan */
}
.solarized .xml-doctype {
color: oklch(0.6 0.2 290); /* #6c71c4 violet */
}
.solarized .xml-text {
color: oklch(0.75 0.02 90); /* Base text */
}

View File

@@ -0,0 +1,111 @@
/* Sunset Theme */
.sunset {
/* Sunset Theme - Mellow oranges and soft purples */
--background: oklch(0.15 0.02 280); /* Deep twilight blue-purple */
--background-50: oklch(0.15 0.02 280 / 0.5);
--background-80: oklch(0.15 0.02 280 / 0.8);
--foreground: oklch(0.95 0.01 80); /* Warm white */
--foreground-secondary: oklch(0.75 0.02 60);
--foreground-muted: oklch(0.6 0.02 60);
--card: oklch(0.2 0.025 280);
--card-foreground: oklch(0.95 0.01 80);
--popover: oklch(0.18 0.02 280);
--popover-foreground: oklch(0.95 0.01 80);
--primary: oklch(0.68 0.18 45); /* Mellow sunset orange */
--primary-foreground: oklch(0.15 0.02 280);
--brand-400: oklch(0.72 0.17 45);
--brand-500: oklch(0.68 0.18 45); /* Soft sunset orange */
--brand-600: oklch(0.64 0.19 42);
--secondary: oklch(0.25 0.03 280);
--secondary-foreground: oklch(0.95 0.01 80);
--muted: oklch(0.27 0.03 280);
--muted-foreground: oklch(0.6 0.02 60);
--accent: oklch(0.35 0.04 310);
--accent-foreground: oklch(0.95 0.01 80);
--destructive: oklch(0.6 0.2 25); /* Muted red */
--border: oklch(0.32 0.04 280);
--border-glass: oklch(0.68 0.18 45 / 0.3);
--input: oklch(0.2 0.025 280);
--ring: oklch(0.68 0.18 45);
--chart-1: oklch(0.68 0.18 45); /* Mellow orange */
--chart-2: oklch(0.75 0.16 340); /* Soft pink sunset */
--chart-3: oklch(0.78 0.18 70); /* Soft golden */
--chart-4: oklch(0.66 0.19 42); /* Subtle coral */
--chart-5: oklch(0.72 0.14 310); /* Pastel purple */
--sidebar: oklch(0.13 0.015 280);
--sidebar-foreground: oklch(0.95 0.01 80);
--sidebar-primary: oklch(0.68 0.18 45);
--sidebar-primary-foreground: oklch(0.15 0.02 280);
--sidebar-accent: oklch(0.25 0.03 280);
--sidebar-accent-foreground: oklch(0.95 0.01 80);
--sidebar-border: oklch(0.32 0.04 280);
--sidebar-ring: oklch(0.68 0.18 45);
/* Action button colors - Mellow sunset palette */
--action-view: oklch(0.68 0.18 45); /* Mellow orange */
--action-view-hover: oklch(0.64 0.19 42);
--action-followup: oklch(0.75 0.16 340); /* Soft pink */
--action-followup-hover: oklch(0.7 0.17 340);
--action-commit: oklch(0.65 0.16 140); /* Soft green */
--action-commit-hover: oklch(0.6 0.17 140);
--action-verify: oklch(0.65 0.16 140); /* Soft green */
--action-verify-hover: oklch(0.6 0.17 140);
/* Running indicator - Mellow orange */
--running-indicator: oklch(0.68 0.18 45);
--running-indicator-text: oklch(0.72 0.17 45);
/* Status colors - Sunset theme */
--status-success: oklch(0.65 0.16 140);
--status-success-bg: oklch(0.65 0.16 140 / 0.2);
--status-warning: oklch(0.78 0.18 70);
--status-warning-bg: oklch(0.78 0.18 70 / 0.2);
--status-error: oklch(0.65 0.2 25);
--status-error-bg: oklch(0.65 0.2 25 / 0.2);
--status-info: oklch(0.75 0.16 340);
--status-info-bg: oklch(0.75 0.16 340 / 0.2);
--status-backlog: oklch(0.65 0.02 280);
--status-in-progress: oklch(0.78 0.18 70);
--status-waiting: oklch(0.72 0.17 60);
}
/* Theme-specific overrides */
/* Sunset theme scrollbar */
.sunset ::-webkit-scrollbar-thumb,
.sunset .scrollbar-visible::-webkit-scrollbar-thumb {
background: oklch(0.5 0.14 45);
border-radius: 4px;
}
.sunset ::-webkit-scrollbar-thumb:hover,
.sunset .scrollbar-visible::-webkit-scrollbar-thumb:hover {
background: oklch(0.58 0.16 45);
}
.sunset ::-webkit-scrollbar-track,
.sunset .scrollbar-visible::-webkit-scrollbar-track {
background: oklch(0.18 0.03 280);
}
.sunset .scrollbar-styled::-webkit-scrollbar-thumb {
background: oklch(0.5 0.14 45);
}
.sunset .scrollbar-styled::-webkit-scrollbar-thumb:hover {
background: oklch(0.58 0.16 45);
}

View File

@@ -0,0 +1,149 @@
/* Synthwave Theme */
.synthwave {
--background: oklch(0.15 0.05 290); /* #262335 */
--background-50: oklch(0.15 0.05 290 / 0.5);
--background-80: oklch(0.15 0.05 290 / 0.8);
--foreground: oklch(0.95 0.02 320); /* #ffffff with warm tint */
--foreground-secondary: oklch(0.75 0.05 320);
--foreground-muted: oklch(0.55 0.08 290);
--card: oklch(0.2 0.06 290); /* #34294f */
--card-foreground: oklch(0.95 0.02 320);
--popover: oklch(0.18 0.05 290);
--popover-foreground: oklch(0.95 0.02 320);
--primary: oklch(0.7 0.28 350); /* #f97e72 hot pink */
--primary-foreground: oklch(0.15 0.05 290);
--brand-400: oklch(0.75 0.28 350);
--brand-500: oklch(0.7 0.28 350); /* Hot pink */
--brand-600: oklch(0.65 0.3 350);
--secondary: oklch(0.25 0.07 290);
--secondary-foreground: oklch(0.95 0.02 320);
--muted: oklch(0.25 0.07 290);
--muted-foreground: oklch(0.55 0.08 290);
--accent: oklch(0.3 0.08 290);
--accent-foreground: oklch(0.95 0.02 320);
--destructive: oklch(0.6 0.25 15);
--border: oklch(0.4 0.1 290);
--border-glass: oklch(0.7 0.28 350 / 0.3);
--input: oklch(0.2 0.06 290);
--ring: oklch(0.7 0.28 350);
--chart-1: oklch(0.7 0.28 350); /* Hot pink */
--chart-2: oklch(0.8 0.25 200); /* Cyan #72f1b8 */
--chart-3: oklch(0.85 0.2 60); /* Yellow #fede5d */
--chart-4: oklch(0.7 0.25 280); /* Purple #ff7edb */
--chart-5: oklch(0.7 0.2 30); /* Orange #f97e72 */
--sidebar: oklch(0.13 0.05 290);
--sidebar-foreground: oklch(0.95 0.02 320);
--sidebar-primary: oklch(0.7 0.28 350);
--sidebar-primary-foreground: oklch(0.15 0.05 290);
--sidebar-accent: oklch(0.25 0.07 290);
--sidebar-accent-foreground: oklch(0.95 0.02 320);
--sidebar-border: oklch(0.4 0.1 290);
--sidebar-ring: oklch(0.7 0.28 350);
/* Action button colors - Synthwave hot pink/cyan theme */
--action-view: oklch(0.7 0.28 350); /* Hot pink */
--action-view-hover: oklch(0.65 0.3 350);
--action-followup: oklch(0.8 0.25 200); /* Cyan */
--action-followup-hover: oklch(0.75 0.27 200);
--action-commit: oklch(0.85 0.2 60); /* Yellow */
--action-commit-hover: oklch(0.8 0.22 60);
--action-verify: oklch(0.85 0.2 60); /* Yellow */
--action-verify-hover: oklch(0.8 0.22 60);
/* Running indicator - Hot pink */
--running-indicator: oklch(0.7 0.28 350);
--running-indicator-text: oklch(0.75 0.26 350);
}
/* Red Theme - Bold crimson/red aesthetic */
/* Theme-specific overrides */
.synthwave .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #f97e72 0%, #72f1b8 25%, #ff7edb 50%, #72f1b8 75%, #f97e72 100%);
animation: spin 2s linear infinite, synthwave-glow 1.5s ease-in-out infinite alternate;
}
.synthwave .animated-outline-inner {
background: oklch(0.15 0.05 290) !important;
color: #f97e72 !important;
text-shadow: 0 0 8px #f97e72;
}
.synthwave [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
background: oklch(0.22 0.07 290) !important;
color: #72f1b8 !important;
text-shadow: 0 0 12px #72f1b8;
box-shadow: 0 0 15px rgba(114, 241, 184, 0.3);
}
.synthwave .slider-track {
background: oklch(0.25 0.07 290);
}
.synthwave .slider-range {
background: linear-gradient(to right, #f97e72, #ff7edb);
box-shadow: 0 0 10px #f97e72, 0 0 5px #ff7edb;
}
.synthwave .slider-thumb {
background: oklch(0.2 0.06 290);
border-color: #f97e72;
box-shadow: 0 0 8px #f97e72;
}
.synthwave .xml-highlight {
color: oklch(0.95 0.02 320); /* Warm white */
}
.synthwave .xml-tag-bracket {
color: oklch(0.7 0.28 350); /* #f97e72 hot pink */
}
.synthwave .xml-tag-name {
color: oklch(0.7 0.28 350); /* Hot pink */
text-shadow: 0 0 8px oklch(0.7 0.28 350 / 0.5);
}
.synthwave .xml-attribute-name {
color: oklch(0.7 0.25 280); /* #ff7edb purple */
}
.synthwave .xml-attribute-equals {
color: oklch(0.8 0.02 320); /* White-ish */
}
.synthwave .xml-attribute-value {
color: oklch(0.85 0.2 60); /* #fede5d yellow */
text-shadow: 0 0 5px oklch(0.85 0.2 60 / 0.3);
}
.synthwave .xml-comment {
color: oklch(0.55 0.08 290); /* Dim purple */
font-style: italic;
}
.synthwave .xml-cdata {
color: oklch(0.8 0.25 200); /* #72f1b8 cyan */
}
.synthwave .xml-doctype {
color: oklch(0.8 0.25 200); /* Cyan */
}
.synthwave .xml-text {
color: oklch(0.95 0.02 320); /* White */
}

View File

@@ -0,0 +1,144 @@
/* Tokyonight Theme */
.tokyonight {
--background: oklch(0.16 0.03 260); /* #1a1b26 */
--background-50: oklch(0.16 0.03 260 / 0.5);
--background-80: oklch(0.16 0.03 260 / 0.8);
--foreground: oklch(0.85 0.02 250); /* #a9b1d6 */
--foreground-secondary: oklch(0.7 0.03 250);
--foreground-muted: oklch(0.5 0.04 250); /* #565f89 */
--card: oklch(0.2 0.03 260); /* #24283b */
--card-foreground: oklch(0.85 0.02 250);
--popover: oklch(0.18 0.03 260);
--popover-foreground: oklch(0.85 0.02 250);
--primary: oklch(0.7 0.18 280); /* #7aa2f7 blue */
--primary-foreground: oklch(0.16 0.03 260);
--brand-400: oklch(0.75 0.18 280);
--brand-500: oklch(0.7 0.18 280); /* #7aa2f7 */
--brand-600: oklch(0.65 0.2 280); /* #7dcfff */
--secondary: oklch(0.24 0.03 260); /* #292e42 */
--secondary-foreground: oklch(0.85 0.02 250);
--muted: oklch(0.24 0.03 260);
--muted-foreground: oklch(0.5 0.04 250);
--accent: oklch(0.28 0.04 260);
--accent-foreground: oklch(0.85 0.02 250);
--destructive: oklch(0.65 0.2 15); /* #f7768e */
--border: oklch(0.32 0.04 260);
--border-glass: oklch(0.7 0.18 280 / 0.3);
--input: oklch(0.2 0.03 260);
--ring: oklch(0.7 0.18 280);
--chart-1: oklch(0.7 0.18 280); /* Blue #7aa2f7 */
--chart-2: oklch(0.75 0.18 200); /* Cyan #7dcfff */
--chart-3: oklch(0.75 0.18 140); /* Green #9ece6a */
--chart-4: oklch(0.7 0.2 320); /* Magenta #bb9af7 */
--chart-5: oklch(0.8 0.18 70); /* Yellow #e0af68 */
--sidebar: oklch(0.14 0.03 260);
--sidebar-foreground: oklch(0.85 0.02 250);
--sidebar-primary: oklch(0.7 0.18 280);
--sidebar-primary-foreground: oklch(0.16 0.03 260);
--sidebar-accent: oklch(0.24 0.03 260);
--sidebar-accent-foreground: oklch(0.85 0.02 250);
--sidebar-border: oklch(0.32 0.04 260);
--sidebar-ring: oklch(0.7 0.18 280);
/* Action button colors - Tokyo Night blue/magenta theme */
--action-view: oklch(0.7 0.18 280); /* Blue */
--action-view-hover: oklch(0.65 0.2 280);
--action-followup: oklch(0.75 0.18 200); /* Cyan */
--action-followup-hover: oklch(0.7 0.2 200);
--action-commit: oklch(0.75 0.18 140); /* Green */
--action-commit-hover: oklch(0.7 0.2 140);
--action-verify: oklch(0.75 0.18 140); /* Green */
--action-verify-hover: oklch(0.7 0.2 140);
/* Running indicator - Blue */
--running-indicator: oklch(0.7 0.18 280);
--running-indicator-text: oklch(0.75 0.16 280);
}
/* ========================================
SOLARIZED DARK THEME
The classic color scheme by Ethan Schoonover
======================================== */
/* Theme-specific overrides */
.tokyonight .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #7aa2f7 0%, #bb9af7 50%, #7aa2f7 100%);
}
.tokyonight .animated-outline-inner {
background: oklch(0.16 0.03 260) !important;
color: #7aa2f7 !important;
}
.tokyonight [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
background: oklch(0.22 0.04 260) !important;
color: #bb9af7 !important;
}
.tokyonight .slider-track {
background: oklch(0.24 0.03 260);
}
.tokyonight .slider-range {
background: linear-gradient(to right, #7aa2f7, #bb9af7);
}
.tokyonight .slider-thumb {
background: oklch(0.2 0.03 260);
border-color: #7aa2f7;
}
.tokyonight .xml-highlight {
color: oklch(0.85 0.02 250); /* #a9b1d6 */
}
.tokyonight .xml-tag-bracket {
color: oklch(0.65 0.2 15); /* #f7768e red */
}
.tokyonight .xml-tag-name {
color: oklch(0.65 0.2 15); /* Red for tags */
}
.tokyonight .xml-attribute-name {
color: oklch(0.7 0.2 320); /* #bb9af7 purple */
}
.tokyonight .xml-attribute-equals {
color: oklch(0.75 0.02 250); /* Dim text */
}
.tokyonight .xml-attribute-value {
color: oklch(0.75 0.18 140); /* #9ece6a green */
}
.tokyonight .xml-comment {
color: oklch(0.5 0.04 250); /* #565f89 */
font-style: italic;
}
.tokyonight .xml-cdata {
color: oklch(0.75 0.18 200); /* #7dcfff cyan */
}
.tokyonight .xml-doctype {
color: oklch(0.7 0.18 280); /* #7aa2f7 blue */
}
.tokyonight .xml-text {
color: oklch(0.85 0.02 250); /* Text color */
}