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:
parent
1ee364fc97
commit
58b179b275
5 changed files with 99 additions and 21 deletions
|
|
@ -16,7 +16,13 @@
|
||||||
{{ link.label }}
|
{{ link.label }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</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 />
|
<StatusDot />
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
@ -25,6 +31,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
import { RouterLink, RouterView } from 'vue-router'
|
import { RouterLink, RouterView } from 'vue-router'
|
||||||
import StatusDot from '@/components/StatusDot.vue'
|
import StatusDot from '@/components/StatusDot.vue'
|
||||||
|
|
||||||
|
|
@ -34,4 +41,12 @@ const navLinks = [
|
||||||
{ to: '/incidents', label: 'Incidents' },
|
{ to: '/incidents', label: 'Incidents' },
|
||||||
{ to: '/sources', label: 'Sources' },
|
{ 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>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,17 @@ import { createPinia } from 'pinia'
|
||||||
import 'virtual:uno.css'
|
import 'virtual:uno.css'
|
||||||
import '@fontsource/jetbrains-mono/400.css'
|
import '@fontsource/jetbrains-mono/400.css'
|
||||||
import '@fontsource/jetbrains-mono/600.css'
|
import '@fontsource/jetbrains-mono/600.css'
|
||||||
|
import './style/theme.css'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
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)
|
const app = createApp(App)
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
|
|
||||||
52
web/src/style/theme.css
Normal file
52
web/src/style/theme.css
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -149,7 +149,7 @@
|
||||||
>
|
>
|
||||||
<td class="px-4 py-2.5 text-text-primary">{{ inc.label }}</td>
|
<td class="px-4 py-2.5 text-text-primary">{{ inc.label }}</td>
|
||||||
<td class="px-4 py-2.5">
|
<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 }}
|
{{ inc.severity }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -366,13 +366,15 @@ async function selectIncident(inc: Incident) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── helpers ───────────────────────────────────────────────────
|
// ── 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 {
|
return {
|
||||||
low: 'bg-surface-border text-text-muted',
|
backgroundColor: `var(--badge-${key}-bg)`,
|
||||||
medium: 'bg-yellow-900 text-yellow-300',
|
color: `var(--badge-${key}-text)`,
|
||||||
high: 'bg-orange-900 text-orange-300',
|
borderColor: `var(--badge-${key}-text)`,
|
||||||
critical: 'bg-red-900 text-red-300',
|
}
|
||||||
}[sev] ?? 'bg-surface-border text-text-muted'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function severityTextClass(sev: string | null): string {
|
function severityTextClass(sev: string | null): string {
|
||||||
|
|
|
||||||
|
|
@ -2,28 +2,29 @@ import { defineConfig, presetAttributify, presetWind } from 'unocss'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
presets: [presetWind(), presetAttributify()],
|
presets: [presetWind(), presetAttributify()],
|
||||||
|
dark: 'class',
|
||||||
theme: {
|
theme: {
|
||||||
colors: {
|
colors: {
|
||||||
surface: {
|
surface: {
|
||||||
DEFAULT: '#0f1117',
|
DEFAULT: 'var(--color-surface)',
|
||||||
raised: '#161b25',
|
raised: 'var(--color-surface-raised)',
|
||||||
border: '#1e2636',
|
border: 'var(--color-surface-border)',
|
||||||
},
|
},
|
||||||
accent: {
|
accent: {
|
||||||
DEFAULT: '#4e9af1',
|
DEFAULT: 'var(--color-accent)',
|
||||||
muted: '#2a4a72',
|
muted: 'var(--color-accent-muted)',
|
||||||
},
|
},
|
||||||
sev: {
|
sev: {
|
||||||
debug: '#6b7280',
|
debug: 'var(--color-sev-debug)',
|
||||||
info: '#60a5fa',
|
info: 'var(--color-sev-info)',
|
||||||
warn: '#fbbf24',
|
warn: 'var(--color-sev-warn)',
|
||||||
error: '#f87171',
|
error: 'var(--color-sev-error)',
|
||||||
critical: '#ef4444',
|
critical: 'var(--color-sev-critical)',
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
primary: '#e2e8f0',
|
primary: 'var(--color-text-primary)',
|
||||||
muted: '#94a3b8',
|
muted: 'var(--color-text-muted)',
|
||||||
dim: '#475569',
|
dim: 'var(--color-text-dim)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue