feat: add theme customization with accent colors and light mode

This commit is contained in:
Your Name
2026-02-18 10:34:59 +02:00
parent 99bca0709b
commit 0fe491c15f
3 changed files with 244 additions and 16 deletions

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue' import { onMounted, watch } from 'vue'
import TitleBar from './components/TitleBar.vue' import TitleBar from './components/TitleBar.vue'
import NavRail from './components/NavRail.vue' import NavRail from './components/NavRail.vue'
import ToastNotification from './components/ToastNotification.vue' import ToastNotification from './components/ToastNotification.vue'
@@ -7,6 +7,20 @@ import { useSettingsStore } from './stores/settings'
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
function applyTheme() {
const el = document.documentElement
const mode = settingsStore.settings.theme_mode || 'dark'
const accent = settingsStore.settings.accent_color || 'amber'
if (mode === 'system') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
el.setAttribute('data-theme', prefersDark ? 'dark' : 'light')
} else {
el.setAttribute('data-theme', mode)
}
el.setAttribute('data-accent', accent)
}
onMounted(async () => { onMounted(async () => {
await settingsStore.fetchSettings() await settingsStore.fetchSettings()
const zoom = parseInt(settingsStore.settings.ui_zoom) || 100 const zoom = parseInt(settingsStore.settings.ui_zoom) || 100
@@ -14,6 +28,15 @@ onMounted(async () => {
if (app) { if (app) {
(app.style as any).zoom = `${zoom}%` (app.style as any).zoom = `${zoom}%`
} }
applyTheme()
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (settingsStore.settings.theme_mode === 'system') applyTheme()
})
})
watch(() => [settingsStore.settings.theme_mode, settingsStore.settings.accent_color], () => {
applyTheme()
}) })
</script> </script>

View File

