fix: theming

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-07-07 22:49:51 -04:00
parent 5a729d851e
commit 93d32d2a9f
9 changed files with 242 additions and 95 deletions
+11 -15
View File
@@ -1,8 +1,10 @@
import { ref, watch } from 'vue';
import { defineStore } from 'pinia';
import { useUserStore } from './userStore';
import { useTenantStore } from './tenantStore';
export type MenuMode = 'apps' | 'user-settings' | 'admin-settings';
export type ThemeMode = 'light' | 'dark' | 'system';
export const useLayoutStore = defineStore('layout', () => {
// Loading state
@@ -10,13 +12,17 @@ export const useLayoutStore = defineStore('layout', () => {
// Sidebar state - initialize from settings or defaults
const userStore = useUserStore();
const tenantStore = useTenantStore();
const sidebarDrawer = ref(userStore.getSetting('sidebar_drawer') ?? true);
const miniSidebar = ref(userStore.getSetting('mini_sidebar') ?? false);
const menuMode = ref<MenuMode>('apps');
// Theme state - initialize from settings or defaults
const theme = ref(userStore.getSetting('theme') ?? 'light');
const font = ref(userStore.getSetting('font') ?? 'Public sans');
// Theme mode - user choice, falling back to the tenant default
const theme = ref<ThemeMode>(
(userStore.getSetting('theme') as ThemeMode | null) ??
(tenantStore.getSetting('theme_default_mode') as ThemeMode | null) ??
'light'
);
// Watch and sync sidebar state to settings
watch(sidebarDrawer, (value) => {
@@ -31,10 +37,6 @@ export const useLayoutStore = defineStore('layout', () => {
userStore.setSetting('theme', value);
});
watch(font, (value) => {
userStore.setSetting('font', value);
});
// Actions
function toggleSidebarDrawer() {
sidebarDrawer.value = !sidebarDrawer.value;
@@ -44,14 +46,10 @@ export const useLayoutStore = defineStore('layout', () => {
miniSidebar.value = value;
}
function setTheme(value: string) {
function setTheme(value: ThemeMode) {
theme.value = value;
}
function setFont(value: string) {
font.value = value;
}
function setMenuMode(value: MenuMode) {
menuMode.value = value;
}
@@ -71,14 +69,12 @@ export const useLayoutStore = defineStore('layout', () => {
miniSidebar,
menuMode,
theme,
font,
// Actions
toggleSidebarDrawer,
setMiniSidebar,
setMenuMode,
toggleMenuMode,
setTheme,
setFont
setTheme
};
});
+194
View File
@@ -0,0 +1,194 @@
import { computed, watch } from 'vue';
import { defineStore } from 'pinia';
import vuetify from '@KTXC/plugins/vuetify/index';
import { useUserStore } from './userStore';
import { useTenantStore } from './tenantStore';
import { useLayoutStore } from './layoutStore';
export const DEFAULT_FONT = 'Public Sans';
export type ThemeVariant = 'light' | 'dark';
/** Sparse palette overrides for one theme variant */
export interface ThemePalette {
colors: Record<string, string>;
variables?: Record<string, string | number>;
}
/** The shape persisted in the `theme_palette` user/tenant setting */
export type ThemePaletteSet = Partial<Record<ThemeVariant, ThemePalette>>;
interface VariantSnapshot {
colors: Record<string, string>;
variables: Record<string, string | number>;
}
const VARIANTS: ThemeVariant[] = ['light', 'dark'];
export function encodePaletteSet(paletteSet: ThemePaletteSet | null): string | null {
return paletteSet ? JSON.stringify(paletteSet) : null;
}
function parsePaletteSet(value: unknown): ThemePaletteSet | null {
if (!value) return null;
if (typeof value === 'string') {
try {
return JSON.parse(value) as ThemePaletteSet;
} catch {
return null;
}
}
return null;
}
/**
* System theming engine.
*
* Applies the appearance settings (mode, palette, font) persisted in the
* user/tenant settings to the live Vuetify instance. Booted from private.ts
* before app.mount(), so the first paint is already themed. Settings are
* plain data — UI for editing them is provided by the themer module, which
* only reads/writes settings and never touches Vuetify directly.
*
* Setting keys consumed:
* - user: `theme` (light|dark|system), `theme_palette`, `theme_font`
* - tenant: `theme_default_mode`, `theme_palette`, `theme_font`,
* `theme_lock` (forces the tenant palette/font for all users)
*/
export const useThemeStore = defineStore('theme', () => {
const userStore = useUserStore();
const tenantStore = useTenantStore();
const layoutStore = useLayoutStore();
const theme = vuetify.theme;
// Frozen copies of the built-in palettes, captured before the first
// apply(). Stored palettes are sparse and always merge over these, so no
// token (grey scale, scrollbar, variables, ...) can vanish.
const defaults: Record<ThemeVariant, VariantSnapshot> = {
light: snapshotVariant('light'),
dark: snapshotVariant('dark'),
};
function snapshotVariant(variant: ThemeVariant): VariantSnapshot {
const definition = theme.themes.value[variant];
return Object.freeze({
colors: Object.freeze({ ...(definition?.colors ?? {}) }) as Record<string, string>,
variables: Object.freeze({ ...(definition?.variables ?? {}) }) as Record<string, string | number>,
});
}
// ===========================================================================
// Effective settings (tenant lock > user choice > tenant default > built-in)
// ===========================================================================
const isLocked = computed(() => tenantStore.getSetting('theme_lock') === true);
const effectivePalette = computed<ThemePaletteSet>(() => {
const tenantPalette = parsePaletteSet(tenantStore.getSetting('theme_palette'));
if (isLocked.value) return tenantPalette ?? {};
const userPalette = parsePaletteSet(userStore.getSetting('theme_palette'));
return userPalette ?? tenantPalette ?? {};
});
const effectiveFont = computed<string>(() => {
const tenantFont = tenantStore.getSetting('theme_font') as string | null;
if (isLocked.value) return tenantFont ?? DEFAULT_FONT;
const userFont = userStore.getSetting('theme_font') as string | null;
return userFont ?? tenantFont ?? DEFAULT_FONT;
});
// ===========================================================================
// Application
// ===========================================================================
function mergeVariant(variant: ThemeVariant, palette: ThemePalette | undefined): VariantSnapshot {
const overrides = palette?.colors ?? {};
const colors: Record<string, string> = { ...defaults[variant].colors, ...overrides };
// Any token overridden without an explicit foreground pair loses the
// inherited `on-*` value, so Vuetify regenerates it with luma-based
// contrast (arbitrary user colors stay readable).
for (const token of Object.keys(overrides)) {
if (!token.startsWith('on-') && !(`on-${token}` in overrides)) {
delete colors[`on-${token}`];
}
}
const variables = { ...defaults[variant].variables, ...(palette?.variables ?? {}) };
return { colors, variables };
}
function applyPalette(): void {
const paletteSet = effectivePalette.value;
for (const variant of VARIANTS) {
const target = theme.themes.value[variant];
if (!target) continue;
const merged = mergeVariant(variant, paletteSet[variant]);
// Wholesale assignment on the themes ref is reactive in Vuetify 4.0.x:
// computedThemes/styles regenerate the theme stylesheet.
target.colors = merged.colors as typeof target.colors;
target.variables = merged.variables;
}
}
function applyFont(font: string): void {
if (font && font !== DEFAULT_FONT) {
document.documentElement.style.setProperty('--themer-font', font);
document.body.style.fontFamily = `"${font}", sans-serif`;
} else {
document.documentElement.style.removeProperty('--themer-font');
document.body.style.fontFamily = '';
}
}
function applyMode(): void {
// 'system' resolves via Vuetify's built-in prefers-color-scheme tracking
theme.change(layoutStore.theme);
}
let booted = false;
/** Called from private.ts after the settings stores init, before app.mount(). */
function boot(): void {
if (booted) return;
booted = true;
watch(effectivePalette, applyPalette);
watch(effectiveFont, applyFont);
watch(() => layoutStore.theme, applyMode);
applyPalette();
applyFont(effectiveFont.value);
applyMode();
}
// ===========================================================================
// Preview themes (used by editing UIs; never persisted)
// ===========================================================================
/** Register/update a throwaway named theme, merged over the built-in defaults. */
function setPreviewTheme(name: string, variant: ThemeVariant, palette: ThemePalette): void {
const merged = mergeVariant(variant, palette);
theme.themes.value[name] = {
dark: variant === 'dark',
colors: merged.colors,
variables: merged.variables,
} as (typeof theme.themes.value)[string];
}
function removePreviewTheme(name: string): void {
delete theme.themes.value[name];
}
return {
// State
defaults,
isLocked,
effectivePalette,
effectiveFont,
// Lifecycle
boot,
// Preview support
setPreviewTheme,
removePreviewTheme,
};
});