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:
+154
-31
@@ -1,28 +1,7 @@
|
||||
/* 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;
|
||||
@@ -30,8 +9,7 @@
|
||||
--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;
|
||||
@@ -41,14 +19,160 @@
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import ReportPicker from './ReportPicker.svelte';
|
||||
import ThemeSwitcher from './ThemeSwitcher.svelte';
|
||||
|
||||
/**
|
||||
* @type {string}
|
||||
@@ -11,9 +12,6 @@
|
||||
*/
|
||||
export let onReportChange;
|
||||
|
||||
/** @type {HTMLButtonElement|null} */
|
||||
let anchorEl = null;
|
||||
|
||||
/** @type {ReportPicker} */
|
||||
let picker;
|
||||
|
||||
@@ -38,7 +36,6 @@
|
||||
<header class="header">
|
||||
<button
|
||||
class="report-btn"
|
||||
bind:this={anchorEl}
|
||||
on:click={() => picker.toggle()}
|
||||
>
|
||||
<span class="report-label">{displayLabel}</span>
|
||||
@@ -47,19 +44,21 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="header-actions">
|
||||
<ThemeSwitcher mode="cycle" />
|
||||
<a href="/settings" class="settings-btn" aria-label="Settings">
|
||||
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ReportPicker
|
||||
bind:this={picker}
|
||||
{activeReport}
|
||||
onSelect={onReportChange}
|
||||
anchorEl={anchorEl}
|
||||
/>
|
||||
|
||||
<style>
|
||||
@@ -102,6 +101,12 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.settings-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import PropertyPills from './PropertyPills.svelte';
|
||||
|
||||
/**
|
||||
* @type {(input: string) => void}
|
||||
* @type {(input: string) => Promise<void>}
|
||||
*/
|
||||
export let onSubmit;
|
||||
|
||||
@@ -18,11 +18,15 @@
|
||||
/** @type {ReturnType<typeof setTimeout>|null} */
|
||||
let blurTimer = null;
|
||||
|
||||
function handleSubmit() {
|
||||
async function handleSubmit() {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || loading) return;
|
||||
onSubmit(trimmed);
|
||||
try {
|
||||
await onSubmit(trimmed);
|
||||
value = '';
|
||||
} catch {
|
||||
// Value preserved for retry
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,7 +151,7 @@
|
||||
.input:focus {
|
||||
outline: none;
|
||||
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 {
|
||||
|
||||
@@ -36,9 +36,9 @@
|
||||
|
||||
<SwipeAction onSwipe={() => onComplete(task.uuid)}>
|
||||
<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'} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="task-content">
|
||||
<div class="task-header">
|
||||
@@ -105,6 +105,13 @@
|
||||
align-items: flex-start;
|
||||
padding-top: 0.125rem;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
padding-bottom: 0;
|
||||
min-width: unset;
|
||||
min-height: unset;
|
||||
}
|
||||
|
||||
.task-content {
|
||||
@@ -142,38 +149,38 @@
|
||||
}
|
||||
|
||||
.project {
|
||||
background-color: #e0e7ff;
|
||||
color: #4338ca;
|
||||
background-color: var(--color-project-bg);
|
||||
color: var(--color-project-text);
|
||||
}
|
||||
|
||||
.priority-high {
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
background-color: var(--color-priority-high-bg);
|
||||
color: var(--color-priority-high-text);
|
||||
}
|
||||
|
||||
.priority-medium {
|
||||
background-color: #fef3c7;
|
||||
color: #92400e;
|
||||
background-color: var(--color-priority-medium-bg);
|
||||
color: var(--color-priority-medium-text);
|
||||
}
|
||||
|
||||
.priority-low {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
background-color: var(--color-priority-low-bg);
|
||||
color: var(--color-priority-low-text);
|
||||
}
|
||||
|
||||
.due {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
background-color: var(--color-due-bg);
|
||||
color: var(--color-due-text);
|
||||
}
|
||||
|
||||
.due.due-today {
|
||||
background-color: #fef3c7;
|
||||
color: #92400e;
|
||||
background-color: var(--color-due-today-bg);
|
||||
color: var(--color-due-today-text);
|
||||
}
|
||||
|
||||
.due.overdue {
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
background-color: var(--color-overdue-bg);
|
||||
color: var(--color-overdue-text);
|
||||
}
|
||||
|
||||
.tags {
|
||||
@@ -185,8 +192,8 @@
|
||||
.tag {
|
||||
font-size: var(--font-size-xs);
|
||||
padding: 0.125rem 0.5rem;
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
background-color: var(--color-tag-bg);
|
||||
color: var(--color-tag-text);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
</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: "";
|
||||
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 {
|
||||
|
||||
@@ -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();
|
||||
@@ -1,7 +1,25 @@
|
||||
<script>
|
||||
import '../app.css';
|
||||
import { themeStore } from '$lib/stores/theme.js';
|
||||
|
||||
// Subscribe to trigger initialization on mount (sets data-theme attribute)
|
||||
$: void $themeStore;
|
||||
</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">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import { authStore } from '$lib/stores/auth.js';
|
||||
import ThemeSwitcher from '$lib/components/ThemeSwitcher.svelte';
|
||||
import { syncStore } from '$lib/stores/sync.js';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
@@ -75,7 +76,19 @@
|
||||
|
||||
<div class="page">
|
||||
<div class="container">
|
||||
<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}
|
||||
<section class="section">
|
||||
@@ -153,6 +166,39 @@
|
||||
</div>
|
||||
|
||||
<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 {
|
||||
background-color: var(--bg-primary);
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
Reference in New Issue
Block a user