From e521dcba0fc653e74ab2a9d46adc083fa6b4f6af Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Thu, 9 Jul 2026 17:26:21 +0200 Subject: [PATCH] feat(pm): add light/dark/system theme toggle Defaults to dark (matching the rest of the app) with a menu to switch to Light or System. Custom CSS-variable overrides handle the hand-rolled Tailwind classes; a second Vuetify theme is swapped in sync for Vuetify components. Scoped entirely to /pm. --- app/assets/css/global.css | 13 +++ app/composables/usePmTheme.ts | 62 +++++++++++++++ app/pages/pm.vue | 145 ++++++++++++++++++++++++++++++++-- nuxt.config.ts | 16 ++++ 4 files changed, 231 insertions(+), 5 deletions(-) create mode 100644 app/composables/usePmTheme.ts diff --git a/app/assets/css/global.css b/app/assets/css/global.css index ec7ce6a..ec2c0e1 100644 --- a/app/assets/css/global.css +++ b/app/assets/css/global.css @@ -12,6 +12,19 @@ --border: rgba(255, 255, 255, .10); } +/* Light theme override — currently only used by /pm (scoped via [data-theme="light"] + on .pm-page), not applied app-wide. */ +[data-theme="light"] { + --bg: #f4f6fb; + --bg2: #e7ebf3; + --accent: #0891b2; + --accent2: #0369a1; + --text: #0f1420; + --t2: rgba(15, 20, 32, .75); + --t3: rgba(15, 20, 32, .55); + --border: rgba(15, 20, 32, .14); +} + /* Reset + Base */ html, body, #__nuxt { min-height: 100vh; diff --git a/app/composables/usePmTheme.ts b/app/composables/usePmTheme.ts new file mode 100644 index 0000000..1756e31 --- /dev/null +++ b/app/composables/usePmTheme.ts @@ -0,0 +1,62 @@ +import { computed, onBeforeUnmount, ref, watch } from 'vue' +import { useTheme } from 'vuetify' +import { useCookie } from '#imports' + +export type PmThemePreference = 'system' | 'light' | 'dark' +export type PmEffectiveTheme = 'light' | 'dark' + +const PM_THEME_COOKIE = 'os_pm_theme' + +/** + * /pm-scoped light/dark toggle. Defaults to dark (matching the rest of the app) + * until the user explicitly picks Light or System, at which point that choice is + * remembered and wins over the default. Only /pm has a light theme today — the + * Vuetify theme is reset back to the app-wide dark default on unmount so other + * pages are unaffected. + */ +export function usePmTheme() { + const vuetifyTheme = useTheme() + const storedPreference = useCookie(PM_THEME_COOKIE, { + sameSite: 'lax', + path: '/', + maxAge: 60 * 60 * 24 * 365, + }) + + const preference = ref(storedPreference.value ?? 'dark') + const systemPrefersLight = ref(false) + + const effectiveTheme = computed(() => { + if (preference.value === 'system') { + return systemPrefersLight.value ? 'light' : 'dark' + } + return preference.value + }) + + function applyVuetifyTheme() { + vuetifyTheme.global.name.value = effectiveTheme.value === 'light' ? 'opensquawkLight' : 'opensquawkDark' + } + + function setPreference(next: PmThemePreference) { + preference.value = next + storedPreference.value = next + } + + if (typeof window !== 'undefined') { + const media = window.matchMedia('(prefers-color-scheme: light)') + systemPrefersLight.value = media.matches + + const handleChange = (event: MediaQueryListEvent) => { + systemPrefersLight.value = event.matches + } + media.addEventListener('change', handleChange) + + onBeforeUnmount(() => { + media.removeEventListener('change', handleChange) + vuetifyTheme.global.name.value = 'opensquawkDark' + }) + } + + watch(effectiveTheme, applyVuetifyTheme, { immediate: true }) + + return { preference, effectiveTheme, setPreference } +} diff --git a/app/pages/pm.vue b/app/pages/pm.vue index f979265..54a5929 100644 --- a/app/pages/pm.vue +++ b/app/pages/pm.vue @@ -1,9 +1,9 @@ + + + + - + mdi-message-draw Feedback @@ -649,8 +708,8 @@
{ const engine = useCommunicationsEngine() const auth = useAuthStore() const api = useApi() +const { effectiveTheme: pmTheme, preference: pmThemePreference, setPreference: setPmTheme } = usePmTheme() +const themeMenu = ref(false) +const themeIcon = computed(() => { + if (pmThemePreference.value === 'system') return 'mdi-theme-light-dark' + return pmThemePreference.value === 'light' ? 'mdi-white-balance-sunny' : 'mdi-weather-night' +}) const router = useRouter() const route = useRoute() const radioBackend = useRadioBackend() @@ -5515,6 +5581,24 @@ onUnmounted(() => { touch-action: manipulation; } +/* Idle state needs a boundary that survives bright ambient light/glare — a + hairline low-opacity border reads as "no border at all" outdoors. Width + + opacity + a soft shadow together are more glare-resistant than opacity alone. */ +.ptt-pad--idle { + border-color: rgba(255, 255, 255, 0.28); + box-shadow: 0 0 0 1px rgba(34, 211, 238, 0.16), 0 14px 30px rgba(0, 0, 0, 0.35); +} + +.pm-page[data-theme="light"] .ptt-pad--idle { + border-color: rgba(15, 20, 32, 0.24); + background-color: rgba(15, 20, 32, 0.045) !important; + box-shadow: 0 0 0 1px rgba(8, 145, 178, 0.22), 0 10px 24px rgba(15, 20, 32, 0.1); +} + +.pm-page[data-theme="light"] .ptt-pad--active { + box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.28); +} + .signal-bar { width: 3px; height: 12px; @@ -5558,6 +5642,56 @@ onUnmounted(() => { overflow: hidden; } +/* --------------------------------------------------------------------------- + Light theme — pragmatic MVP: re-tint the handful of Tailwind white/black + opacity utilities used throughout this page rather than touching every + template occurrence individually. Secondary/decorative surfaces (chips, + gradients) are left as-is; they still read fine on the light background. + --------------------------------------------------------------------------- */ +.pm-page[data-theme="light"] .text-white, +.pm-page[data-theme="light"] .text-white\/90, +.pm-page[data-theme="light"] .text-white\/85, +.pm-page[data-theme="light"] .text-white\/80 { + color: #0f1420; +} + +.pm-page[data-theme="light"] .text-white\/70, +.pm-page[data-theme="light"] .text-white\/65, +.pm-page[data-theme="light"] .text-white\/60 { + color: rgba(15, 20, 32, 0.75); +} + +.pm-page[data-theme="light"] .text-white\/55, +.pm-page[data-theme="light"] .text-white\/50, +.pm-page[data-theme="light"] .text-white\/45, +.pm-page[data-theme="light"] .text-white\/40 { + color: rgba(15, 20, 32, 0.55); +} + +.pm-page[data-theme="light"] .text-white\/35, +.pm-page[data-theme="light"] .text-white\/30 { + color: rgba(15, 20, 32, 0.38); +} + +.pm-page[data-theme="light"] .border-white\/10, +.pm-page[data-theme="light"] .border-white\/5 { + border-color: rgba(15, 20, 32, 0.14); +} + +.pm-page[data-theme="light"] .bg-white\/5 { + background-color: rgba(15, 20, 32, 0.035); +} + +.pm-page[data-theme="light"] .bg-black\/30, +.pm-page[data-theme="light"] .bg-black\/40, +.pm-page[data-theme="light"] .bg-black\/55 { + background-color: rgba(15, 20, 32, 0.05); +} + +.pm-page[data-theme="light"] .ring-white\/5 { + --tw-ring-color: rgba(15, 20, 32, 0.08); +} + /* HUD top bar (matches classroom design) */ .hud { flex: 0 0 auto; @@ -5889,6 +6023,7 @@ onUnmounted(() => { } .hud-right .btn-label { display: none; } .hud-center { padding: 0; } + .hud-feedback-btn { display: none; } } .pm-body { diff --git a/nuxt.config.ts b/nuxt.config.ts index 5c62d5e..d7e2e34 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -84,6 +84,22 @@ export default defineNuxtConfig({ 'on-background': '#ffffff', 'on-surface': '#ffffff' } + }, + // Only used on /pm today (see app/composables/usePmTheme.ts) — not the app default. + opensquawkLight: { + dark: false, + colors: { + background: '#f4f6fb', + surface: '#ffffff', + primary: '#0891b2', + secondary: '#0369a1', + info: '#0891b2', + success: '#16a34a', + warning: '#d97706', + error: '#dc2626', + 'on-background': '#0f1420', + 'on-surface': '#0f1420' + } } } }