@@ -34,6 +34,63 @@
--font-mono: 'JetBrains Mono', 'IBM Plex Mono', monospace; --font-mono: 'JetBrains Mono', 'IBM Plex Mono', monospace;
} }
/* Accent color overrides */
[data-accent="amber"] {
--color-accent: #D97706;
--color-accent-hover: #B45309;
--color-accent-muted: rgba(217, 119, 6, 0.12);
--color-accent-text: #FBBF24;
}
[data-accent="blue"] {
--color-accent: #3B82F6;
--color-accent-hover: #2563EB;
--color-accent-muted: rgba(59, 130, 246, 0.12);
--color-accent-text: #60A5FA;
}
[data-accent="purple"] {
--color-accent: #8B5CF6;
--color-accent-hover: #7C3AED;
--color-accent-muted: rgba(139, 92, 246, 0.12);
--color-accent-text: #A78BFA;
}
[data-accent="green"] {
--color-accent: #10B981;
--color-accent-hover: #059669;
--color-accent-muted: rgba(16, 185, 129, 0.12);
--color-accent-text: #34D399;
}
[data-accent="red"] {
--color-accent: #EF4444;
--color-accent-hover: #DC2626;
--color-accent-muted: rgba(239, 68, 68, 0.12);
--color-accent-text: #F87171;
}
[data-accent="pink"] {
--color-accent: #EC4899;
--color-accent-hover: #DB2777;
--color-accent-muted: rgba(236, 72, 153, 0.12);
--color-accent-text: #F472B6;
}
[data-accent="cyan"] {
--color-accent: #06B6D4;
--color-accent-hover: #0891B2;
--color-accent-muted: rgba(6, 182, 212, 0.12);
--color-accent-text: #22D3EE;
}
/* Light mode */
[data-theme="light"] {
--color-bg-base: #FAFAF9;
--color-bg-surface: #FFFFFF;
--color-bg-elevated: #F5F5F4;
--color-bg-inset: #E7E5E4;
--color-text-primary: #1C1917;
--color-text-secondary: #57534E;
--color-text-tertiary: #A8A29E;
--color-border-subtle: #E7E5E4;
--color-border-visible: #D6D3D1;
}
@layer base { @layer base {
* { * {
margin: 0; margin: 0;
@@ -173,3 +230,18 @@
.animate-pulse-colon { .animate-pulse-colon {
animation: pulse-colon 1s ease-in-out infinite; animation: pulse-colon 1s ease-in-out infinite;
} }
/* Markdown inline styles */
.markdown-inline strong { font-weight: 600; }
.markdown-inline em { font-style: italic; }
.markdown-inline code {
padding: 0.1em 0.3em;
background: var(--color-bg-elevated);
border-radius: 3px;
font-family: var(--font-mono);
font-size: 0.85em;
}
.markdown-inline a {
color: var(--color-accent-text);
text-decoration: underline;
}

View File

@@ -91,6 +91,44 @@
/> />
</div> </div>
</div> </div>
<div class="border-t border-border-subtle mt-5 pt-5">
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Theme</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Light or dark appearance</p>
</div>
<div class="w-40">
<AppSelect
v-model="themeMode"
:options="themeModes"
label-key="label"
value-key="value"
placeholder="Dark"
:placeholder-value="'dark'"
@update:model-value="saveThemeSettings"
/>
</div>
</div>
<div class="flex items-center justify-between mt-5">
<div>
<p class="text-[0.8125rem] text-text-primary">Accent Color</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Primary interface color</p>
</div>
<div class="flex gap-2">
<button
v-for="ac in accentColors"
:key="ac.value"
@click="accentColor = ac.value; saveThemeSettings()"
class="w-6 h-6 rounded-full border-2 transition-all hover:scale-110 cursor-pointer"
:class="accentColor === ac.value ? 'border-text-primary scale-110' : 'border-transparent'"
:style="{ backgroundColor: ac.color }"
:title="ac.label"
/>
</div>
</div>
</div>
</div> </div>
<!-- Timer --> <!-- Timer -->
@@ -120,22 +158,76 @@
</button> </button>
</div> </div>
<!-- Reminder Interval — progressive disclosure --> <!-- Idle sub-settings — progressive disclosure -->
<div <div v-if="idleDetection" class="space-y-5 pl-4 border-l-2 border-border-subtle ml-1">
v-if="idleDetection" <div class="flex items-center justify-between">
class="flex items-center justify-between pl-4 border-l-2 border-border-subtle ml-1" <div>
> <p class="text-[0.8125rem] text-text-primary">Idle Timeout</p>
<div> <p class="text-[0.6875rem] text-text-tertiary mt-0.5">Minutes before idle pause triggers</p>
<p class="text-[0.8125rem] text-text-primary">Reminder Interval</p> </div>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Minutes between reminders while running</p> <AppNumberInput
v-model="idleTimeout"
:min="1"
:max="60"
:step="1"
:precision="0"
suffix="min"
@update:model-value="saveSettings"
/>
</div> </div>
<input <div class="flex items-center justify-between">
v-model.number="reminderInterval" <div>
type="number" <p class="text-[0.8125rem] text-text-primary">Reminder Interval</p>
min="0" <p class="text-[0.6875rem] text-text-tertiary mt-0.5">Minutes between reminders while running</p>
max="120" </div>
class="w-20 px-3 py-1.5 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary text-right font-mono focus:outline-none focus:border-border-visible" <AppNumberInput
@change="saveSettings" v-model="reminderInterval"
:min="0"
:max="120"
:step="5"
:precision="0"
suffix="min"
@update:model-value="saveSettings"
/>
</div>
</div>
<!-- Divider -->
<div class="border-t border-border-subtle" />
<!-- App Tracking Mode -->
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">App Tracking Mode</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">What happens when a tracked app leaves focus</p>
</div>
<div class="w-48">
<AppSelect
v-model="appTrackingMode"
:options="appTrackingModes"
label-key="label"
value-key="value"
placeholder="Auto-pause"
:placeholder-value="'auto'"
@update:model-value="saveSettings"
/>
</div>
</div>
<!-- Check Interval -->
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Check Interval</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">How often to check idle & app visibility</p>
</div>
<AppNumberInput
v-model="appCheckInterval"
:min="1"
:max="60"
:step="1"
:precision="0"
suffix="sec"
@update:model-value="saveSettings"
/> />
</div> </div>
</div> </div>
@@ -259,11 +351,38 @@ const activeTab = ref('general')
// Settings state // Settings state
const hourlyRate = ref(0) const hourlyRate = ref(0)
const idleDetection = ref(true) const idleDetection = ref(true)
const idleTimeout = ref(5)
const reminderInterval = ref(15) const reminderInterval = ref(15)
const appTrackingMode = ref('auto')
const appCheckInterval = ref(5)
const zoomLevel = ref(100) const zoomLevel = ref(100)
const locale = ref('system') const locale = ref('system')
const currency = ref('USD') const currency = ref('USD')
const currencyOptions = getCurrencies() const currencyOptions = getCurrencies()
const themeMode = ref('dark')
const accentColor = ref('amber')
const themeModes = [
{ value: 'dark', label: 'Dark' },
{ value: 'light', label: 'Light' },
{ value: 'system', label: 'System' },
]
const accentColors = [
{ value: 'amber', label: 'Amber', color: '#D97706' },
{ value: 'blue', label: 'Blue', color: '#3B82F6' },
{ value: 'purple', label: 'Purple', color: '#8B5CF6' },
{ value: 'green', label: 'Green', color: '#10B981' },
{ value: 'red', label: 'Red', color: '#EF4444' },
{ value: 'pink', label: 'Pink', color: '#EC4899' },
{ value: 'cyan', label: 'Cyan', color: '#06B6D4' },
]
const appTrackingModes = [
{ value: 'auto', label: 'Auto-pause/resume' },
{ value: 'notify', label: 'Notify + auto-resume' },
{ value: 'prompt', label: 'Prompt user' },
]
// Dialog state // Dialog state
const showClearDataDialog = ref(false) const showClearDataDialog = ref(false)
@@ -312,7 +431,10 @@ async function saveSettings() {
try { try {
await settingsStore.updateSetting('hourly_rate', hourlyRate.value.toString()) await settingsStore.updateSetting('hourly_rate', hourlyRate.value.toString())
await settingsStore.updateSetting('idle_detection', idleDetection.value.toString()) await settingsStore.updateSetting('idle_detection', idleDetection.value.toString())
await settingsStore.updateSetting('idle_timeout', idleTimeout.value.toString())
await settingsStore.updateSetting('reminder_interval', reminderInterval.value.toString()) await settingsStore.updateSetting('reminder_interval', reminderInterval.value.toString())
await settingsStore.updateSetting('app_tracking_mode', appTrackingMode.value)
await settingsStore.updateSetting('app_check_interval', appCheckInterval.value.toString())
} catch (error) { } catch (error) {
console.error('Failed to save settings:', error) console.error('Failed to save settings:', error)
toastStore.error('Failed to save settings') toastStore.error('Failed to save settings')
@@ -325,6 +447,12 @@ async function saveLocaleSettings() {
await settingsStore.updateSetting('currency', currency.value) await settingsStore.updateSetting('currency', currency.value)
} }
// Save theme settings
async function saveThemeSettings() {
await settingsStore.updateSetting('theme_mode', themeMode.value)
await settingsStore.updateSetting('accent_color', accentColor.value)
}
// Export all data // Export all data
async function exportData() { async function exportData() {
try { try {
@@ -362,9 +490,14 @@ onMounted(async () => {
hourlyRate.value = parseFloat(settingsStore.settings.hourly_rate) || 0 hourlyRate.value = parseFloat(settingsStore.settings.hourly_rate) || 0
idleDetection.value = settingsStore.settings.idle_detection !== 'false' idleDetection.value = settingsStore.settings.idle_detection !== 'false'
idleTimeout.value = parseInt(settingsStore.settings.idle_timeout) || 5
reminderInterval.value = parseInt(settingsStore.settings.reminder_interval) || 15 reminderInterval.value = parseInt(settingsStore.settings.reminder_interval) || 15
appTrackingMode.value = settingsStore.settings.app_tracking_mode || 'auto'
appCheckInterval.value = parseInt(settingsStore.settings.app_check_interval) || 5
zoomLevel.value = parseInt(settingsStore.settings.ui_zoom) || 100 zoomLevel.value = parseInt(settingsStore.settings.ui_zoom) || 100
locale.value = settingsStore.settings.locale || 'system' locale.value = settingsStore.settings.locale || 'system'
currency.value = settingsStore.settings.currency || 'USD' currency.value = settingsStore.settings.currency || 'USD'
themeMode.value = settingsStore.settings.theme_mode || 'dark'
accentColor.value = settingsStore.settings.accent_color || 'amber'
}) })
</script> </script>