feat: explicit dark/light theme override with Settings toggle

Adds user-controlled theme selection independent of OS preference:

- useTheme composable: sets data-theme="dark"|"light" on <html>,
  persisted to localStorage as snipe:theme. Follows the same pattern
  as useSnipeMode.
- theme.css: [data-theme="dark"] and [data-theme="light"] explicit
  attribute selectors override @media (prefers-color-scheme: light).
  Media query updated to :root:not([data-theme="dark"]) so it has no
  effect when the user has forced dark on a light-OS machine.
- App.vue: restoreTheme() called in onMounted alongside restoreSnipeMode.
- SettingsView: Appearance section with System/Dark/Light segmented
  button group.
This commit is contained in:
pyr0ball 2026-04-16 11:52:10 -07:00
parent c90061733c
commit 9734c50c19
4 changed files with 146 additions and 1 deletions

View file

@ -19,6 +19,7 @@ import { onMounted } from 'vue'
import { RouterView, useRoute } from 'vue-router'
import { useMotion } from './composables/useMotion'
import { useSnipeMode } from './composables/useSnipeMode'
import { useTheme } from './composables/useTheme'
import { useKonamiCode } from './composables/useKonamiCode'
import { useSessionStore } from './stores/session'
import { useBlocklistStore } from './stores/blocklist'
@ -28,6 +29,7 @@ import FeedbackButton from './components/FeedbackButton.vue'
const motion = useMotion()
const { activate, restore } = useSnipeMode()
const { restore: restoreTheme } = useTheme()
const session = useSessionStore()
const blocklistStore = useBlocklistStore()
const preferencesStore = usePreferencesStore()
@ -37,6 +39,7 @@ useKonamiCode(activate)
onMounted(async () => {
restore() // re-apply snipe mode from localStorage on hard reload
restoreTheme() // re-apply explicit theme override on hard reload
await session.bootstrap() // fetch tier + feature flags from API
blocklistStore.fetchBlocklist() // pre-load so card block buttons reflect state immediately
preferencesStore.load() // load user preferences after session resolves

View file

@ -80,8 +80,34 @@
Warm cream surfaces with the same amber accent.
Snipe Mode data attribute overrides this via higher specificity.
*/
/* Explicit dark override — beats OS preference when user forces dark in Settings */
[data-theme="dark"]:not([data-snipe-mode="active"]) {
--color-surface: #0d1117;
--color-surface-2: #161b22;
--color-surface-raised: #1c2129;
--color-border: #30363d;
--color-border-light: #21262d;
--color-text: #e6edf3;
--color-text-muted: #8b949e;
--color-text-inverse: #0d1117;
--app-primary: #f59e0b;
--app-primary-hover: #d97706;
--app-primary-light: rgba(245, 158, 11, 0.12);
--trust-high: #3fb950;
--trust-mid: #d29922;
--trust-low: #f85149;
--color-success: #3fb950;
--color-error: #f85149;
--color-warning: #d29922;
--color-info: #58a6ff;
--color-accent: #a478ff;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3);
--shadow-md: 0 4px 12px rgba(0,0,0,0.5), 0 2px 4px rgba(0,0,0,0.3);
--shadow-lg: 0 10px 30px rgba(0,0,0,0.6), 0 4px 8px rgba(0,0,0,0.3);
}
@media (prefers-color-scheme: light) {
:root:not([data-snipe-mode="active"]) {
:root:not([data-theme="dark"]):not([data-snipe-mode="active"]) {
/* Surfaces — warm cream, like a tactical field notebook */
--color-surface: #f8f5ee;
--color-surface-2: #f0ece3;
@ -120,6 +146,32 @@
}
}
/* Explicit light override — beats OS preference when user forces light in Settings */
[data-theme="light"]:not([data-snipe-mode="active"]) {
--color-surface: #f8f5ee;
--color-surface-2: #f0ece3;
--color-surface-raised: #e8e3d8;
--color-border: #c8bfae;
--color-border-light: #dbd3c4;
--color-text: #1c1a16;
--color-text-muted: #6b6357;
--color-text-inverse: #f8f5ee;
--app-primary: #d97706;
--app-primary-hover: #b45309;
--app-primary-light: rgba(217, 119, 6, 0.12);
--trust-high: #16a34a;
--trust-mid: #b45309;
--trust-low: #dc2626;
--color-success: #16a34a;
--color-error: #dc2626;
--color-warning: #b45309;
--color-info: #2563eb;
--color-accent: #7c3aed;
--shadow-sm: 0 1px 3px rgba(60,45,20,0.12), 0 1px 2px rgba(60,45,20,0.08);
--shadow-md: 0 4px 12px rgba(60,45,20,0.15), 0 2px 4px rgba(60,45,20,0.1);
--shadow-lg: 0 10px 30px rgba(60,45,20,0.2), 0 4px 8px rgba(60,45,20,0.1);
}
/* ── Snipe Mode easter egg theme ─────────────────── */
/* Activated by Konami code; stored as 'cf-snipe-mode' in localStorage */
/* Applied: document.documentElement.dataset.snipeMode = 'active' */

View file

@ -0,0 +1,33 @@
import { ref, watchEffect } from 'vue'
const LS_KEY = 'snipe:theme'
type ThemeMode = 'system' | 'dark' | 'light'
// Module-level — shared across all callers
const mode = ref<ThemeMode>((localStorage.getItem(LS_KEY) as ThemeMode) ?? 'system')
function _apply(m: ThemeMode) {
const el = document.documentElement
if (m === 'dark') {
el.dataset.theme = 'dark'
} else if (m === 'light') {
el.dataset.theme = 'light'
} else {
delete el.dataset.theme
}
}
export function useTheme() {
function setMode(m: ThemeMode) {
mode.value = m
localStorage.setItem(LS_KEY, m)
_apply(m)
}
/** Re-apply from localStorage on hard reload (call from App.vue onMounted). */
function restore() {
_apply(mode.value)
}
return { mode, setMode, restore }
}

View file

@ -26,6 +26,28 @@
</label>
</section>
<!-- Appearance -->
<section class="settings-section">
<h2 class="settings-section-title">Appearance</h2>
<div class="settings-toggle">
<div class="settings-toggle-text">
<span class="settings-toggle-label">Theme</span>
<span class="settings-toggle-desc">Override the system color scheme. Default follows your OS preference.</span>
</div>
<div class="theme-btn-group" role="group" aria-label="Theme selection">
<button
v-for="opt in themeOptions"
:key="opt.value"
class="theme-btn"
:class="{ 'theme-btn--active': theme.mode.value === opt.value }"
:aria-pressed="theme.mode.value === opt.value"
type="button"
@click="theme.setMode(opt.value)"
>{{ opt.label }}</button>
</div>
</div>
</section>
<!-- Affiliate Links only shown to signed-in cloud users -->
<section v-if="session.isLoggedIn" class="settings-section">
<h2 class="settings-section-title">Affiliate Links</h2>
@ -109,11 +131,18 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useTrustSignalPref } from '../composables/useTrustSignalPref'
import { useTheme } from '../composables/useTheme'
import { useSessionStore } from '../stores/session'
import { usePreferencesStore } from '../stores/preferences'
import { useLLMQueryBuilder } from '../composables/useLLMQueryBuilder'
const { enabled: trustSignalEnabled, setEnabled } = useTrustSignalPref()
const theme = useTheme()
const themeOptions: { value: 'system' | 'dark' | 'light'; label: string }[] = [
{ value: 'system', label: 'System' },
{ value: 'dark', label: 'Dark' },
{ value: 'light', label: 'Light' },
]
const session = useSessionStore()
const prefs = usePreferencesStore()
const { autoRun: llmAutoRun, setAutoRun: setLLMAutoRun } = useLLMQueryBuilder()
@ -292,4 +321,32 @@ function saveByokId() {
color: var(--color-danger, #f85149);
margin: 0;
}
.theme-btn-group {
display: flex;
gap: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
flex-shrink: 0;
}
.theme-btn {
padding: var(--space-2) var(--space-4);
background: transparent;
border: none;
border-right: 1px solid var(--color-border);
color: var(--color-text-muted);
font-size: 0.8rem;
cursor: pointer;
font-family: inherit;
transition: background var(--transition), color var(--transition);
}
.theme-btn:last-child { border-right: none; }
.theme-btn:hover { background: var(--color-surface-raised); color: var(--color-text); }
.theme-btn--active {
background: var(--app-primary-light);
color: var(--app-primary);
font-weight: 600;
}
</style>