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.
This commit is contained in:
itsrubberduck
2026-07-09 17:26:21 +02:00
parent 5531da0e9a
commit e521dcba0f
4 changed files with 231 additions and 5 deletions

View File

@@ -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;

View File

@@ -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<PmThemePreference | null>(PM_THEME_COOKIE, {
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 365,
})
const preference = ref<PmThemePreference>(storedPreference.value ?? 'dark')
const systemPrefersLight = ref(false)
const effectiveTheme = computed<PmEffectiveTheme>(() => {
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 }
}

View File

@@ -1,9 +1,9 @@
<template>
<div class="min-h-screen bg-[#050910] text-white">
<div class="pm-page min-h-screen bg-[var(--bg)] text-[var(--text)]" :data-theme="pmTheme">
<!-- Session-start overlay: covers the (synchronous) taxi-route computation -->
<div
v-if="sessionStarting"
class="fixed inset-0 z-[2000] flex flex-col items-center justify-center gap-4 bg-[#050910]/80 backdrop-blur"
class="fixed inset-0 z-[2000] flex flex-col items-center justify-center gap-4 bg-[var(--bg)]/80 backdrop-blur"
role="status"
aria-live="polite"
>
@@ -519,6 +519,65 @@
</span>
</template>
</v-tooltip>
<v-menu v-model="themeMenu" :offset="[0, 8]" location="bottom end" transition="scale-transition">
<template #activator="{ props }">
<button
class="btn ghost"
type="button"
v-bind="props"
title="Theme"
aria-haspopup="menu"
:aria-expanded="themeMenu ? 'true' : 'false'"
>
<v-icon size="18">{{ themeIcon }}</v-icon>
<span class="btn-label">Theme</span>
</button>
</template>
<div class="experience-menu" role="menu" aria-label="Select theme">
<button
type="button"
role="menuitemradio"
class="experience-option"
:class="{ 'is-active': pmThemePreference === 'dark' }"
:aria-checked="pmThemePreference === 'dark'"
@click="setPmTheme('dark')"
>
<v-icon size="18" class="experience-option-icon">mdi-weather-night</v-icon>
<div class="experience-option-body">
<div class="experience-option-title">Dark</div>
</div>
<v-icon v-if="pmThemePreference === 'dark'" size="16" class="experience-option-check">mdi-check</v-icon>
</button>
<button
type="button"
role="menuitemradio"
class="experience-option"
:class="{ 'is-active': pmThemePreference === 'light' }"
:aria-checked="pmThemePreference === 'light'"
@click="setPmTheme('light')"
>
<v-icon size="18" class="experience-option-icon">mdi-white-balance-sunny</v-icon>
<div class="experience-option-body">
<div class="experience-option-title">Light</div>
</div>
<v-icon v-if="pmThemePreference === 'light'" size="16" class="experience-option-check">mdi-check</v-icon>
</button>
<button
type="button"
role="menuitemradio"
class="experience-option"
:class="{ 'is-active': pmThemePreference === 'system' }"
:aria-checked="pmThemePreference === 'system'"
@click="setPmTheme('system')"
>
<v-icon size="18" class="experience-option-icon">mdi-theme-light-dark</v-icon>
<div class="experience-option-body">
<div class="experience-option-title">Use system</div>
</div>
<v-icon v-if="pmThemePreference === 'system'" size="16" class="experience-option-check">mdi-check</v-icon>
</button>
</div>
</v-menu>
<button
type="button"
class="btn ghost"
@@ -538,7 +597,7 @@
<v-icon size="18">{{ bugReportCapturing ? 'mdi-loading mdi-spin' : 'mdi-bug-outline' }}</v-icon>
<span class="btn-label">{{ bugReportCapturing ? '…' : 'Bug' }}</span>
</button>
<NuxtLink class="btn ghost" to="/feedback" title="Share feedback or ideas">
<NuxtLink class="btn ghost hud-feedback-btn" to="/feedback" title="Share feedback or ideas">
<v-icon size="18">mdi-message-draw</v-icon>
<span class="btn-label">Feedback</span>
</NuxtLink>
@@ -649,8 +708,8 @@
<ClientOnly>
<div
class="ptt-pad flex h-52 lg:h-60 items-center justify-center rounded-2xl border text-center transition cursor-pointer"
:class="isRecording ? 'border-green-400/40 ring-4 ring-green-400/40 bg-green-500/10' : 'border-white/10 ring-1 ring-white/5 bg-black/40'"
class="ptt-pad flex h-52 lg:h-60 items-center justify-center rounded-2xl border-2 text-center transition cursor-pointer"
:class="isRecording ? 'ptt-pad--active border-green-400/50 ring-4 ring-green-400/40 bg-green-500/10' : 'ptt-pad--idle bg-black/40'"
@touchstart.prevent="startRecording(false)"
@touchend.prevent="stopRecording"
@touchcancel.prevent="stopRecording"
@@ -1479,6 +1538,7 @@ import useCommunicationsEngine from "../../shared/utils/communicationsEngine";
import { normalizeRadioPhrase, normalizeAtisForSpeech, DEFAULT_AIRLINE_TELEPHONY } from '../../shared/utils/radioSpeech';
import { useAuthStore } from '~/stores/auth'
import { useApi } from '~/composables/useApi'
import { usePmTheme } from '~/composables/usePmTheme'
import { loadPizzicatoLite } from '../../shared/utils/pizzicatoLite'
import type { PizzicatoLite } from '../../shared/utils/pizzicatoLite'
import { createNoiseGenerators, getReadabilityProfile } from '../../shared/utils/radioEffects'
@@ -1523,6 +1583,12 @@ const pmLog = (() => {
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 {

View File

@@ -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'
}
}
}
}