From 9734c50c192919dba52370ac17a8ea5603bfbd81 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Thu, 16 Apr 2026 11:52:10 -0700 Subject: [PATCH] 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 , 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. --- web/src/App.vue | 3 ++ web/src/assets/theme.css | 54 ++++++++++++++++++++++++++++++- web/src/composables/useTheme.ts | 33 +++++++++++++++++++ web/src/views/SettingsView.vue | 57 +++++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 web/src/composables/useTheme.ts diff --git a/web/src/App.vue b/web/src/App.vue index 592f820..d85caf7 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -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 diff --git a/web/src/assets/theme.css b/web/src/assets/theme.css index 272c0d5..be45348 100644 --- a/web/src/assets/theme.css +++ b/web/src/assets/theme.css @@ -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' */ diff --git a/web/src/composables/useTheme.ts b/web/src/composables/useTheme.ts new file mode 100644 index 0000000..39b3c02 --- /dev/null +++ b/web/src/composables/useTheme.ts @@ -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((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 } +} diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index f35bf0a..4213b4a 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -26,6 +26,28 @@ + +
+

Appearance

+
+
+ Theme + Override the system color scheme. Default follows your OS preference. +
+
+ +
+
+
+

Affiliate Links

@@ -109,11 +131,18 @@