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> </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="h-screen w-screen" class:p-2={isLinux}>
<div <div
class="relative h-full w-full overflow-hidden bg-surface-light text-text-light dark:bg-surface-dark dark:text-text-dark" 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}
{#if app.screen === "setup"} {#if app.screen === "setup"}
<SetupScreen /> <SetupScreen cancellable={app.hasWorkspace} />
{:else} {:else}
<TasksScreen /> <TasksScreen />
{/if} {/if}

View file

@ -68,3 +68,68 @@ body {
background-color: #242424; background-color: #242424;
color: #e5e7eb; 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 lists = $state<TaskList[]>([]);
let activeListId = $state<string | null>(null); let activeListId = $state<string | null>(null);
let tasks = $state<Task[]>([]); let tasks = $state<Task[]>([]);
let darkMode = $state( let osDark = globalThis.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false;
globalThis.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false,
);
let syncing = $state(false); let syncing = $state(false);
let syncMode = $state<"full" | "push" | "pull">("full"); let syncMode = $state<"full" | "push" | "pull">("full");
let lastSyncResult = $state<SyncResult | null>(null); let lastSyncResult = $state<SyncResult | null>(null);
@ -56,6 +54,16 @@ let hasWorkspace = $derived(
Object.keys(config.workspaces).length > 0, 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 ────────────────────────────────────────────────────────── // ── Actions ──────────────────────────────────────────────────────────
async function loadConfig() { async function loadConfig() {
@ -311,8 +319,31 @@ function setSyncMode(mode: "full" | "push" | "pull") {
syncMode = mode; syncMode = mode;
} }
function toggleDarkMode() { async function setTheme(theme: string | null) {
darkMode = !darkMode; 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) { function setScreen(s: Screen) {
@ -350,8 +381,11 @@ export const app = {
get completedTasks() { get completedTasks() {
return completedTasks; return completedTasks;
}, },
get darkMode() { get currentTheme() {
return darkMode; return currentTheme;
},
get isDark() {
return isDark;
}, },
get syncing() { get syncing() {
return syncing; return syncing;
@ -388,7 +422,8 @@ export const app = {
setGroupByDueDate, setGroupByDueDate,
triggerSync, triggerSync,
setSyncMode, setSyncMode,
toggleDarkMode, setTheme,
addWebdavWorkspace,
setScreen, setScreen,
clearError, clearError,
}; };