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:
parent
c90061733c
commit
9734c50c19
4 changed files with 146 additions and 1 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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' */
|
||||||
|
|
|
||||||
33
web/src/composables/useTheme.ts
Normal file
33
web/src/composables/useTheme.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue