feat: dark/light theme — CSS variables, OS preference, toggle button

- theme.css: CSS custom properties for both modes (surface, accent, text,
  sev, badge); .dark class override; smooth 0.15s transitions
- uno.config.ts: colors now reference var() — all semantic classes
  auto-switch with the .dark class; dark: 'class' strategy enabled
- main.ts: apply saved preference (localStorage ts-theme) or
  prefers-color-scheme before first paint to prevent flash
- App.vue: ☾/☀ toggle button persists choice to localStorage
- IncidentsView: severityStyle() uses badge CSS variables via inline
  style — fixes /opacity modifier incompatibility with CSS vars
This commit is contained in:
pyr0ball 2026-05-09 16:20:07 -07:00
parent 13ed483d39
commit de621175d0
5 changed files with 99 additions and 21 deletions

View file

@ -16,7 +16,13 @@
{{ link.label }}
</RouterLink>
</div>
<div class="ml-auto">
<div class="ml-auto flex items-center gap-3">
<button
@click="toggleTheme"
:title="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
class="text-text-dim hover:text-text-primary transition-colors text-base leading-none px-1"
aria-label="Toggle theme"
>{{ isDark ? '☀' : '☾' }}</button>
<StatusDot />
</div>
</nav>
@ -25,6 +31,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { RouterLink, RouterView } from 'vue-router'
import StatusDot from '@/components/StatusDot.vue'
@ -34,4 +41,12 @@ const navLinks = [
{ to: '/incidents', label: 'Incidents' },
{ to: '/sources', label: 'Sources' },
]
const isDark = ref(document.documentElement.classList.contains('dark'))
function toggleTheme() {
isDark.value = !isDark.value
document.documentElement.classList.toggle('dark', isDark.value)
localStorage.setItem('ts-theme', isDark.value ? 'dark' : 'light')
}
</script>

View file

@ -3,9 +3,17 @@ import { createPinia } from 'pinia'
import 'virtual:uno.css'
import '@fontsource/jetbrains-mono/400.css'
import '@fontsource/jetbrains-mono/600.css'
import './style/theme.css'
import App from './App.vue'
import router from './router'
// Apply saved theme or OS preference before first paint to avoid flash
const saved = localStorage.getItem('ts-theme')
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
if (saved === 'dark' || (!saved && prefersDark)) {
document.documentElement.classList.add('dark')
}
const app = createApp(App)
app.use(createPinia())
app.use(router)

52
web/src/style/theme.css Normal file
View file

@ -0,0 +1,52 @@
/* Turnstone theme — light (default) and dark override via .dark on <html> */
:root {
--color-surface: #f8fafc;
--color-surface-raised: #f1f5f9;
--color-surface-border: #e2e8f0;
--color-accent: #2563eb;
--color-accent-muted: #dbeafe;
--color-text-primary: #0f172a;
--color-text-muted: #475569;
--color-text-dim: #94a3b8;
/* severity text colors */
--color-sev-debug: #6b7280;
--color-sev-info: #2563eb;
--color-sev-warn: #d97706;
--color-sev-error: #dc2626;
--color-sev-critical: #b91c1c;
/* severity badge — explicit bg + text pairs (no opacity hacks) */
--badge-low-bg: #f1f5f9; --badge-low-text: #475569;
--badge-medium-bg: #fef3c7; --badge-medium-text: #b45309;
--badge-high-bg: #ffedd5; --badge-high-text: #c2410c;
--badge-critical-bg: #fee2e2; --badge-critical-text: #b91c1c;
}
.dark {
--color-surface: #0f1117;
--color-surface-raised: #161b25;
--color-surface-border: #1e2636;
--color-accent: #4e9af1;
--color-accent-muted: #2a4a72;
--color-text-primary: #e2e8f0;
--color-text-muted: #94a3b8;
--color-text-dim: #475569;
--color-sev-debug: #6b7280;
--color-sev-info: #60a5fa;
--color-sev-warn: #fbbf24;
--color-sev-error: #f87171;
--color-sev-critical: #ef4444;
--badge-low-bg: #1e293b; --badge-low-text: #94a3b8;
--badge-medium-bg: #451a03; --badge-medium-text: #fbbf24;
--badge-high-bg: #431407; --badge-high-text: #fb923c;
--badge-critical-bg: #450a0a; --badge-critical-text: #f87171;
}
/* Smooth theme transitions */
*, *::before, *::after {
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}

View file

@ -149,7 +149,7 @@
>
<td class="px-4 py-2.5 text-text-primary">{{ inc.label }}</td>
<td class="px-4 py-2.5">
<span :class="['px-1.5 py-0.5 rounded text-xs font-medium', severityClass(inc.severity)]">
<span class="px-2 py-0.5 rounded text-xs font-medium border" :style="severityStyle(inc.severity)">
{{ inc.severity }}
</span>
</td>
@ -366,13 +366,15 @@ async function selectIncident(inc: Incident) {
}
// helpers
function severityClass(sev: string): string {
function severityStyle(sev: string): Record<string, string> {
const k = sev?.toLowerCase() ?? 'low'
const known = ['low', 'medium', 'high', 'critical']
const key = known.includes(k) ? k : 'low'
return {
low: 'bg-surface-border text-text-muted',
medium: 'bg-yellow-900 text-yellow-300',
high: 'bg-orange-900 text-orange-300',
critical: 'bg-red-900 text-red-300',
}[sev] ?? 'bg-surface-border text-text-muted'
backgroundColor: `var(--badge-${key}-bg)`,
color: `var(--badge-${key}-text)`,
borderColor: `var(--badge-${key}-text)`,
}
}
function severityTextClass(sev: string | null): string {

View file

@ -2,28 +2,29 @@ import { defineConfig, presetAttributify, presetWind } from 'unocss'
export default defineConfig({
presets: [presetWind(), presetAttributify()],
dark: 'class',
theme: {
colors: {
surface: {
DEFAULT: '#0f1117',
raised: '#161b25',
border: '#1e2636',
DEFAULT: 'var(--color-surface)',
raised: 'var(--color-surface-raised)',
border: 'var(--color-surface-border)',
},
accent: {
DEFAULT: '#4e9af1',
muted: '#2a4a72',
DEFAULT: 'var(--color-accent)',
muted: 'var(--color-accent-muted)',
},
sev: {
debug: '#6b7280',
info: '#60a5fa',
warn: '#fbbf24',
error: '#f87171',
critical: '#ef4444',
debug: 'var(--color-sev-debug)',
info: 'var(--color-sev-info)',
warn: 'var(--color-sev-warn)',
error: 'var(--color-sev-error)',
critical: 'var(--color-sev-critical)',
},
text: {
primary: '#e2e8f0',
muted: '#94a3b8',
dim: '#475569',
primary: 'var(--color-text-primary)',
muted: 'var(--color-text-muted)',
dim: 'var(--color-text-dim)',
},
},
fontFamily: {