From de621175d0b80ea66aef4b8e223e5935263ff0fa Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sat, 9 May 2026 16:20:07 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20dark/light=20theme=20=E2=80=94=20CSS=20?= =?UTF-8?q?variables,=20OS=20preference,=20toggle=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- web/src/App.vue | 17 ++++++++++- web/src/main.ts | 8 +++++ web/src/style/theme.css | 52 +++++++++++++++++++++++++++++++++ web/src/views/IncidentsView.vue | 16 +++++----- web/uno.config.ts | 27 ++++++++--------- 5 files changed, 99 insertions(+), 21 deletions(-) create mode 100644 web/src/style/theme.css diff --git a/web/src/App.vue b/web/src/App.vue index a3a8ba9..dbb1ae6 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -16,7 +16,13 @@ {{ link.label }} -
+
+
@@ -25,6 +31,7 @@ diff --git a/web/src/main.ts b/web/src/main.ts index 37aa5aa..3cb1e95 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -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) diff --git a/web/src/style/theme.css b/web/src/style/theme.css new file mode 100644 index 0000000..318b1dd --- /dev/null +++ b/web/src/style/theme.css @@ -0,0 +1,52 @@ +/* Turnstone theme — light (default) and dark override via .dark on */ + +: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; +} diff --git a/web/src/views/IncidentsView.vue b/web/src/views/IncidentsView.vue index adddc66..39b29e8 100644 --- a/web/src/views/IncidentsView.vue +++ b/web/src/views/IncidentsView.vue @@ -149,7 +149,7 @@ > {{ inc.label }} - + {{ inc.severity }} @@ -366,13 +366,15 @@ async function selectIncident(inc: Incident) { } // ── helpers ─────────────────────────────────────────────────── -function severityClass(sev: string): string { +function severityStyle(sev: string): Record { + 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 { diff --git a/web/uno.config.ts b/web/uno.config.ts index f67af61..2a24e79 100644 --- a/web/uno.config.ts +++ b/web/uno.config.ts @@ -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: {