feat(#45): manual theme switcher (light/dark/solarized/colorblind-safe)
Some checks failed
CI / test (push) Failing after 18s
Some checks failed
CI / test (push) Failing after 18s
- theme.css: explicit [data-theme] blocks for light, dark, solarized-dark, solarized-light, colorblind (Wong 2011 palette); auto-dark media query updated to :root:not([data-theme]) so explicit themes always win - useTheme.ts: singleton composable — setTheme(), restoreTheme(), initTheme(); persists to localStorage + API; coordinates with hacker mode exit - AppNav.vue: theme <select> in sidebar footer; exitHackerMode now calls restoreTheme() instead of deleting data-theme directly - useEasterEgg.ts: hacker mode toggle-off calls restoreTheme() - App.vue: calls initTheme() on mount before restore() - dev-api.py: POST /api/settings/theme endpoint persists to user.yaml
This commit is contained in:
parent
64554dbef1
commit
4f825d0f00
6 changed files with 321 additions and 7 deletions
19
dev-api.py
19
dev-api.py
|
|
@ -1457,6 +1457,25 @@ class IdentitySyncPayload(BaseModel):
|
|||
phone: str = ""
|
||||
linkedin_url: str = ""
|
||||
|
||||
_VALID_THEMES = frozenset({"auto", "light", "dark", "solarized-dark", "solarized-light", "colorblind"})
|
||||
|
||||
class ThemePayload(BaseModel):
|
||||
theme: str
|
||||
|
||||
@app.post("/api/settings/theme")
|
||||
def set_theme(payload: ThemePayload):
|
||||
"""Persist the user's chosen theme to user.yaml."""
|
||||
if payload.theme not in _VALID_THEMES:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid theme: {payload.theme}")
|
||||
try:
|
||||
data = load_user_profile(_user_yaml_path())
|
||||
data["theme"] = payload.theme
|
||||
save_user_profile(_user_yaml_path(), data)
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
class UIPrefPayload(BaseModel):
|
||||
preference: str # "streamlit" | "vue"
|
||||
|
||||
|
|
|
|||
|
|
@ -16,12 +16,14 @@ import { computed, onMounted } from 'vue'
|
|||
import { RouterView, useRoute } from 'vue-router'
|
||||
import { useMotion } from './composables/useMotion'
|
||||
import { useHackerMode, useKonamiCode } from './composables/useEasterEgg'
|
||||
import { useTheme } from './composables/useTheme'
|
||||
import AppNav from './components/AppNav.vue'
|
||||
import { useDigestStore } from './stores/digest'
|
||||
|
||||
const motion = useMotion()
|
||||
const route = useRoute()
|
||||
const { toggle, restore } = useHackerMode()
|
||||
const { initTheme } = useTheme()
|
||||
const digestStore = useDigestStore()
|
||||
|
||||
const isWizard = computed(() => route.path.startsWith('/setup'))
|
||||
|
|
@ -29,7 +31,8 @@ const isWizard = computed(() => route.path.startsWith('/setup'))
|
|||
useKonamiCode(toggle)
|
||||
|
||||
onMounted(() => {
|
||||
restore() // re-apply hacker mode from localStorage on hard reload
|
||||
initTheme() // apply persisted theme (hacker mode takes priority inside initTheme)
|
||||
restore() // kept for hacker mode re-entry on hard reload (initTheme handles it, belt+suspenders)
|
||||
digestStore.fetchAll() // populate badge immediately, before user visits Digest tab
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -73,11 +73,11 @@
|
|||
}
|
||||
|
||||
/* ── Accessible Solarpunk — dark (system dark mode) ─
|
||||
Activates when OS/browser is in dark mode.
|
||||
Uses :not([data-theme="hacker"]) so the Konami easter
|
||||
egg always wins over the system preference. */
|
||||
Activates when OS/browser is in dark mode AND no
|
||||
explicit theme is selected. Explicit [data-theme="*"]
|
||||
always wins over the system preference. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="hacker"]) {
|
||||
:root:not([data-theme]) {
|
||||
/* Brand — lighter greens readable on dark surfaces */
|
||||
--color-primary: #6ab870;
|
||||
--color-primary-hover: #7ecb84;
|
||||
|
|
@ -161,6 +161,153 @@
|
|||
--color-accent-glow-lg: rgba(0, 255, 65, 0.6);
|
||||
}
|
||||
|
||||
/* ── Explicit light — forces light even on dark-OS ─ */
|
||||
[data-theme="light"] {
|
||||
--color-primary: #2d5a27;
|
||||
--color-primary-hover: #234820;
|
||||
--color-primary-light: #e8f2e7;
|
||||
--color-surface: #eaeff8;
|
||||
--color-surface-alt: #dde4f0;
|
||||
--color-surface-raised: #f5f7fc;
|
||||
--color-border: #a8b8d0;
|
||||
--color-border-light: #ccd5e6;
|
||||
--color-text: #1a2338;
|
||||
--color-text-muted: #4a5c7a;
|
||||
--color-text-inverse: #eaeff8;
|
||||
--color-accent: #c4732a;
|
||||
--color-accent-hover: #a85c1f;
|
||||
--color-accent-light: #fdf0e4;
|
||||
--color-success: #3a7a32;
|
||||
--color-error: #c0392b;
|
||||
--color-warning: #d4891a;
|
||||
--color-info: #1e6091;
|
||||
--shadow-sm: 0 1px 3px rgba(26, 35, 56, 0.08), 0 1px 2px rgba(26, 35, 56, 0.04);
|
||||
--shadow-md: 0 4px 12px rgba(26, 35, 56, 0.1), 0 2px 4px rgba(26, 35, 56, 0.06);
|
||||
--shadow-lg: 0 10px 30px rgba(26, 35, 56, 0.12), 0 4px 8px rgba(26, 35, 56, 0.06);
|
||||
}
|
||||
|
||||
/* ── Explicit dark — forces dark even on light-OS ── */
|
||||
[data-theme="dark"] {
|
||||
--color-primary: #6ab870;
|
||||
--color-primary-hover: #7ecb84;
|
||||
--color-primary-light: #162616;
|
||||
--color-surface: #16202e;
|
||||
--color-surface-alt: #1e2a3a;
|
||||
--color-surface-raised: #263547;
|
||||
--color-border: #2d4060;
|
||||
--color-border-light: #233352;
|
||||
--color-text: #e4eaf5;
|
||||
--color-text-muted: #8da0bc;
|
||||
--color-text-inverse: #16202e;
|
||||
--color-accent: #e8a84a;
|
||||
--color-accent-hover: #f5bc60;
|
||||
--color-accent-light: #2d1e0a;
|
||||
--color-success: #5eb85e;
|
||||
--color-error: #e05252;
|
||||
--color-warning: #e8a84a;
|
||||
--color-info: #4da6e8;
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35), 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
--shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.4), 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* ── Solarized Dark ──────────────────────────────── */
|
||||
/* Ethan Schoonover's Solarized palette (dark variant) */
|
||||
[data-theme="solarized-dark"] {
|
||||
--color-primary: #2aa198; /* cyan — used as primary brand color */
|
||||
--color-primary-hover: #35b8ad;
|
||||
--color-primary-light: #002b36;
|
||||
|
||||
--color-surface: #002b36; /* base03 */
|
||||
--color-surface-alt: #073642; /* base02 */
|
||||
--color-surface-raised: #0d4352;
|
||||
|
||||
--color-border: #073642;
|
||||
--color-border-light: #0a4a5a;
|
||||
|
||||
--color-text: #839496; /* base0 */
|
||||
--color-text-muted: #657b83; /* base00 */
|
||||
--color-text-inverse: #002b36;
|
||||
|
||||
--color-accent: #b58900; /* yellow */
|
||||
--color-accent-hover: #cb9f10;
|
||||
--color-accent-light: #1a1300;
|
||||
|
||||
--color-success: #859900; /* green */
|
||||
--color-error: #dc322f; /* red */
|
||||
--color-warning: #b58900; /* yellow */
|
||||
--color-info: #268bd2; /* blue */
|
||||
|
||||
--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.45), 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.5), 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* ── Solarized Light ─────────────────────────────── */
|
||||
[data-theme="solarized-light"] {
|
||||
--color-primary: #2aa198; /* cyan */
|
||||
--color-primary-hover: #1e8a82;
|
||||
--color-primary-light: #eee8d5;
|
||||
|
||||
--color-surface: #fdf6e3; /* base3 */
|
||||
--color-surface-alt: #eee8d5; /* base2 */
|
||||
--color-surface-raised: #fffdf7;
|
||||
|
||||
--color-border: #d3c9b0;
|
||||
--color-border-light: #e4dacc;
|
||||
|
||||
--color-text: #657b83; /* base00 */
|
||||
--color-text-muted: #839496; /* base0 */
|
||||
--color-text-inverse: #fdf6e3;
|
||||
|
||||
--color-accent: #b58900; /* yellow */
|
||||
--color-accent-hover: #9a7300;
|
||||
--color-accent-light: #fdf0c0;
|
||||
|
||||
--color-success: #859900; /* green */
|
||||
--color-error: #dc322f; /* red */
|
||||
--color-warning: #b58900; /* yellow */
|
||||
--color-info: #268bd2; /* blue */
|
||||
|
||||
--shadow-sm: 0 1px 3px rgba(101, 123, 131, 0.12), 0 1px 2px rgba(101, 123, 131, 0.08);
|
||||
--shadow-md: 0 4px 12px rgba(101, 123, 131, 0.15), 0 2px 4px rgba(101, 123, 131, 0.08);
|
||||
--shadow-lg: 0 10px 30px rgba(101, 123, 131, 0.18), 0 4px 8px rgba(101, 123, 131, 0.08);
|
||||
}
|
||||
|
||||
/* ── Colorblind-safe (deuteranopia/protanopia) ────── */
|
||||
/* Avoids red/green confusion. Uses blue+orange as the
|
||||
primary pair; cyan+magenta as semantic differentiators.
|
||||
Based on Wong (2011) 8-color colorblind-safe palette. */
|
||||
[data-theme="colorblind"] {
|
||||
--color-primary: #0072B2; /* blue — safe primary */
|
||||
--color-primary-hover: #005a8e;
|
||||
--color-primary-light: #e0f0fa;
|
||||
|
||||
--color-surface: #f4f6fb;
|
||||
--color-surface-alt: #e6eaf4;
|
||||
--color-surface-raised: #fafbfe;
|
||||
|
||||
--color-border: #b0bcd8;
|
||||
--color-border-light: #cdd5e8;
|
||||
|
||||
--color-text: #1a2338;
|
||||
--color-text-muted: #4a5c7a;
|
||||
--color-text-inverse: #f4f6fb;
|
||||
|
||||
--color-accent: #E69F00; /* orange — safe secondary */
|
||||
--color-accent-hover: #c98900;
|
||||
--color-accent-light: #fdf4dc;
|
||||
|
||||
--color-success: #009E73; /* teal-green — distinct from red/green confusion zone */
|
||||
--color-error: #CC0066; /* magenta-red — distinguishable from green */
|
||||
--color-warning: #E69F00; /* orange */
|
||||
--color-info: #56B4E9; /* sky blue */
|
||||
|
||||
--shadow-sm: 0 1px 3px rgba(26, 35, 56, 0.08), 0 1px 2px rgba(26, 35, 56, 0.04);
|
||||
--shadow-md: 0 4px 12px rgba(26, 35, 56, 0.1), 0 2px 4px rgba(26, 35, 56, 0.06);
|
||||
--shadow-lg: 0 10px 30px rgba(26, 35, 56, 0.12), 0 4px 8px rgba(26, 35, 56, 0.06);
|
||||
}
|
||||
|
||||
/* ── Base resets ─────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,22 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Theme picker -->
|
||||
<div class="sidebar__theme" v-if="!isHackerMode">
|
||||
<label class="sidebar__theme-label" for="theme-select">Theme</label>
|
||||
<select
|
||||
id="theme-select"
|
||||
class="sidebar__theme-select"
|
||||
:value="currentTheme"
|
||||
@change="setTheme(($event.target as HTMLSelectElement).value as Theme)"
|
||||
aria-label="Select theme"
|
||||
>
|
||||
<option v-for="opt in THEME_OPTIONS" :key="opt.value" :value="opt.value">
|
||||
{{ opt.icon }} {{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Settings at bottom -->
|
||||
<div class="sidebar__footer">
|
||||
<RouterLink to="/settings" class="sidebar__link sidebar__link--footer" active-class="sidebar__link--active">
|
||||
|
|
@ -79,7 +95,10 @@ import {
|
|||
} from '@heroicons/vue/24/outline'
|
||||
|
||||
import { useDigestStore } from '../stores/digest'
|
||||
import { useTheme, THEME_OPTIONS, type Theme } from '../composables/useTheme'
|
||||
|
||||
const digestStore = useDigestStore()
|
||||
const { currentTheme, setTheme, restoreTheme } = useTheme()
|
||||
|
||||
// Logo click easter egg — 9.6: Click the Bird 5× rapidly
|
||||
const logoClickCount = ref(0)
|
||||
|
|
@ -104,8 +123,8 @@ const isHackerMode = computed(() =>
|
|||
)
|
||||
|
||||
function exitHackerMode() {
|
||||
delete document.documentElement.dataset.theme
|
||||
localStorage.removeItem('cf-hacker-mode')
|
||||
restoreTheme()
|
||||
}
|
||||
|
||||
const _apiBase = import.meta.env.BASE_URL.replace(/\/$/, '')
|
||||
|
|
@ -315,6 +334,47 @@ const mobileLinks = [
|
|||
background: var(--color-surface-alt);
|
||||
}
|
||||
|
||||
/* ── Theme picker ───────────────────────────────────── */
|
||||
.sidebar__theme {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.sidebar__theme-label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.sidebar__theme-select {
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-surface-alt);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-body);
|
||||
cursor: pointer;
|
||||
appearance: auto;
|
||||
transition: border-color 150ms ease, background 150ms ease;
|
||||
}
|
||||
|
||||
.sidebar__theme-select:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.sidebar__theme-select:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── Mobile tab bar (<1024px) ───────────────────────── */
|
||||
.app-tabbar {
|
||||
display: none; /* hidden on desktop */
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { useTheme } from './useTheme'
|
||||
|
||||
const KONAMI = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','b','a']
|
||||
const KONAMI_AB = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','a','b']
|
||||
|
|
@ -31,8 +32,10 @@ export function useHackerMode() {
|
|||
function toggle() {
|
||||
const root = document.documentElement
|
||||
if (root.dataset.theme === 'hacker') {
|
||||
delete root.dataset.theme
|
||||
localStorage.removeItem('cf-hacker-mode')
|
||||
// Let useTheme restore the user's chosen theme rather than just deleting data-theme
|
||||
const { restoreTheme } = useTheme()
|
||||
restoreTheme()
|
||||
} else {
|
||||
root.dataset.theme = 'hacker'
|
||||
localStorage.setItem('cf-hacker-mode', 'true')
|
||||
|
|
|
|||
82
web/src/composables/useTheme.ts
Normal file
82
web/src/composables/useTheme.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
/**
|
||||
* useTheme — manual theme picker for Peregrine.
|
||||
*
|
||||
* Themes: 'auto' | 'light' | 'dark' | 'solarized-dark' | 'solarized-light' | 'colorblind'
|
||||
* Persisted in localStorage under 'cf-theme'.
|
||||
* Applied via document.documentElement.dataset.theme.
|
||||
* 'auto' removes the attribute so the @media prefers-color-scheme rule takes effect.
|
||||
*
|
||||
* Hacker mode sits on top of this system — toggling it off calls restoreTheme()
|
||||
* so the user's chosen theme is reinstated rather than dropping back to auto.
|
||||
*/
|
||||
|
||||
import { ref, readonly } from 'vue'
|
||||
import { useApiFetch } from './useApi'
|
||||
|
||||
export type Theme = 'auto' | 'light' | 'dark' | 'solarized-dark' | 'solarized-light' | 'colorblind'
|
||||
|
||||
const STORAGE_KEY = 'cf-theme'
|
||||
const HACKER_KEY = 'cf-hacker-mode'
|
||||
|
||||
export const THEME_OPTIONS: { value: Theme; label: string; icon: string }[] = [
|
||||
{ value: 'auto', label: 'Auto', icon: '⬡' },
|
||||
{ value: 'light', label: 'Light', icon: '☀' },
|
||||
{ value: 'dark', label: 'Dark', icon: '🌙' },
|
||||
{ value: 'solarized-light', label: 'Solarized Light', icon: '🌤' },
|
||||
{ value: 'solarized-dark', label: 'Solarized Dark', icon: '🌃' },
|
||||
{ value: 'colorblind', label: 'Colorblind Safe', icon: '♿' },
|
||||
]
|
||||
|
||||
// Module-level singleton so all consumers share the same reactive state.
|
||||
const _current = ref<Theme>(_load())
|
||||
|
||||
function _load(): Theme {
|
||||
return (localStorage.getItem(STORAGE_KEY) as Theme | null) ?? 'auto'
|
||||
}
|
||||
|
||||
function _apply(theme: Theme) {
|
||||
const root = document.documentElement
|
||||
if (theme === 'auto') {
|
||||
delete root.dataset.theme
|
||||
} else {
|
||||
root.dataset.theme = theme
|
||||
}
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
function setTheme(theme: Theme) {
|
||||
_current.value = theme
|
||||
localStorage.setItem(STORAGE_KEY, theme)
|
||||
_apply(theme)
|
||||
// Best-effort persist to server; ignore failures (works offline / local LLM)
|
||||
useApiFetch('/api/settings/theme', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ theme }),
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
/** Restore user's chosen theme — called when hacker mode or other overlays exit. */
|
||||
function restoreTheme() {
|
||||
// Hacker mode clears itself; we only restore if it's actually off.
|
||||
if (localStorage.getItem(HACKER_KEY) === 'true') return
|
||||
_apply(_current.value)
|
||||
}
|
||||
|
||||
/** Call once at app boot to apply persisted theme before first render. */
|
||||
function initTheme() {
|
||||
// Hacker mode takes priority on restore.
|
||||
if (localStorage.getItem(HACKER_KEY) === 'true') {
|
||||
document.documentElement.dataset.theme = 'hacker'
|
||||
} else {
|
||||
_apply(_current.value)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentTheme: readonly(_current),
|
||||
setTheme,
|
||||
restoreTheme,
|
||||
initTheme,
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue