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

View file

@ -80,8 +80,34 @@
Warm cream surfaces with the same amber accent. Warm cream surfaces with the same amber accent.
Snipe Mode data attribute overrides this via higher specificity. 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) { @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 */ /* Surfaces — warm cream, like a tactical field notebook */
--color-surface: #f8f5ee; --color-surface: #f8f5ee;
--color-surface-2: #f0ece3; --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 ─────────────────── */ /* ── Snipe Mode easter egg theme ─────────────────── */
/* Activated by Konami code; stored as 'cf-snipe-mode' in localStorage */ /* Activated by Konami code; stored as 'cf-snipe-mode' in localStorage */
/* Applied: document.documentElement.dataset.snipeMode = 'active' */ /* 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> </label>
</section> </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 --> <!-- Affiliate Links only shown to signed-in cloud users -->
<section v-if="session.isLoggedIn" class="settings-section"> <section v-if="session.isLoggedIn" class="settings-section">
<h2 class="settings-section-title">Affiliate Links</h2> <h2 class="settings-section-title">Affiliate Links</h2>
@ -109,11 +131,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { useTrustSignalPref } from '../composables/useTrustSignalPref' import { useTrustSignalPref } from '../composables/useTrustSignalPref'
import { useTheme } from '../composables/useTheme'
import { useSessionStore } from '../stores/session' import { useSessionStore } from '../stores/session'
import { usePreferencesStore } from '../stores/preferences' import { usePreferencesStore } from '../stores/preferences'
import { useLLMQueryBuilder } from '../composables/useLLMQueryBuilder' import { useLLMQueryBuilder } from '../composables/useLLMQueryBuilder'
const { enabled: trustSignalEnabled, setEnabled } = useTrustSignalPref() 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 session = useSessionStore()
const prefs = usePreferencesStore() const prefs = usePreferencesStore()
const { autoRun: llmAutoRun, setAutoRun: setLLMAutoRun } = useLLMQueryBuilder() const { autoRun: llmAutoRun, setAutoRun: setLLMAutoRun } = useLLMQueryBuilder()
@ -292,4 +321,32 @@ function saveByokId() {
color: var(--color-danger, #f85149); color: var(--color-danger, #f85149);
margin: 0; 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> </style>