feat(frontend): add light/dark theme switcher

Add persisted theme mode (light, dark, system) with a header menu.
Rework the light and dark palettes for stronger contrast and drop the
high-contrast theme.
This commit is contained in:
Rafał Tyński
2026-10-03 18:54:13 +02:00
parent 2b6a9f5433
commit 1ad16fb35b
4 changed files with 128 additions and 52 deletions
+33 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import MainView from './pages/MainView.vue'
import { useThemeMode } from './composables/useThemeMode'
interface NavItem {
title: string
@@ -10,6 +11,11 @@ interface NavItem {
const drawer = ref(false)
const { mode, themeOptions } = useThemeMode()
const activeTheme = computed(
() => themeOptions.find((option) => option.value === mode.value) ?? themeOptions[2]!,
)
const navItems: NavItem[] = [
{ title: 'Misja', href: '#misja', icon: 'mdi-compass-outline' },
{ title: 'Jak to działa', href: '#jak-to-dziala', icon: 'mdi-sitemap-outline' },
@@ -29,6 +35,32 @@ const navItems: NavItem[] = [
/>
<v-app-bar-title>TarninaHub</v-app-bar-title>
<v-spacer />
<v-menu location="bottom end">
<template #activator="{ props }">
<v-btn
v-bind="props"
:prepend-icon="activeTheme.icon"
:aria-label="`Motyw: ${activeTheme.title}. Zmień motyw`"
color="on-primary"
variant="text"
class="me-1"
>
{{ activeTheme.title }}
</v-btn>
</template>
<v-list aria-label="Wybór motywu">
<v-list-item
v-for="option in themeOptions"
:key="option.value"
:prepend-icon="option.icon"
:title="option.title"
:active="mode === option.value"
@click="mode = option.value"
/>
</v-list>
</v-menu>
<nav aria-label="Menu główne" class="d-none d-md-block">
<v-btn
v-for="item in navItems"
+32
View File
@@ -0,0 +1,32 @@
import { computed, watch } from 'vue'
import { usePreferredDark, useStorage } from '@vueuse/core'
import { useTheme } from 'vuetify'
export type ThemeMode = 'light' | 'dark' | 'system'
export type ResolvedTheme = 'light' | 'dark'
export interface ThemeOption {
value: ThemeMode
title: string
icon: string
}
export const themeOptions: ThemeOption[] = [
{ value: 'light', title: 'Jasny', icon: 'mdi-white-balance-sunny' },
{ value: 'dark', title: 'Ciemny', icon: 'mdi-weather-night' },
{ value: 'system', title: 'Systemowy', icon: 'mdi-theme-light-dark' },
]
export function useThemeMode() {
const theme = useTheme()
const preferredDark = usePreferredDark()
const mode = useStorage<ThemeMode>('tarninahub-theme', 'system')
const resolved = computed<ResolvedTheme>(() =>
mode.value === 'system' ? (preferredDark.value ? 'dark' : 'light') : mode.value,
)
watch(resolved, (value) => theme.change(value), { immediate: true })
return { mode, resolved, preferredDark, themeOptions }
}
+62 -47
View File
@@ -5,66 +5,81 @@ import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import { en, pl } from 'vuetify/locale'
const light: ThemeDefinition = {
export const light: ThemeDefinition = {
dark: false,
colors: {
background: '#FFFFFF',
background: '#FBF8FF',
'on-background': '#1A1030',
surface: '#FFFFFF',
'surface-variant': '#4D4D4D',
primary: '#1565C0',
'on-surface': '#1A1030',
'surface-bright': '#FFFFFF',
'on-surface-bright': '#1A1030',
'surface-light': '#F1EAFB',
'on-surface-light': '#1A1030',
'surface-variant': '#ECE4FA',
'on-surface-variant': '#1A1030',
primary: '#5B21B6',
'on-primary': '#FFFFFF',
secondary: '#455A64',
secondary: '#6A1B9A',
'on-secondary': '#FFFFFF',
info: '#0B4F9E',
'on-info': '#FFFFFF',
success: '#166534',
'on-success': '#FFFFFF',
warning: '#7C4A00',
'on-warning': '#FFFFFF',
error: '#B3261E',
'on-error': '#FFFFFF',
success: '#1B5E20',
'on-success': '#FFFFFF',
warning: '#8A4B00',
'on-warning': '#FFFFFF',
info: '#01579B',
'on-info': '#FFFFFF',
outline: '#6E5D93',
focus: '#4C1D95',
},
variables: {
'border-color': '#6E5D93',
'border-opacity': 1,
'high-emphasis-opacity': 1,
'medium-emphasis-opacity': 0.8,
'disabled-opacity': 0.5,
},
}
const dark: ThemeDefinition = {
export const dark: ThemeDefinition = {
dark: true,
colors: {
background: '#121212',
surface: '#1E1E1E',
'surface-variant': '#CFCFCF',
primary: '#90CAF9',
'on-primary': '#0D1B2A',
secondary: '#B0BEC5',
'on-secondary': '#10181C',
background: '#151020',
'on-background': '#F1EBFF',
surface: '#1E1730',
'on-surface': '#F1EBFF',
'surface-bright': '#2A2142',
'on-surface-bright': '#F1EBFF',
'surface-light': '#2A2142',
'on-surface-light': '#F1EBFF',
'surface-variant': '#2A2142',
'on-surface-variant': '#F1EBFF',
primary: '#B79CFF',
'on-primary': '#240850',
secondary: '#E3AEF2',
'on-secondary': '#3A0F4A',
info: '#9EC5FF',
'on-info': '#0A2447',
success: '#86DDA0',
'on-success': '#08311A',
warning: '#FFC766',
'on-warning': '#3A2600',
error: '#FFB4AB',
'on-error': '#410002',
success: '#A5D6A7',
'on-success': '#0B2A0E',
warning: '#FFCC80',
'on-warning': '#2E1A00',
info: '#81D4FA',
'on-info': '#00263A',
},
}
'on-error': '#4A0A06',
const highContrast: ThemeDefinition = {
dark: true,
colors: {
background: '#000000',
surface: '#000000',
'surface-variant': '#FFFFFF',
primary: '#FFFF00',
'on-primary': '#000000',
secondary: '#00FFFF',
'on-secondary': '#000000',
error: '#FF8A80',
'on-error': '#000000',
success: '#69F0AE',
'on-success': '#000000',
warning: '#FFD740',
'on-warning': '#000000',
info: '#80D8FF',
'on-info': '#000000',
outline: '#9A8FB5',
focus: '#FFD54F',
},
variables: {
'border-color': '#9A8FB5',
'border-opacity': 1,
'high-emphasis-opacity': 1,
'medium-emphasis-opacity': 0.8,
'disabled-opacity': 0.5,
},
}
@@ -72,7 +87,7 @@ export default createVuetify({
components,
directives,
locale: { locale: 'pl', fallback: 'en', messages: { pl, en } },
theme: { defaultTheme: 'system', themes: { light, dark, highContrast } },
theme: { defaultTheme: 'system', themes: { light, dark } },
defaults: {
global: { ripple: false },
VBtn: { size: 'large', variant: 'flat', color: 'primary' },
+1 -4
View File
@@ -1,12 +1,9 @@
.v-theme--light {
--a11y-focus: #0b3d91;
--a11y-focus: #4c1d95;
}
.v-theme--dark {
--a11y-focus: #ffd54f;
}
.v-theme--highContrast {
--a11y-focus: #ffff00;
}
html {
font-size: 112.5%;