mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-01 06:06:05 +08:00
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:
@@ -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;
|
||||
|
||||
62
app/composables/usePmTheme.ts
Normal file
62
app/composables/usePmTheme.ts
Normal 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 }
|
||||
}
|
||||
145
app/pages/pm.vue
145
app/pages/pm.vue
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user