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
@@ -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>