From 0352c22b4fd399f19411ffb6bc4f256dfd20feaa Mon Sep 17 00:00:00 2001 From: Joakim Date: Sat, 14 Feb 2026 22:36:07 +0100 Subject: [PATCH] feat(web): add theme system with Obsidian, Paper, and Midnight themes Three holistic design directions with CSS custom properties, a theme store persisted to localStorage, and a live switcher in both the header (cycle button) and settings page (card selector). Also fixes checkbox checkmark alignment and adds back navigation from settings. Co-Authored-By: Claude Opus 4.6 --- opal-web/src/app.css | 189 +++++++++++++++--- opal-web/src/lib/components/Header.svelte | 15 +- opal-web/src/lib/components/InputBar.svelte | 14 +- opal-web/src/lib/components/TaskItem.svelte | 43 ++-- .../src/lib/components/ThemeSwitcher.svelte | 135 +++++++++++++ .../src/lib/components/ui/Checkbox.svelte | 10 +- opal-web/src/lib/stores/theme.js | 57 ++++++ opal-web/src/routes/+layout.svelte | 18 ++ opal-web/src/routes/settings/+page.svelte | 48 ++++- 9 files changed, 462 insertions(+), 67 deletions(-) create mode 100644 opal-web/src/lib/components/ThemeSwitcher.svelte create mode 100644 opal-web/src/lib/stores/theme.js diff --git a/opal-web/src/app.css b/opal-web/src/app.css index 58a6fa1..1eefb54 100644 --- a/opal-web/src/app.css +++ b/opal-web/src/app.css @@ -1,54 +1,178 @@ /* Global Styles - Mobile-First */ +/* ── Base tokens (shared across themes) ── */ :root { - /* Colors */ - --color-primary: #4f46e5; - --color-primary-dark: #4338ca; - --color-secondary: #6b7280; - --color-success: #10b981; - --color-danger: #ef4444; - --color-warning: #f59e0b; - - /* Backgrounds */ - --bg-primary: #ffffff; - --bg-secondary: #f9fafb; - --bg-tertiary: #f3f4f6; - - /* Text */ - --text-primary: #111827; - --text-secondary: #6b7280; - --text-tertiary: #9ca3af; - - /* Borders */ - --border-color: #e5e7eb; - --border-radius: 0.5rem; - /* Spacing */ --spacing-xs: 0.25rem; --spacing-sm: 0.5rem; --spacing-md: 1rem; --spacing-lg: 1.5rem; --spacing-xl: 2rem; - - /* Typography */ - --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + + /* Typography sizes */ --font-size-xs: 0.75rem; --font-size-sm: 0.875rem; --font-size-base: 1rem; --font-size-lg: 1.125rem; --font-size-xl: 1.25rem; --font-size-2xl: 1.5rem; - + /* Layout */ --content-max-width: 768px; - - /* Shadows */ - --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); - --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1); - --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + --border-radius: 0.5rem; } -/* Reset & Base */ +/* ── Theme: Obsidian (dark productivity) ── */ +[data-theme="obsidian"] { + --font-sans: "Inter", system-ui, sans-serif; + --font-mono: "JetBrains Mono", "Fira Code", ui-monospace, monospace; + + --color-primary: #39d0ba; + --color-primary-dark: #2db8a4; + --color-secondary: #8b949e; + --color-success: #3fb950; + --color-danger: #f85149; + --color-warning: #d29922; + --color-accent: #39d0ba; + + --bg-primary: #161b22; + --bg-secondary: #0d1117; + --bg-tertiary: #21262d; + + --text-primary: #e6edf3; + --text-secondary: #8b949e; + --text-tertiary: #484f58; + + --border-color: #30363d; + + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5); + + --focus-ring: rgba(57, 208, 186, 0.3); + + /* Semantic badge colors */ + --color-project-bg: rgba(57, 208, 186, 0.12); + --color-project-text: #39d0ba; + --color-priority-high-bg: rgba(248, 81, 73, 0.15); + --color-priority-high-text: #f85149; + --color-priority-medium-bg: rgba(210, 153, 34, 0.15); + --color-priority-medium-text: #d29922; + --color-priority-low-bg: rgba(139, 148, 158, 0.1); + --color-priority-low-text: #8b949e; + --color-due-bg: rgba(57, 208, 186, 0.1); + --color-due-text: #39d0ba; + --color-due-today-bg: rgba(210, 153, 34, 0.15); + --color-due-today-text: #d29922; + --color-overdue-bg: rgba(248, 81, 73, 0.15); + --color-overdue-text: #f85149; + --color-tag-bg: rgba(139, 148, 158, 0.1); + --color-tag-text: #8b949e; + + color-scheme: dark; +} + +/* ── Theme: Paper (warm minimal) ── */ +[data-theme="paper"] { + --font-sans: "Inter", "SF Pro Text", system-ui, sans-serif; + --font-mono: ui-monospace, "SF Mono", monospace; + + --color-primary: #6366f1; + --color-primary-dark: #4f46e5; + --color-secondary: #78716c; + --color-success: #10b981; + --color-danger: #e11d48; + --color-warning: #d97706; + --color-accent: #6366f1; + + --bg-primary: #ffffff; + --bg-secondary: #faf8f5; + --bg-tertiary: #f5f5f4; + + --text-primary: #1c1917; + --text-secondary: #78716c; + --text-tertiary: #a8a29e; + + --border-color: #e7e5e4; + + --shadow-sm: 0 1px 2px 0 rgba(28, 25, 23, 0.04); + --shadow-md: 0 4px 6px -1px rgba(28, 25, 23, 0.06); + --shadow-lg: 0 10px 15px -3px rgba(28, 25, 23, 0.08); + + --focus-ring: rgba(99, 102, 241, 0.2); + + /* Semantic badge colors */ + --color-project-bg: #e0e7ff; + --color-project-text: #4338ca; + --color-priority-high-bg: #ffe4e6; + --color-priority-high-text: #be123c; + --color-priority-medium-bg: #fef3c7; + --color-priority-medium-text: #92400e; + --color-priority-low-bg: #f5f5f4; + --color-priority-low-text: #a8a29e; + --color-due-bg: #dbeafe; + --color-due-text: #1e40af; + --color-due-today-bg: #fef3c7; + --color-due-today-text: #92400e; + --color-overdue-bg: #ffe4e6; + --color-overdue-text: #be123c; + --color-tag-bg: #f5f5f4; + --color-tag-text: #78716c; + + color-scheme: light; +} + +/* ── Theme: Midnight (vibrant dark) ── */ +[data-theme="midnight"] { + --font-sans: "Inter", system-ui, sans-serif; + --font-mono: ui-monospace, monospace; + + --color-primary: #8b5cf6; + --color-primary-dark: #7c3aed; + --color-secondary: #94a3b8; + --color-success: #34d399; + --color-danger: #ef4444; + --color-warning: #f97316; + --color-accent: #8b5cf6; + + --bg-primary: #1e293b; + --bg-secondary: #0f172a; + --bg-tertiary: #334155; + + --text-primary: #f1f5f9; + --text-secondary: #94a3b8; + --text-tertiary: #64748b; + + --border-color: #334155; + + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5); + + --focus-ring: rgba(139, 92, 246, 0.3); + + /* Semantic badge colors */ + --color-project-bg: rgba(139, 92, 246, 0.15); + --color-project-text: #a78bfa; + --color-priority-high-bg: rgba(239, 68, 68, 0.15); + --color-priority-high-text: #ef4444; + --color-priority-medium-bg: rgba(249, 115, 22, 0.15); + --color-priority-medium-text: #f97316; + --color-priority-low-bg: rgba(148, 163, 184, 0.1); + --color-priority-low-text: #94a3b8; + --color-due-bg: rgba(139, 92, 246, 0.1); + --color-due-text: #a78bfa; + --color-due-today-bg: rgba(249, 115, 22, 0.15); + --color-due-today-text: #f97316; + --color-overdue-bg: rgba(239, 68, 68, 0.15); + --color-overdue-text: #ef4444; + --color-tag-bg: rgba(148, 163, 184, 0.1); + --color-tag-text: #94a3b8; + + color-scheme: dark; +} + +/* ── Reset & Base ── */ * { margin: 0; padding: 0; @@ -171,4 +295,3 @@ textarea { html { scroll-behavior: smooth; } - diff --git a/opal-web/src/lib/components/Header.svelte b/opal-web/src/lib/components/Header.svelte index 8c0ad96..ecbfac7 100644 --- a/opal-web/src/lib/components/Header.svelte +++ b/opal-web/src/lib/components/Header.svelte @@ -1,5 +1,6 @@ + +{#if mode === 'cycle'} + +{:else} +
+ {#each THEMES as name} + {@const meta = themeMeta[name]} + + {/each} +
+{/if} + + diff --git a/opal-web/src/lib/components/ui/Checkbox.svelte b/opal-web/src/lib/components/ui/Checkbox.svelte index ae1d926..a7d2fc6 100644 --- a/opal-web/src/lib/components/ui/Checkbox.svelte +++ b/opal-web/src/lib/components/ui/Checkbox.svelte @@ -66,13 +66,13 @@ content: ""; position: absolute; display: none; - left: 0.375rem; - top: 0.125rem; - width: 0.375rem; - height: 0.625rem; + left: 50%; + top: 45%; + width: 0.3rem; + height: 0.6rem; border: solid white; border-width: 0 2px 2px 0; - transform: rotate(45deg); + transform: translate(-50%, -50%) rotate(45deg); } .checkbox:checked ~ .checkmark:after { diff --git a/opal-web/src/lib/stores/theme.js b/opal-web/src/lib/stores/theme.js new file mode 100644 index 0000000..f8bf333 --- /dev/null +++ b/opal-web/src/lib/stores/theme.js @@ -0,0 +1,57 @@ +import { writable } from 'svelte/store'; +import { browser } from '$app/environment'; + +/** @typedef {'obsidian' | 'paper' | 'midnight'} ThemeName */ + +const STORAGE_KEY = 'opal-theme'; + +/** @type {ThemeName} */ +const DEFAULT_THEME = 'obsidian'; + +/** @type {ThemeName[]} */ +export const THEMES = ['obsidian', 'paper', 'midnight']; + +/** + * Read stored theme, falling back to default + * @returns {ThemeName} + */ +function getInitial() { + if (!browser) return DEFAULT_THEME; + const stored = localStorage.getItem(STORAGE_KEY); + if (stored && THEMES.includes(/** @type {ThemeName} */ (stored))) { + return /** @type {ThemeName} */ (stored); + } + return DEFAULT_THEME; +} + +function createThemeStore() { + const { subscribe, set, update } = writable(getInitial()); + + /** Apply theme to the document */ + function apply(/** @type {ThemeName} */ theme) { + if (browser) { + document.documentElement.dataset.theme = theme; + localStorage.setItem(STORAGE_KEY, theme); + } + } + + // Apply on every change + subscribe(apply); + + return { + subscribe, + /** @param {ThemeName} theme */ + set(theme) { + set(theme); + }, + /** Cycle to the next theme */ + cycle() { + update(current => { + const idx = THEMES.indexOf(current); + return THEMES[(idx + 1) % THEMES.length]; + }); + } + }; +} + +export const themeStore = createThemeStore(); diff --git a/opal-web/src/routes/+layout.svelte b/opal-web/src/routes/+layout.svelte index fc19820..0e6e9eb 100644 --- a/opal-web/src/routes/+layout.svelte +++ b/opal-web/src/routes/+layout.svelte @@ -1,7 +1,25 @@ + + + {@html `