feat: per-workspace theme system with 5 theme options

Replaces in-memory darkMode toggle with persisted per-workspace theme
selection. Adds dark, nord, dracula, and solarized CSS theme definitions.
Theme is applied via data-theme attribute and derived isDark class.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tristan Michael 2026-04-03 03:58:06 -07:00
parent a60b1a997b
commit a1e97bc0fe
3 changed files with 110 additions and 10 deletions

View file

@ -12,7 +12,7 @@
});
</script>
<div class={app.darkMode ? "dark" : ""}>
<div class={app.isDark ? "dark" : ""} data-theme={app.currentTheme ?? ""}>
<div class="h-screen w-screen" class:p-2={isLinux}>
<div
class="relative h-full w-full overflow-hidden bg-surface-light text-text-light dark:bg-surface-dark dark:text-text-dark"
@ -30,7 +30,7 @@
{/if}
{#if app.screen === "setup"}
<SetupScreen />
<SetupScreen cancellable={app.hasWorkspace} />
{:else}
<TasksScreen />
{/if}

View file

@ -68,3 +68,68 @@ body {
background-color: #242424;
color: #e5e7eb;
}
/* ── Theme overrides ─────────────────────────────────────────────── */
[data-theme="dark"] {
--color-primary: #2d87b8;
--color-primary-hover: #2474a0;
--color-surface-light: #242424;
--color-surface-dark: #242424;
--color-card-light: #303030;
--color-card-dark: #303030;
--color-text-light: #e5e7eb;
--color-text-dark: #e5e7eb;
--color-text-secondary-light: #9ca3af;
--color-text-secondary-dark: #9ca3af;
--color-border-light: #3d3d3d;
--color-border-dark: #3d3d3d;
}
[data-theme="nord"] {
--color-primary: #88c0d0;
--color-primary-hover: #7ab3c3;
--color-surface-light: #2e3440;
--color-surface-dark: #2e3440;
--color-card-light: #3b4252;
--color-card-dark: #3b4252;
--color-text-light: #eceff4;
--color-text-dark: #eceff4;
--color-text-secondary-light: #d8dee9;
--color-text-secondary-dark: #d8dee9;
--color-border-light: #434c5e;
--color-border-dark: #434c5e;
--color-danger: #bf616a;
}
[data-theme="dracula"] {
--color-primary: #bd93f9;
--color-primary-hover: #a87ef0;
--color-surface-light: #282a36;
--color-surface-dark: #282a36;
--color-card-light: #343746;
--color-card-dark: #343746;
--color-text-light: #f8f8f2;
--color-text-dark: #f8f8f2;
--color-text-secondary-light: #bfbfbf;
--color-text-secondary-dark: #bfbfbf;
--color-border-light: #44475a;
--color-border-dark: #44475a;
--color-danger: #ff5555;
}
[data-theme="solarized"] {
--color-primary: #268bd2;
--color-primary-hover: #1e7ac0;
--color-surface-light: #002b36;
--color-surface-dark: #002b36;
--color-card-light: #073642;
--color-card-dark: #073642;
--color-text-light: #93a1a1;
--color-text-dark: #93a1a1;
--color-text-secondary-light: #657b83;
--color-text-secondary-dark: #657b83;
--color-border-light: #094959;
--color-border-dark: #094959;
--color-danger: #dc322f;
}

View file

@ -20,9 +20,7 @@ let config = $state<AppConfig | null>(null);
let lists = $state<TaskList[]>([]);
let activeListId = $state<string | null>(null);
let tasks = $state<Task[]>([]);
let darkMode = $state(
globalThis.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false,
);
let osDark = globalThis.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false;
let syncing = $state(false);
let syncMode = $state<"full" | "push" | "pull">("full");
let lastSyncResult = $state<SyncResult | null>(null);
@ -56,6 +54,16 @@ let hasWorkspace = $derived(
Object.keys(config.workspaces).length > 0,
);
const DARK_THEMES = new Set(["dark", "nord", "dracula", "solarized"]);
let currentTheme = $derived(
config?.current_workspace
? config.workspaces[config.current_workspace]?.theme ?? null
: null,
);
let isDark = $derived(
currentTheme ? DARK_THEMES.has(currentTheme) : osDark,
);
// ── Actions ──────────────────────────────────────────────────────────
async function loadConfig() {
@ -311,8 +319,31 @@ function setSyncMode(mode: "full" | "push" | "pull") {
syncMode = mode;
}
function toggleDarkMode() {
darkMode = !darkMode;
async function setTheme(theme: string | null) {
if (!config?.current_workspace) return;
try {
await invoke("set_workspace_theme", {
workspaceName: config.current_workspace,
theme,
});
config = await invoke<AppConfig>("get_config");
} catch (e) {
error = String(e);
}
}
async function addWebdavWorkspace(name: string, webdavUrl: string, username: string, password: string) {
try {
await invoke("add_webdav_workspace", { name, webdavUrl, username, password });
config = await invoke<AppConfig>("get_config");
await loadLists();
const ws = config?.workspaces[name];
if (ws) invoke("watch_workspace", { path: ws.path }).catch((e) => console.warn("File watcher failed:", e));
screen = "tasks";
error = null;
} catch (e) {
error = String(e);
}
}
function setScreen(s: Screen) {
@ -350,8 +381,11 @@ export const app = {
get completedTasks() {
return completedTasks;
},
get darkMode() {
return darkMode;
get currentTheme() {
return currentTheme;
},
get isDark() {
return isDark;
},
get syncing() {
return syncing;
@ -388,7 +422,8 @@ export const app = {
setGroupByDueDate,
triggerSync,
setSyncMode,
toggleDarkMode,
setTheme,
addWebdavWorkspace,
setScreen,
clearError,
};