feat(#45): manual theme switcher (light/dark/solarized/colorblind-safe)
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:
pyr0ball 2026-04-04 22:22:04 -07:00
parent 64554dbef1
commit 4f825d0f00
6 changed files with 321 additions and 7 deletions

View file

@ -1457,6 +1457,25 @@ class IdentitySyncPayload(BaseModel):
phone: str = "" phone: str = ""
linkedin_url: 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): class UIPrefPayload(BaseModel):
preference: str # "streamlit" | "vue" preference: str # "streamlit" | "vue"

View file

@ -16,12 +16,14 @@ import { computed, 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 { useHackerMode, useKonamiCode } from './composables/useEasterEgg' import { useHackerMode, useKonamiCode } from './composables/useEasterEgg'
import { useTheme } from './composables/useTheme'
import AppNav from './components/AppNav.vue' import AppNav from './components/AppNav.vue'
import { useDigestStore } from './stores/digest' import { useDigestStore } from './stores/digest'
const motion = useMotion() const motion = useMotion()
const route = useRoute() const route = useRoute()
const { toggle, restore } = useHackerMode() const { toggle, restore } = useHackerMode()
const { initTheme } = useTheme()
const digestStore = useDigestStore() const digestStore = useDigestStore()
const isWizard = computed(() => route.path.startsWith('/setup')) const isWizard = computed(() => route.path.startsWith('/setup'))
@ -29,7 +31,8 @@ const isWizard = computed(() => route.path.startsWith('/setup'))
useKonamiCode(toggle) useKonamiCode(toggle)
onMounted(() => { 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 digestStore.fetchAll() // populate badge immediately, before user visits Digest tab
}) })
</script> </script>

View file

@ -73,11 +73,11 @@
} }
/* Accessible Solarpunk dark (system dark mode) /* Accessible Solarpunk dark (system dark mode)
Activates when OS/browser is in dark mode. Activates when OS/browser is in dark mode AND no
Uses :not([data-theme="hacker"]) so the Konami easter explicit theme is selected. Explicit [data-theme="*"]
egg always wins over the system preference. */ always wins over the system preference. */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root:not([data-theme="hacker"]) { :root:not([data-theme]) {
/* Brand — lighter greens readable on dark surfaces */ /* Brand — lighter greens readable on dark surfaces */
--color-primary: #6ab870; --color-primary: #6ab870;
--color-primary-hover: #7ecb84; --color-primary-hover: #7ecb84;
@ -161,6 +161,153 @@
--color-accent-glow-lg: rgba(0, 255, 65, 0.6); --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 ─────────────────────────────────── */ /* ── Base resets ─────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; } *, *::before, *::after { box-sizing: border-box; }

View file

@ -34,6 +34,22 @@
</button> </button>
</div> </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 --> <!-- Settings at bottom -->
<div class="sidebar__footer"> <div class="sidebar__footer">
<RouterLink to="/settings" class="sidebar__link sidebar__link--footer" active-class="sidebar__link--active"> <RouterLink to="/settings" class="sidebar__link sidebar__link--footer" active-class="sidebar__link--active">
@ -79,7 +95,10 @@ import {
} from '@heroicons/vue/24/outline' } from '@heroicons/vue/24/outline'
import { useDigestStore } from '../stores/digest' import { useDigestStore } from '../stores/digest'
import { useTheme, THEME_OPTIONS, type Theme } from '../composables/useTheme'
const digestStore = useDigestStore() const digestStore = useDigestStore()
const { currentTheme, setTheme, restoreTheme } = useTheme()
// Logo click easter egg 9.6: Click the Bird 5× rapidly // Logo click easter egg 9.6: Click the Bird 5× rapidly
const logoClickCount = ref(0) const logoClickCount = ref(0)
@ -104,8 +123,8 @@ const isHackerMode = computed(() =>
) )
function exitHackerMode() { function exitHackerMode() {
delete document.documentElement.dataset.theme
localStorage.removeItem('cf-hacker-mode') localStorage.removeItem('cf-hacker-mode')
restoreTheme()
} }
const _apiBase = import.meta.env.BASE_URL.replace(/\/$/, '') const _apiBase = import.meta.env.BASE_URL.replace(/\/$/, '')
@ -315,6 +334,47 @@ const mobileLinks = [
background: var(--color-surface-alt); 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) ───────────────────────── */ /* ── Mobile tab bar (<1024px) ───────────────────────── */
.app-tabbar { .app-tabbar {
display: none; /* hidden on desktop */ display: none; /* hidden on desktop */

View file

@ -1,4 +1,5 @@
import { onMounted, onUnmounted } from 'vue' import { onMounted, onUnmounted } from 'vue'
import { useTheme } from './useTheme'
const KONAMI = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','b','a'] 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'] const KONAMI_AB = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','a','b']
@ -31,8 +32,10 @@ export function useHackerMode() {
function toggle() { function toggle() {
const root = document.documentElement const root = document.documentElement
if (root.dataset.theme === 'hacker') { if (root.dataset.theme === 'hacker') {
delete root.dataset.theme
localStorage.removeItem('cf-hacker-mode') localStorage.removeItem('cf-hacker-mode')
// Let useTheme restore the user's chosen theme rather than just deleting data-theme
const { restoreTheme } = useTheme()
restoreTheme()
} else { } else {
root.dataset.theme = 'hacker' root.dataset.theme = 'hacker'
localStorage.setItem('cf-hacker-mode', 'true') localStorage.setItem('cf-hacker-mode', 'true')

View 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,
}
}