Skip to content

Commit d15ff8c

Browse files
authored
Merge pull request #371 from temma02/feature/issue-43-theme-customization
feat(ui): implement theme system with dark/light mode and customization
2 parents 9c97757 + 9ef6461 commit d15ff8c

4 files changed

Lines changed: 157 additions & 2 deletions

File tree

client/src/lib/ThemeToggle.svelte

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<script lang="ts">
2+
import { themeStore, accentColors, type AccentColor, type FontSize } from '$lib/theme';
3+
4+
let open = false;
5+
const accents = Object.keys(accentColors) as AccentColor[];
6+
const fontSizes: { value: FontSize; label: string }[] = [
7+
{ value: 'sm', label: 'Small' },
8+
{ value: 'md', label: 'Medium' },
9+
{ value: 'lg', label: 'Large' },
10+
];
11+
</script>
12+
13+
<div class="relative">
14+
<!-- Toggle dark/light -->
15+
<button
16+
on:click={() => themeStore.toggle()}
17+
class="p-2 rounded-lg border border-[var(--accent)] text-[var(--accent)] hover:bg-[var(--accent)] hover:text-white transition-colors"
18+
title="Toggle theme"
19+
aria-label="Toggle dark/light mode"
20+
>
21+
{#if $themeStore.theme === 'dark'}☀️{:else}🌙{/if}
22+
</button>
23+
24+
<!-- Settings gear -->
25+
<button
26+
on:click={() => (open = !open)}
27+
class="ml-1 p-2 rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
28+
title="Customize theme"
29+
aria-label="Open theme settings"
30+
>⚙️</button>
31+
32+
{#if open}
33+
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
34+
<div
35+
class="absolute right-0 mt-2 w-56 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg p-4 z-50"
36+
on:click|stopPropagation
37+
>
38+
<!-- Accent color -->
39+
<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-2 uppercase tracking-wide">Accent</p>
40+
<div class="flex gap-2 mb-4">
41+
{#each accents as accent}
42+
<button
43+
on:click={() => themeStore.setAccent(accent)}
44+
class="w-6 h-6 rounded-full border-2 transition-transform hover:scale-110"
45+
style="background:{accentColors[accent].bg}; border-color:{$themeStore.accent === accent ? '#000' : 'transparent'}"
46+
title={accent}
47+
aria-label="Set accent to {accent}"
48+
/>
49+
{/each}
50+
</div>
51+
52+
<!-- Font size -->
53+
<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-2 uppercase tracking-wide">Font Size</p>
54+
<div class="flex gap-1">
55+
{#each fontSizes as fs}
56+
<button
57+
on:click={() => themeStore.setFontSize(fs.value)}
58+
class="flex-1 py-1 text-xs rounded-lg border transition-colors
59+
{$themeStore.fontSize === fs.value
60+
? 'bg-[var(--accent)] text-white border-[var(--accent)]'
61+
: 'border-gray-300 dark:border-gray-600 dark:text-gray-300 hover:border-[var(--accent)]'}"
62+
>{fs.label}</button>
63+
{/each}
64+
</div>
65+
</div>
66+
{/if}
67+
</div>
68+
69+
<!-- Close panel on outside click -->
70+
<svelte:window on:click={() => (open = false)} />

client/src/lib/theme.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { writable } from 'svelte/store';
2+
import { browser } from '$app/environment';
3+
4+
export type Theme = 'light' | 'dark';
5+
export type AccentColor = 'indigo' | 'blue' | 'emerald' | 'violet' | 'rose';
6+
export type FontSize = 'sm' | 'md' | 'lg';
7+
8+
export interface ThemePreferences {
9+
theme: Theme;
10+
accent: AccentColor;
11+
fontSize: FontSize;
12+
}
13+
14+
const STORAGE_KEY = 'stellar-escrow-theme';
15+
16+
const defaults: ThemePreferences = { theme: 'light', accent: 'indigo', fontSize: 'md' };
17+
18+
function loadPrefs(): ThemePreferences {
19+
if (!browser) return defaults;
20+
try {
21+
return { ...defaults, ...JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') };
22+
} catch {
23+
return defaults;
24+
}
25+
}
26+
27+
function createThemeStore() {
28+
const { subscribe, set, update } = writable<ThemePreferences>(loadPrefs());
29+
30+
function persist(prefs: ThemePreferences) {
31+
if (browser) localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
32+
applyToDOM(prefs);
33+
}
34+
35+
return {
36+
subscribe,
37+
setTheme: (theme: Theme) => update(p => { const n = { ...p, theme }; persist(n); return n; }),
38+
setAccent: (accent: AccentColor) => update(p => { const n = { ...p, accent }; persist(n); return n; }),
39+
setFontSize: (fontSize: FontSize) => update(p => { const n = { ...p, fontSize }; persist(n); return n; }),
40+
toggle: () => update(p => { const n = { ...p, theme: p.theme === 'light' ? 'dark' : 'light' }; persist(n); return n; }),
41+
init: () => { const prefs = loadPrefs(); set(prefs); persist(prefs); }
42+
};
43+
}
44+
45+
export const accentColors: Record<AccentColor, { bg: string; text: string; border: string }> = {
46+
indigo: { bg: '#6366f1', text: '#6366f1', border: '#6366f1' },
47+
blue: { bg: '#3b82f6', text: '#3b82f6', border: '#3b82f6' },
48+
emerald:{ bg: '#10b981', text: '#10b981', border: '#10b981' },
49+
violet: { bg: '#8b5cf6', text: '#8b5cf6', border: '#8b5cf6' },
50+
rose: { bg: '#f43f5e', text: '#f43f5e', border: '#f43f5e' },
51+
};
52+
53+
export const fontSizeMap: Record<FontSize, string> = { sm: '14px', md: '16px', lg: '18px' };
54+
55+
export function applyToDOM(prefs: ThemePreferences) {
56+
if (!browser) return;
57+
const root = document.documentElement;
58+
root.classList.toggle('dark', prefs.theme === 'dark');
59+
const color = accentColors[prefs.accent].bg;
60+
root.style.setProperty('--accent', color);
61+
root.style.setProperty('--font-size-base', fontSizeMap[prefs.fontSize]);
62+
}
63+
64+
export const themeStore = createThemeStore();

client/src/routes/+layout.svelte

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,33 @@
11
<script lang="ts">
22
import '../app.css';
3+
import { onMount } from 'svelte';
4+
import { themeStore } from '$lib/theme';
5+
import ThemeToggle from '$lib/ThemeToggle.svelte';
6+
7+
onMount(() => themeStore.init());
38
</script>
49

5-
<div class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
6-
<slot />
10+
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors duration-200">
11+
<header class="border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-6 py-3 flex items-center justify-between">
12+
<a href="/" class="font-bold text-lg text-[var(--accent)]">StellarEscrow</a>
13+
<ThemeToggle />
14+
</header>
15+
<main>
16+
<slot />
17+
</main>
718
</div>
819

920
<style global lang="postcss">
1021
@tailwind base;
1122
@tailwind components;
1223
@tailwind utilities;
24+
25+
:root {
26+
--accent: #6366f1;
27+
--font-size-base: 16px;
28+
}
29+
30+
html {
31+
font-size: var(--font-size-base);
32+
}
1333
</style>

client/tailwind.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/** @type {import('tailwindcss').Config} */
22
export default {
33
content: ['./src/**/*.{html,js,svelte,ts}'],
4+
darkMode: 'class',
45
theme: {
56
extend: {}
67
},

0 commit comments

Comments
 (0)