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 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 22:36:07 +01:00
parent 6c2fc6960a
commit 0352c22b4f
9 changed files with 462 additions and 67 deletions
+156 -33
View File
@@ -1,54 +1,178 @@
/* Global Styles - Mobile-First */ /* Global Styles - Mobile-First */
/* ── Base tokens (shared across themes) ── */
:root { :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 */
--spacing-xs: 0.25rem; --spacing-xs: 0.25rem;
--spacing-sm: 0.5rem; --spacing-sm: 0.5rem;
--spacing-md: 1rem; --spacing-md: 1rem;
--spacing-lg: 1.5rem; --spacing-lg: 1.5rem;
--spacing-xl: 2rem; --spacing-xl: 2rem;
/* Typography */ /* Typography sizes */
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-size-xs: 0.75rem; --font-size-xs: 0.75rem;
--font-size-sm: 0.875rem; --font-size-sm: 0.875rem;
--font-size-base: 1rem; --font-size-base: 1rem;
--font-size-lg: 1.125rem; --font-size-lg: 1.125rem;
--font-size-xl: 1.25rem; --font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem; --font-size-2xl: 1.5rem;
/* Layout */ /* Layout */
--content-max-width: 768px; --content-max-width: 768px;
--border-radius: 0.5rem;
/* 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);
} }
/* 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; margin: 0;
padding: 0; padding: 0;
@@ -171,4 +295,3 @@ textarea {
html { html {
scroll-behavior: smooth; scroll-behavior: smooth;
} }
+10 -5
View File
@@ -1,5 +1,6 @@
<script> <script>
import ReportPicker from './ReportPicker.svelte'; import ReportPicker from './ReportPicker.svelte';
import ThemeSwitcher from './ThemeSwitcher.svelte';
/** /**
* @type {string} * @type {string}
@@ -11,9 +12,6 @@
*/ */
export let onReportChange; export let onReportChange;
/** @type {HTMLButtonElement|null} */
let anchorEl = null;
/** @type {ReportPicker} */ /** @type {ReportPicker} */
let picker; let picker;
@@ -38,7 +36,6 @@
<header class="header"> <header class="header">
<button <button
class="report-btn" class="report-btn"
bind:this={anchorEl}
on:click={() => picker.toggle()} on:click={() => picker.toggle()}
> >
<span class="report-label">{displayLabel}</span> <span class="report-label">{displayLabel}</span>
@@ -47,19 +44,21 @@
</svg> </svg>
</button> </button>
<div class="header-actions">
<ThemeSwitcher mode="cycle" />
<a href="/settings" class="settings-btn" aria-label="Settings"> <a href="/settings" class="settings-btn" aria-label="Settings">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg> </svg>
</a> </a>
</div>
</header> </header>
<ReportPicker <ReportPicker
bind:this={picker} bind:this={picker}
{activeReport} {activeReport}
onSelect={onReportChange} onSelect={onReportChange}
anchorEl={anchorEl}
/> />
<style> <style>
@@ -102,6 +101,12 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
.header-actions {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.settings-btn { .settings-btn {
display: flex; display: flex;
align-items: center; align-items: center;
+9 -5
View File
@@ -2,7 +2,7 @@
import PropertyPills from './PropertyPills.svelte'; import PropertyPills from './PropertyPills.svelte';
/** /**
* @type {(input: string) => void} * @type {(input: string) => Promise<void>}
*/ */
export let onSubmit; export let onSubmit;
@@ -18,11 +18,15 @@
/** @type {ReturnType<typeof setTimeout>|null} */ /** @type {ReturnType<typeof setTimeout>|null} */
let blurTimer = null; let blurTimer = null;
function handleSubmit() { async function handleSubmit() {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed || loading) return; if (!trimmed || loading) return;
onSubmit(trimmed); try {
value = ''; await onSubmit(trimmed);
value = '';
} catch {
// Value preserved for retry
}
} }
/** /**
@@ -147,7 +151,7 @@
.input:focus { .input:focus {
outline: none; outline: none;
border-color: var(--color-primary); border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.15); box-shadow: 0 0 0 2px var(--focus-ring);
} }
.submit-btn { .submit-btn {
+25 -18
View File
@@ -36,9 +36,9 @@
<SwipeAction onSwipe={() => onComplete(task.uuid)}> <SwipeAction onSwipe={() => onComplete(task.uuid)}>
<div class="task-item" class:completing on:transitionend={handleTransitionEnd}> <div class="task-item" class:completing on:transitionend={handleTransitionEnd}>
<div class="task-checkbox" on:click|stopPropagation={handleCheckbox} role="button" tabindex="-1"> <button class="task-checkbox" on:click|stopPropagation={handleCheckbox} type="button" aria-label="Complete task">
<Checkbox checked={task.status === 'C'} /> <Checkbox checked={task.status === 'C'} />
</div> </button>
<div class="task-content"> <div class="task-content">
<div class="task-header"> <div class="task-header">
@@ -105,6 +105,13 @@
align-items: flex-start; align-items: flex-start;
padding-top: 0.125rem; padding-top: 0.125rem;
cursor: pointer; cursor: pointer;
background: none;
border: none;
padding-left: 0;
padding-right: 0;
padding-bottom: 0;
min-width: unset;
min-height: unset;
} }
.task-content { .task-content {
@@ -142,38 +149,38 @@
} }
.project { .project {
background-color: #e0e7ff; background-color: var(--color-project-bg);
color: #4338ca; color: var(--color-project-text);
} }
.priority-high { .priority-high {
background-color: #fee2e2; background-color: var(--color-priority-high-bg);
color: #991b1b; color: var(--color-priority-high-text);
} }
.priority-medium { .priority-medium {
background-color: #fef3c7; background-color: var(--color-priority-medium-bg);
color: #92400e; color: var(--color-priority-medium-text);
} }
.priority-low { .priority-low {
background-color: var(--bg-tertiary); background-color: var(--color-priority-low-bg);
color: var(--text-tertiary); color: var(--color-priority-low-text);
} }
.due { .due {
background-color: #dbeafe; background-color: var(--color-due-bg);
color: #1e40af; color: var(--color-due-text);
} }
.due.due-today { .due.due-today {
background-color: #fef3c7; background-color: var(--color-due-today-bg);
color: #92400e; color: var(--color-due-today-text);
} }
.due.overdue { .due.overdue {
background-color: #fee2e2; background-color: var(--color-overdue-bg);
color: #991b1b; color: var(--color-overdue-text);
} }
.tags { .tags {
@@ -185,8 +192,8 @@
.tag { .tag {
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
padding: 0.125rem 0.5rem; padding: 0.125rem 0.5rem;
background-color: var(--bg-tertiary); background-color: var(--color-tag-bg);
color: var(--text-secondary); color: var(--color-tag-text);
border-radius: 0.25rem; border-radius: 0.25rem;
} }
</style> </style>
@@ -0,0 +1,135 @@
<script>
import { themeStore, THEMES } from '$lib/stores/theme.js';
/** @type {'cycle' | 'full'} */
export let mode = 'cycle';
const themeMeta = {
obsidian: {
label: 'Obsidian',
description: 'Dark productivity',
colors: ['#0d1117', '#161b22', '#39d0ba']
},
paper: {
label: 'Paper',
description: 'Warm minimal',
colors: ['#faf8f5', '#ffffff', '#6366f1']
},
midnight: {
label: 'Midnight',
description: 'Vibrant dark',
colors: ['#0f172a', '#1e293b', '#8b5cf6']
}
};
</script>
{#if mode === 'cycle'}
<button
class="theme-cycle-btn"
on:click={() => themeStore.cycle()}
aria-label="Switch theme ({themeMeta[$themeStore].label})"
title={themeMeta[$themeStore].label}
>
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
</button>
{:else}
<div class="theme-cards">
{#each THEMES as name}
{@const meta = themeMeta[name]}
<button
class="theme-card"
class:active={$themeStore === name}
on:click={() => themeStore.set(name)}
>
<div class="swatches">
{#each meta.colors as color}
<span class="swatch" style="background-color: {color}"></span>
{/each}
</div>
<span class="theme-name">{meta.label}</span>
<span class="theme-desc">{meta.description}</span>
</button>
{/each}
</div>
{/if}
<style>
.theme-cycle-btn {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: var(--border-radius);
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
transition: background-color 0.2s;
min-width: 44px;
}
.theme-cycle-btn:hover {
background-color: var(--bg-secondary);
}
.icon {
width: 1.25rem;
height: 1.25rem;
}
.theme-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-sm);
}
.theme-card {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-md) var(--spacing-sm);
background-color: var(--bg-secondary);
border: 2px solid var(--border-color);
border-radius: var(--border-radius);
cursor: pointer;
font-family: inherit;
min-width: unset;
transition: border-color 0.15s;
}
.theme-card:hover {
border-color: var(--text-tertiary);
}
.theme-card.active {
border-color: var(--color-primary);
}
.swatches {
display: flex;
gap: 0.25rem;
}
.swatch {
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
border: 1px solid var(--border-color);
}
.theme-name {
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--text-primary);
}
.theme-desc {
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
</style>
@@ -66,13 +66,13 @@
content: ""; content: "";
position: absolute; position: absolute;
display: none; display: none;
left: 0.375rem; left: 50%;
top: 0.125rem; top: 45%;
width: 0.375rem; width: 0.3rem;
height: 0.625rem; height: 0.6rem;
border: solid white; border: solid white;
border-width: 0 2px 2px 0; border-width: 0 2px 2px 0;
transform: rotate(45deg); transform: translate(-50%, -50%) rotate(45deg);
} }
.checkbox:checked ~ .checkmark:after { .checkbox:checked ~ .checkmark:after {
+57
View File
@@ -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();
+18
View File
@@ -1,7 +1,25 @@
<script> <script>
import '../app.css'; import '../app.css';
import { themeStore } from '$lib/stores/theme.js';
// Subscribe to trigger initialization on mount (sets data-theme attribute)
$: void $themeStore;
</script> </script>
<svelte:head>
<!-- Prevent FOUC: apply theme before first paint -->
{@html `<script>
(function() {
var t = localStorage.getItem('opal-theme');
if (t && ['obsidian','paper','midnight'].includes(t)) {
document.documentElement.dataset.theme = t;
} else {
document.documentElement.dataset.theme = 'obsidian';
}
})();
</` + 'script>'}
</svelte:head>
<div class="app"> <div class="app">
<slot /> <slot />
</div> </div>
+47 -1
View File
@@ -1,5 +1,6 @@
<script> <script>
import { authStore } from '$lib/stores/auth.js'; import { authStore } from '$lib/stores/auth.js';
import ThemeSwitcher from '$lib/components/ThemeSwitcher.svelte';
import { syncStore } from '$lib/stores/sync.js'; import { syncStore } from '$lib/stores/sync.js';
import Button from '$lib/components/ui/Button.svelte'; import Button from '$lib/components/ui/Button.svelte';
import Input from '$lib/components/ui/Input.svelte'; import Input from '$lib/components/ui/Input.svelte';
@@ -75,8 +76,20 @@
<div class="page"> <div class="page">
<div class="container"> <div class="container">
<h1>Settings</h1> <div class="page-header">
<a href="/" class="back-link" aria-label="Back to tasks">
<svg class="back-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</a>
<h1>Settings</h1>
</div>
<section class="section">
<h2>Theme</h2>
<ThemeSwitcher mode="full" />
</section>
{#if $authStore.isAuthenticated} {#if $authStore.isAuthenticated}
<section class="section"> <section class="section">
<h2>Account</h2> <h2>Account</h2>
@@ -153,6 +166,39 @@
</div> </div>
<style> <style>
.page-header {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding-top: var(--spacing-lg);
margin-bottom: var(--spacing-md);
}
.page-header h1 {
margin-bottom: 0;
}
.back-link {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: var(--border-radius);
color: var(--text-secondary);
transition: background-color 0.2s;
}
.back-link:hover {
background-color: var(--bg-tertiary);
text-decoration: none;
}
.back-icon {
width: 1.25rem;
height: 1.25rem;
}
.section { .section {
background-color: var(--bg-primary); background-color: var(--bg-primary);
border-radius: var(--border-radius); border-radius: var(--border-radius);