feat: add theme selection and customization

This commit is contained in:
2026-07-07 22:59:03 -04:00
parent 49df5195bd
commit fe39b2196d
26 changed files with 3485 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts" setup>
defineProps<{
label: string
modelValue: string
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
}>()
function onPick(value: string | null): void {
if (value) emit('update:modelValue', value.toUpperCase())
}
function onType(value: string): void {
if (/^#[0-9A-Fa-f]{6}$/.test(value)) emit('update:modelValue', value.toUpperCase())
}
</script>
<template>
<VTextField
:label="label"
:model-value="modelValue"
density="comfortable"
@update:model-value="onType"
>
<template #prepend-inner>
<VMenu :close-on-content-click="false" location="bottom start">
<template #activator="{ props: activatorProps }">
<div
v-bind="activatorProps"
class="color-swatch rounded cursor-pointer"
:style="{ backgroundColor: modelValue }"
/>
</template>
<VColorPicker
:model-value="modelValue"
mode="hex"
:modes="['hex', 'rgb', 'hsl']"
show-swatches
@update:model-value="onPick"
/>
</VMenu>
</template>
</VTextField>
</template>
<style scoped>
.color-swatch {
width: 24px;
height: 24px;
border: 1px solid rgba(var(--v-border-color), 0.4);
}
.cursor-pointer {
cursor: pointer;
}
</style>
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts" setup>
import { useL10n } from '@KTXC'
import { FONT_OPTIONS, DEFAULT_FONT } from '@/presets'
defineProps<{
modelValue: string
disabled?: boolean
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
}>()
const { t } = useL10n('themer')
const items = FONT_OPTIONS.map(font => ({
value: font,
title: font === DEFAULT_FONT ? `${font} (${t('font.default', 'Default')})` : font,
}))
</script>
<template>
<VSelect
:model-value="modelValue"
:items="items"
:label="t('font.label', 'Font family')"
:disabled="disabled"
density="comfortable"
@update:model-value="emit('update:modelValue', $event)"
>
<template #item="{ props: itemProps, item }">
<VListItem v-bind="itemProps" :style="{ fontFamily: `'${item.value}', sans-serif` }" />
</template>
</VSelect>
</template>
+42
View File
@@ -0,0 +1,42 @@
<script lang="ts" setup>
import { useL10n } from '@KTXC'
import type { ThemerMode } from '@/types'
defineProps<{
modelValue: ThemerMode
disabled?: boolean
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: ThemerMode): void
}>()
const { t } = useL10n('themer')
const modes: Array<{ value: ThemerMode; icon: string; l10n: string; label: string }> = [
{ value: 'light', icon: 'mdi-weather-sunny', l10n: 'mode.light', label: 'Light' },
{ value: 'dark', icon: 'mdi-weather-night', l10n: 'mode.dark', label: 'Dark' },
{ value: 'system', icon: 'mdi-theme-light-dark', l10n: 'mode.system', label: 'System' },
]
</script>
<template>
<VBtnToggle
:model-value="modelValue"
:disabled="disabled"
mandatory
divided
variant="outlined"
color="primary"
@update:model-value="emit('update:modelValue', $event as ThemerMode)"
>
<VBtn
v-for="mode in modes"
:key="mode.value"
:value="mode.value"
:prepend-icon="mode.icon"
>
{{ t(mode.l10n, mode.label) }}
</VBtn>
</VBtnToggle>
</template>
+79
View File
@@ -0,0 +1,79 @@
<script lang="ts" setup>
import { t as tGlobal } from '@KTXC'
import { useThemerStore } from '@/stores/themerStore'
import type { ThemerPreset, ThemerVariant } from '@/types'
const props = defineProps<{
preset: ThemerPreset
active?: boolean
disabled?: boolean
}>()
const emit = defineEmits<{
(event: 'select', id: string): void
}>()
const themerStore = useThemerStore()
const SWATCH_TOKENS = ['primary', 'secondary', 'surface', 'background']
const VARIANTS: ThemerVariant[] = ['light', 'dark']
function swatchColor(variant: ThemerVariant, token: string): string {
return themerStore.resolveToken(props.preset, variant, token)
}
function title(): string {
return props.preset.l10n
? tGlobal(`themer.${props.preset.l10n}`, props.preset.label)
: props.preset.label
}
</script>
<template>
<VCard
:disabled="disabled"
:variant="active ? 'outlined' : 'elevated'"
:color="active ? 'primary' : undefined"
class="preset-card"
@click="emit('select', preset.id)"
>
<VCardItem>
<VCardTitle class="d-flex align-center text-body-1 font-weight-medium">
{{ title() }}
<VSpacer />
<VIcon v-if="active" color="primary" size="20">mdi-check-circle</VIcon>
</VCardTitle>
</VCardItem>
<VCardText>
<div
v-for="variant in VARIANTS"
:key="variant"
class="d-flex align-center mb-2"
>
<VIcon size="14" class="mr-2 text-medium-emphasis">
{{ variant === 'light' ? 'mdi-weather-sunny' : 'mdi-weather-night' }}
</VIcon>
<div
class="swatch-strip d-flex flex-grow-1 rounded overflow-hidden"
:style="{ border: '1px solid rgba(var(--v-border-color), 0.24)' }"
>
<div
v-for="token in SWATCH_TOKENS"
:key="token"
class="swatch flex-grow-1"
:style="{ backgroundColor: swatchColor(variant, token) }"
/>
</div>
</div>
</VCardText>
</VCard>
</template>
<style scoped>
.preset-card {
cursor: pointer;
}
.swatch-strip {
height: 22px;
}
</style>
+55
View File
@@ -0,0 +1,55 @@
<script lang="ts" setup>
import { useL10n } from '@KTXC'
defineProps<{
themeName: string
}>()
const { t } = useL10n('themer')
</script>
<template>
<VThemeProvider :theme="themeName" with-background class="rounded-lg pa-4 preview-surface">
<p class="text-subtitle-2 mb-3">{{ t('editor.previewTitle', 'Preview') }}</p>
<VCard class="mb-4">
<VCardItem>
<VCardTitle>{{ t('editor.previewCardTitle', 'Sample card') }}</VCardTitle>
</VCardItem>
<VCardText>
{{ t('editor.previewCardText', 'Body text on a surface, with medium emphasis details.') }}
<div class="d-flex flex-wrap ga-2 mt-4">
<VBtn color="primary">{{ t('editor.previewPrimary', 'Primary') }}</VBtn>
<VBtn color="secondary" variant="tonal">{{ t('editor.previewSecondary', 'Secondary') }}</VBtn>
<VBtn variant="outlined">{{ t('editor.previewOutlined', 'Outlined') }}</VBtn>
</div>
<div class="d-flex flex-wrap ga-2 mt-4">
<VChip color="success" size="small">{{ t('editor.previewSuccess', 'Success') }}</VChip>
<VChip color="info" size="small">{{ t('editor.previewInfo', 'Info') }}</VChip>
<VChip color="warning" size="small">{{ t('editor.previewWarning', 'Warning') }}</VChip>
<VChip color="error" size="small">{{ t('editor.previewError', 'Error') }}</VChip>
</div>
<VTextField
class="mt-4"
:label="t('editor.previewField', 'Text field')"
density="comfortable"
hide-details
/>
<VSwitch
color="primary"
:label="t('editor.previewSwitch', 'Switch')"
:model-value="true"
hide-details
/>
</VCardText>
</VCard>
<VAlert type="info" variant="tonal" density="compact">
{{ t('editor.previewAlert', 'Informational alert on the themed background.') }}
</VAlert>
</VThemeProvider>
</template>
<style scoped>
.preview-surface {
border: 1px solid rgba(var(--v-border-color), 0.24);
}
</style>
+29
View File
@@ -0,0 +1,29 @@
import type { ModuleIntegrations } from "@KTXC/types/moduleTypes";
const integrations: ModuleIntegrations = {
// User personal settings
user_settings_menu: [
{
id: 'appearance',
label: 'Appearance',
l10n: 'nav.appearance',
path: '/settings',
icon: 'mdi-palette-outline',
priority: 25,
},
],
// Tenant administration
admin_settings_menu: [
{
id: 'appearance',
label: 'Appearance',
l10n: 'nav.appearance',
path: '/admin',
icon: 'mdi-palette-outline',
priority: 60,
},
],
};
export default integrations;
+15
View File
@@ -0,0 +1,15 @@
import routes from '@/routes'
import integrations from '@/integrations'
// The themer module is UI only: it reads/writes the appearance settings
// (`theme_*` system keys plus its own `themer_*` selection state). Applying
// those settings is a core system function (@KTXC useThemeStore), so there is
// no install/boot step here.
// Replaced with the hashed stylesheet path at build time (vite.config.ts)
export const css = ['__CSS_FILENAME_PLACEHOLDER__']
// Module ships translation catalogs at static/l10n/{locale}.json
export const l10n = true
export { routes, integrations }
+254
View File
@@ -0,0 +1,254 @@
import type { ThemerPreset } from '@/types'
export const DEFAULT_PRESET_ID = 'default'
export const DEFAULT_FONT = 'Public Sans'
/**
* Preset palettes are sparse overrides: any token not listed falls back to the
* app-default palette (captured by the themer store at boot). Curated presets
* define their own `on-*` accents where the default white foreground would not
* hold enough contrast.
*/
export const PRESETS: ThemerPreset[] = [
{
id: 'default',
label: 'Indigo',
l10n: 'presets.default',
light: { colors: {} },
dark: { colors: {} },
},
{
id: 'emerald',
label: 'Emerald',
l10n: 'presets.emerald',
light: {
colors: {
'primary': '#059669',
'primary-darken-1': '#047857',
'primary-lighten-1': '#34D399',
'secondary': '#0D9488',
'secondary-darken-1': '#0F766E',
'secondary-lighten-1': '#2DD4BF',
'background': '#F4FAF7',
'surface-variant': '#EBF4EF',
'grey-lighten-4': '#EBF4EF',
'grey-lighten-5': '#F4FAF7',
'track-bg': '#EBF4EF',
},
variables: { 'border-color': '#DCE8E2' },
},
dark: {
colors: {
'primary': '#34D399',
'on-primary': '#052E1C',
'primary-darken-1': '#10B981',
'primary-lighten-1': '#6EE7B7',
'secondary': '#2DD4BF',
'on-secondary': '#042F2E',
'secondary-darken-1': '#14B8A6',
'secondary-lighten-1': '#5EEAD4',
'background': '#0B1512',
'surface': '#14201B',
'surface-bright': '#1E2E27',
'surface-variant': '#14201B',
'grey-lighten-3': '#24352E',
'grey-lighten-4': '#14201B',
'grey-lighten-5': '#0B1512',
'perfect-scrollbar-thumb': '#2F443B',
'track-bg': '#1E2E27',
},
variables: { 'border-color': '#24352E' },
},
},
{
id: 'rose',
label: 'Rose',
l10n: 'presets.rose',
light: {
colors: {
'primary': '#E11D48',
'primary-darken-1': '#BE123C',
'primary-lighten-1': '#FB7185',
'secondary': '#DB2777',
'secondary-darken-1': '#BE185D',
'secondary-lighten-1': '#F472B6',
'background': '#FDF7F8',
'surface-variant': '#FBEFF2',
'grey-lighten-4': '#FBEFF2',
'grey-lighten-5': '#FDF7F8',
'track-bg': '#FBEFF2',
},
variables: { 'border-color': '#F1DFE4' },
},
dark: {
colors: {
'primary': '#FB7185',
'on-primary': '#40060F',
'primary-darken-1': '#F43F5E',
'primary-lighten-1': '#FDA4AF',
'secondary': '#F472B6',
'on-secondary': '#3E0524',
'secondary-darken-1': '#EC4899',
'secondary-lighten-1': '#F9A8D4',
'background': '#171013',
'surface': '#231A1E',
'surface-bright': '#32232A',
'surface-variant': '#231A1E',
'grey-lighten-3': '#3A2A31',
'grey-lighten-4': '#231A1E',
'grey-lighten-5': '#171013',
'perfect-scrollbar-thumb': '#4C323A',
'track-bg': '#32232A',
},
variables: { 'border-color': '#3A2A31' },
},
},
{
id: 'amber',
label: 'Amber',
l10n: 'presets.amber',
light: {
colors: {
'primary': '#D97706',
'primary-darken-1': '#B45309',
'primary-lighten-1': '#F59E0B',
'secondary': '#EA580C',
'secondary-darken-1': '#C2410C',
'secondary-lighten-1': '#FB923C',
'warning': '#EAB308',
'background': '#FCFAF5',
'surface-variant': '#F8F1E7',
'grey-lighten-4': '#F8F1E7',
'grey-lighten-5': '#FCFAF5',
'track-bg': '#F8F1E7',
},
variables: { 'border-color': '#EDE3D0' },
},
dark: {
colors: {
'primary': '#FBBF24',
'on-primary': '#422006',
'primary-darken-1': '#F59E0B',
'primary-lighten-1': '#FCD34D',
'secondary': '#FB923C',
'on-secondary': '#431407',
'secondary-darken-1': '#F97316',
'secondary-lighten-1': '#FDBA74',
'warning': '#FACC15',
'background': '#15120B',
'surface': '#211C10',
'surface-bright': '#2E2718',
'surface-variant': '#211C10',
'grey-lighten-3': '#383018',
'grey-lighten-4': '#211C10',
'grey-lighten-5': '#15120B',
'perfect-scrollbar-thumb': '#4A3F21',
'track-bg': '#2E2718',
},
variables: { 'border-color': '#383018' },
},
},
{
id: 'ocean',
label: 'Ocean',
l10n: 'presets.ocean',
light: {
colors: {
'primary': '#0284C7',
'primary-darken-1': '#0369A1',
'primary-lighten-1': '#38BDF8',
'secondary': '#0891B2',
'secondary-darken-1': '#0E7490',
'secondary-lighten-1': '#22D3EE',
'background': '#F4F9FC',
'surface-variant': '#E9F3F9',
'grey-lighten-4': '#E9F3F9',
'grey-lighten-5': '#F4F9FC',
'track-bg': '#E9F3F9',
},
variables: { 'border-color': '#DBE9F1' },
},
dark: {
colors: {
'primary': '#38BDF8',
'on-primary': '#082F49',
'primary-darken-1': '#0EA5E9',
'primary-lighten-1': '#7DD3FC',
'secondary': '#22D3EE',
'on-secondary': '#083344',
'secondary-darken-1': '#06B6D4',
'secondary-lighten-1': '#67E8F9',
'background': '#0B141B',
'surface': '#132029',
'surface-bright': '#1C2E3A',
'surface-variant': '#132029',
'grey-lighten-3': '#223648',
'grey-lighten-4': '#132029',
'grey-lighten-5': '#0B141B',
'perfect-scrollbar-thumb': '#2E4557',
'track-bg': '#1C2E3A',
},
variables: { 'border-color': '#223648' },
},
},
{
id: 'graphite',
label: 'Graphite',
l10n: 'presets.graphite',
light: {
colors: {
'primary': '#334155',
'primary-darken-1': '#1E293B',
'primary-lighten-1': '#64748B',
'secondary': '#64748B',
'secondary-darken-1': '#475569',
'secondary-lighten-1': '#94A3B8',
'background': '#F6F7F9',
},
},
dark: {
colors: {
'primary': '#94A3B8',
'on-primary': '#0F172A',
'primary-darken-1': '#64748B',
'primary-lighten-1': '#CBD5E1',
'secondary': '#CBD5E1',
'on-secondary': '#0F172A',
'secondary-darken-1': '#94A3B8',
'secondary-lighten-1': '#E2E8F0',
'background': '#0B0F17',
'surface': '#151A23',
'surface-bright': '#232A36',
'surface-variant': '#151A23',
'grey-lighten-3': '#2A3240',
'grey-lighten-4': '#151A23',
'grey-lighten-5': '#0B0F17',
'perfect-scrollbar-thumb': '#3A4353',
'track-bg': '#232A36',
},
variables: { 'border-color': '#2A3240' },
},
},
]
export function getPreset(id: string | null | undefined): ThemerPreset | undefined {
if (!id) return undefined
return PRESETS.find(preset => preset.id === id)
}
/** Curated font choices; only Public Sans is bundled, the rest are web-safe stacks. */
export const FONT_OPTIONS = [
'Public Sans',
'Arial',
'Verdana',
'Tahoma',
'Trebuchet MS',
'Georgia',
'Times New Roman',
'Courier New',
]
+29
View File
@@ -0,0 +1,29 @@
const routes = [
{
name: 'settings',
path: '/settings',
component: () => import('@/views/ThemerSettingsPage.vue'),
meta: {
requiresAuth: true
}
},
{
name: 'customize',
path: '/settings/customize',
component: () => import('@/views/ThemerCustomizePage.vue'),
meta: {
requiresAuth: true
}
},
{
name: 'admin',
path: '/admin',
component: () => import('@/views/ThemerAdminPage.vue'),
meta: {
requiresAuth: true,
permission: 'tenant.settings.update'
}
},
]
export default routes
+219
View File
@@ -0,0 +1,219 @@
import { computed } from 'vue'
import { defineStore } from 'pinia'
import { useUserStore, useTenantStore, useLayoutStore, useThemeStore, DEFAULT_FONT, encodePaletteSet } from '@KTXC'
import type { ThemePaletteSet } from '@KTXC'
import { getPreset, DEFAULT_PRESET_ID, PRESETS } from '@/presets'
import type { ThemerCustomTheme, ThemerMode, ThemerPreset, ThemerVariant } from '@/types'
/** Tokens exposed in the custom-theme editor */
export const EDITABLE_TOKENS = [
'primary',
'secondary',
'background',
'surface',
'error',
'warning',
'info',
'success',
] as const
export const PREVIEW_THEME_NAMES: Record<ThemerVariant, string> = {
light: 'themer-preview-light',
dark: 'themer-preview-dark',
}
const VARIANTS: ThemerVariant[] = ['light', 'dark']
/**
* UI-side store of the themer module.
*
* Theming itself is a system function: the core theme store (@KTXC
* useThemeStore) applies the `theme_*` settings. This store only translates
* UI intents (pick preset, save custom theme, tenant defaults) into settings
* writes, and keeps the module's own selection state (`themer_preset`,
* `themer_custom`, `themer_default_preset`) alongside.
*/
export const useThemerStore = defineStore('themer', () => {
const userStore = useUserStore()
const tenantStore = useTenantStore()
const layoutStore = useLayoutStore()
const themeStore = useThemeStore()
// ===========================================================================
// State
// ===========================================================================
const isLocked = computed(() => themeStore.isLocked)
const mode = computed<ThemerMode>(() => layoutStore.theme as ThemerMode)
const userPresetId = computed(() => userStore.getSetting('themer_preset') as string | null)
const customTheme = computed<ThemerCustomTheme | null>(() => {
// Persisted as a JSON string: settings values must stay scalar (nested
// objects are flattened into dot-notation keys by the settings store)
const raw = userStore.getSetting('themer_custom')
if (!raw || typeof raw !== 'string') return null
try {
return JSON.parse(raw) as ThemerCustomTheme
} catch {
return null
}
})
const font = computed<string>(() => themeStore.effectiveFont)
const tenantPresetId = computed(() => tenantStore.getSetting('themer_default_preset') as string | null)
const tenantMode = computed(() => tenantStore.getSetting('theme_default_mode') as ThemerMode | null)
/** The selection shown as active in the gallery, after lock + fallback rules */
const activePresetId = computed<string>(() => {
if (isLocked.value) {
return tenantPresetId.value ?? DEFAULT_PRESET_ID
}
const chosen = userPresetId.value ?? tenantPresetId.value ?? DEFAULT_PRESET_ID
if (chosen === 'custom' && !customTheme.value) return DEFAULT_PRESET_ID
return chosen
})
// ===========================================================================
// Helpers
// ===========================================================================
function paletteSetOf(source: ThemerPreset | ThemerCustomTheme): ThemePaletteSet {
// Deep copy so a plain JSON object is persisted
return JSON.parse(JSON.stringify({ light: source.light, dark: source.dark }))
}
/** Resolve a token for display (e.g. gallery swatches): source value or app default */
function resolveToken(source: ThemerPreset | ThemerCustomTheme, variant: ThemerVariant, token: string): string {
return source[variant].colors[token]
?? (themeStore.defaults[variant].colors as Record<string, string>)[token]
?? '#000000'
}
// ===========================================================================
// User actions
// ===========================================================================
function setMode(value: ThemerMode): void {
layoutStore.setTheme(value)
}
function setPreset(id: string): void {
if (isLocked.value) return
if (id === 'custom') {
if (!customTheme.value) return
userStore.setSetting('themer_preset', 'custom')
userStore.setSetting('theme_palette', encodePaletteSet(paletteSetOf(customTheme.value)))
return
}
const preset = getPreset(id)
if (!preset) return
userStore.setSetting('themer_preset', preset.id)
userStore.setSetting(
'theme_palette',
preset.id === DEFAULT_PRESET_ID ? null : encodePaletteSet(paletteSetOf(preset)),
)
}
function setFont(value: string | null): void {
userStore.setSetting('theme_font', !value || value === DEFAULT_FONT ? null : value)
}
function saveCustom(draft: ThemerCustomTheme): void {
const custom: ThemerCustomTheme = JSON.parse(JSON.stringify(draft))
userStore.setSetting('themer_custom', JSON.stringify(custom))
userStore.setSetting('themer_preset', 'custom')
userStore.setSetting('theme_palette', encodePaletteSet(paletteSetOf(custom)))
}
function clearCustom(): void {
userStore.setSetting('themer_custom', null)
if (userPresetId.value === 'custom') {
userStore.setSetting('themer_preset', null)
userStore.setSetting('theme_palette', null)
}
}
// ===========================================================================
// Tenant (admin) actions — server enforces tenant.settings.update
// ===========================================================================
function setTenantDefaultPreset(id: string | null): void {
const preset = getPreset(id)
tenantStore.setSetting('themer_default_preset', preset?.id ?? null)
tenantStore.setSetting(
'theme_palette',
preset && preset.id !== DEFAULT_PRESET_ID ? encodePaletteSet(paletteSetOf(preset)) : null,
)
}
function setTenantDefaultMode(value: ThemerMode | null): void {
tenantStore.setSetting('theme_default_mode', value)
}
function setTenantLock(locked: boolean): void {
tenantStore.setSetting('theme_lock', locked)
}
// ===========================================================================
// Editor support
// ===========================================================================
/** Full editable draft derived from a preset (or the stored custom theme) */
function createDraft(baseId: string): ThemerCustomTheme {
const stored = customTheme.value
if (baseId === 'custom' && stored) {
return JSON.parse(JSON.stringify(stored))
}
const preset = getPreset(baseId) ?? getPreset(DEFAULT_PRESET_ID)!
const variantDraft = (variant: ThemerVariant) => {
const colors: Record<string, string> = { ...preset[variant].colors }
for (const token of EDITABLE_TOKENS) {
colors[token] = resolveToken(preset, variant, token)
}
return { colors, variables: { ...(preset[variant].variables ?? {}) } }
}
return { base: preset.id, light: variantDraft('light'), dark: variantDraft('dark') }
}
/** Register/update the throwaway preview themes used by the editor */
function updatePreviewThemes(draft: ThemerCustomTheme): void {
for (const variant of VARIANTS) {
themeStore.setPreviewTheme(PREVIEW_THEME_NAMES[variant], variant, draft[variant])
}
}
function removePreviewThemes(): void {
for (const variant of VARIANTS) {
themeStore.removePreviewTheme(PREVIEW_THEME_NAMES[variant])
}
}
return {
// State
presets: PRESETS,
isLocked,
mode,
font,
userPresetId,
customTheme,
tenantPresetId,
tenantMode,
activePresetId,
// User actions
setMode,
setPreset,
setFont,
saveCustom,
clearCustom,
// Tenant actions
setTenantDefaultPreset,
setTenantDefaultMode,
setTenantLock,
// Editor support
resolveToken,
createDraft,
updatePreviewThemes,
removePreviewThemes,
}
})
+23
View File
@@ -0,0 +1,23 @@
import type { ThemePalette } from '@KTXC'
export type ThemerVariant = 'light' | 'dark'
export type ThemerMode = 'light' | 'dark' | 'system'
export interface ThemerPreset {
id: string
label: string
/** Catalog key within the themer namespace, e.g. 'presets.emerald' */
l10n?: string
font?: string
/** Sparse overrides over the app-default palettes */
light: ThemePalette
dark: ThemePalette
}
/** The single user-customized theme slot, persisted as the `themer_custom` user setting */
export interface ThemerCustomTheme {
/** Preset id the custom theme was derived from */
base: string
light: ThemePalette
dark: ThemePalette
}
+93
View File
@@ -0,0 +1,93 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { useL10n, useUserStore } from '@KTXC'
import { useThemerStore } from '@/stores/themerStore'
import ModeSelector from '@/components/ModeSelector.vue'
import PresetCard from '@/components/PresetCard.vue'
import { DEFAULT_PRESET_ID } from '@/presets'
const { t } = useL10n('themer')
const userStore = useUserStore()
const themerStore = useThemerStore()
// Server-side the write is enforced by tenant.settings.update; this guard
// only keeps the page from rendering a form that would be rejected.
const canManage = computed(() => userStore.hasPermission('tenant.settings.update'))
const selectedPresetId = computed(() => themerStore.tenantPresetId ?? DEFAULT_PRESET_ID)
function selectPreset(id: string): void {
themerStore.setTenantDefaultPreset(id === DEFAULT_PRESET_ID ? null : id)
}
</script>
<template>
<PerfectScrollbar
class="pa-4"
style="height: calc(100vh - 64px);"
:options="{ wheelPropagation: false }"
>
<h5 class="text-h5 mb-4">{{ t('admin.title', 'Appearance') }}</h5>
<VAlert
v-if="!canManage"
type="warning"
variant="tonal"
:text="t('admin.noPermission', 'You do not have permission to manage tenant appearance settings.')"
/>
<template v-else>
<VRow>
<VCol cols="12" md="6">
<VCard :title="t('admin.modeTitle', 'Default mode')">
<VCardText>
<ModeSelector
:model-value="themerStore.tenantMode ?? 'light'"
@update:model-value="themerStore.setTenantDefaultMode"
/>
<p class="text-caption text-medium-emphasis mt-3 mb-0">
{{ t('admin.modeHint', 'Initial mode for users who have not chosen one themselves.') }}
</p>
</VCardText>
</VCard>
</VCol>
<VCol cols="12" md="6">
<VCard :title="t('admin.lockTitle', 'Enforcement')">
<VCardText>
<VSwitch
:model-value="themerStore.isLocked"
color="primary"
:label="t('admin.lockLabel', 'Lock users to the tenant theme')"
hide-details
@update:model-value="themerStore.setTenantLock($event === true)"
/>
<p class="text-caption text-medium-emphasis mt-3 mb-0">
{{ t('admin.lockHint', 'When locked, users keep their light/dark/system choice but cannot change colors or font.') }}
</p>
</VCardText>
</VCard>
</VCol>
</VRow>
<h6 class="text-h6 mt-6 mb-3">{{ t('admin.presetsTitle', 'Default theme') }}</h6>
<VRow>
<VCol
v-for="preset in themerStore.presets"
:key="preset.id"
cols="12"
sm="6"
md="4"
lg="3"
>
<PresetCard
:preset="preset"
:active="selectedPresetId === preset.id"
@select="selectPreset"
/>
</VCol>
</VRow>
</template>
</PerfectScrollbar>
</template>
+159
View File
@@ -0,0 +1,159 @@
<script lang="ts" setup>
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useL10n, useSnackbar } from '@KTXC'
import { useThemerStore, EDITABLE_TOKENS, PREVIEW_THEME_NAMES } from '@/stores/themerStore'
import ColorField from '@/components/ColorField.vue'
import ThemePreview from '@/components/ThemePreview.vue'
import type { ThemerCustomTheme, ThemerVariant } from '@/types'
const { t } = useL10n('themer')
const router = useRouter()
const { showSnackbar } = useSnackbar()
const themerStore = useThemerStore()
const CORE_TOKENS: ReadonlyArray<(typeof EDITABLE_TOKENS)[number]> = ['primary', 'secondary']
const EXTENDED_TOKENS: ReadonlyArray<(typeof EDITABLE_TOKENS)[number]> =
['background', 'surface', 'error', 'warning', 'info', 'success']
const TOKEN_LABELS: Record<string, { l10n: string; label: string }> = {
primary: { l10n: 'tokens.primary', label: 'Primary' },
secondary: { l10n: 'tokens.secondary', label: 'Secondary' },
background: { l10n: 'tokens.background', label: 'Background' },
surface: { l10n: 'tokens.surface', label: 'Surface' },
error: { l10n: 'tokens.error', label: 'Error' },
warning: { l10n: 'tokens.warning', label: 'Warning' },
info: { l10n: 'tokens.info', label: 'Info' },
success: { l10n: 'tokens.success', label: 'Success' },
}
// Start from the stored custom theme when one exists, else the active preset
const initialBase = themerStore.customTheme ? 'custom' : themerStore.activePresetId
const baseId = ref(initialBase === 'custom' ? themerStore.customTheme!.base : initialBase)
const draft = reactive<ThemerCustomTheme>(themerStore.createDraft(initialBase))
const activeVariant = ref<ThemerVariant>('light')
const previewThemeName = computed(() => PREVIEW_THEME_NAMES[activeVariant.value])
const baseOptions = computed(() =>
themerStore.presets.map(preset => ({ value: preset.id, title: preset.label })),
)
function rebuildFromBase(id: string): void {
const fresh = themerStore.createDraft(id)
draft.base = fresh.base
draft.light = fresh.light
draft.dark = fresh.dark
}
function setToken(variant: ThemerVariant, token: string, value: string): void {
draft[variant].colors[token] = value
// The edited token's stale foreground pair would break contrast; dropping
// it makes the system theming engine regenerate it.
delete draft[variant].colors[`on-${token}`]
}
watch(draft, () => themerStore.updatePreviewThemes(draft), { immediate: true })
onBeforeUnmount(() => {
themerStore.removePreviewThemes()
})
function save(): void {
themerStore.saveCustom(draft)
showSnackbar({ message: t('editor.saved', 'Custom theme saved and applied'), color: 'success' })
router.push({ name: 'themer.settings' })
}
function cancel(): void {
router.push({ name: 'themer.settings' })
}
</script>
<template>
<PerfectScrollbar
class="pa-4"
style="height: calc(100vh - 64px);"
:options="{ wheelPropagation: false }"
>
<div class="d-flex align-center mb-4">
<VBtn icon="mdi-arrow-left" variant="text" size="small" class="mr-2" @click="cancel" />
<h5 class="text-h5 mb-0">{{ t('editor.title', 'Customize Theme') }}</h5>
</div>
<VAlert
v-if="themerStore.isLocked"
type="warning"
variant="tonal"
class="mb-4"
:text="t('editor.lockedNotice', 'Theme selection is managed by your administrator; a saved custom theme will not be applied while the lock is active.')"
/>
<VRow>
<VCol cols="12" md="7">
<VCard>
<VCardText>
<VSelect
v-model="baseId"
:items="baseOptions"
:label="t('editor.baseLabel', 'Start from preset')"
density="comfortable"
class="mb-2"
@update:model-value="rebuildFromBase"
/>
<VTabs v-model="activeVariant" class="v-tabs-pill mb-4">
<VTab value="light">
<VIcon size="18" start>mdi-weather-sunny</VIcon>
{{ t('editor.lightVariant', 'Light') }}
</VTab>
<VTab value="dark">
<VIcon size="18" start>mdi-weather-night</VIcon>
{{ t('editor.darkVariant', 'Dark') }}
</VTab>
</VTabs>
<p class="text-subtitle-2 mb-2">{{ t('editor.coreColors', 'Core colors') }}</p>
<VRow dense>
<VCol v-for="token in CORE_TOKENS" :key="token" cols="12" sm="6">
<ColorField
:label="t(TOKEN_LABELS[token].l10n, TOKEN_LABELS[token].label)"
:model-value="draft[activeVariant].colors[token]"
@update:model-value="setToken(activeVariant, token, $event)"
/>
</VCol>
</VRow>
<p class="text-subtitle-2 mb-2 mt-4">{{ t('editor.extendedColors', 'Extended palette') }}</p>
<VRow dense>
<VCol v-for="token in EXTENDED_TOKENS" :key="token" cols="12" sm="6">
<ColorField
:label="t(TOKEN_LABELS[token].l10n, TOKEN_LABELS[token].label)"
:model-value="draft[activeVariant].colors[token]"
@update:model-value="setToken(activeVariant, token, $event)"
/>
</VCol>
</VRow>
</VCardText>
<VCardActions class="px-4 pb-4">
<VBtn color="primary" variant="flat" prepend-icon="mdi-content-save" @click="save">
{{ t('editor.save', 'Save & Apply') }}
</VBtn>
<VBtn variant="text" @click="rebuildFromBase(baseId)">
{{ t('editor.reset', 'Reset to preset') }}
</VBtn>
<VSpacer />
<VBtn variant="text" @click="cancel">
{{ t('editor.cancel', 'Cancel') }}
</VBtn>
</VCardActions>
</VCard>
</VCol>
<VCol cols="12" md="5">
<ThemePreview :theme-name="previewThemeName" />
</VCol>
</VRow>
</PerfectScrollbar>
</template>
+131
View File
@@ -0,0 +1,131 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useL10n } from '@KTXC'
import { useThemerStore } from '@/stores/themerStore'
import ModeSelector from '@/components/ModeSelector.vue'
import FontSelect from '@/components/FontSelect.vue'
import PresetCard from '@/components/PresetCard.vue'
import type { ThemerPreset } from '@/types'
const { t } = useL10n('themer')
const router = useRouter()
const themerStore = useThemerStore()
const isLocked = computed(() => themerStore.isLocked)
// The stored custom theme rendered as a pseudo-preset for the gallery card
const customPreset = computed<ThemerPreset | null>(() => {
const custom = themerStore.customTheme
if (!custom) return null
return {
id: 'custom',
label: 'Custom',
l10n: 'presets.custom',
light: custom.light,
dark: custom.dark,
}
})
function openEditor(): void {
router.push({ name: 'themer.customize' })
}
</script>
<template>
<PerfectScrollbar
class="pa-4"
style="height: calc(100vh - 64px);"
:options="{ wheelPropagation: false }"
>
<h5 class="text-h5 mb-4">{{ t('settings.title', 'Appearance') }}</h5>
<VAlert
v-if="isLocked"
type="info"
variant="tonal"
class="mb-4"
:text="t('settings.lockedNotice', 'Theme selection is managed by your administrator. You can still choose your preferred mode.')"
/>
<VRow>
<VCol cols="12" md="6">
<VCard :title="t('settings.modeTitle', 'Mode')">
<VCardText>
<ModeSelector
:model-value="themerStore.mode"
@update:model-value="themerStore.setMode"
/>
<p class="text-caption text-medium-emphasis mt-3 mb-0">
{{ t('settings.modeHint', 'System follows your operating system preference.') }}
</p>
</VCardText>
</VCard>
</VCol>
<VCol cols="12" md="6">
<VCard :title="t('settings.fontTitle', 'Font')">
<VCardText>
<FontSelect
:model-value="themerStore.font"
:disabled="isLocked"
@update:model-value="themerStore.setFont"
/>
</VCardText>
</VCard>
</VCol>
</VRow>
<div class="d-flex align-center mt-6 mb-3">
<h6 class="text-h6">{{ t('settings.presetsTitle', 'Themes') }}</h6>
<VSpacer />
<VBtn
v-if="customPreset"
variant="text"
color="error"
prepend-icon="mdi-delete-outline"
class="mr-2"
:disabled="isLocked"
@click="themerStore.clearCustom"
>
{{ t('settings.deleteCustom', 'Delete Custom Theme') }}
</VBtn>
<VBtn
color="primary"
variant="tonal"
prepend-icon="mdi-palette-advanced"
:disabled="isLocked"
@click="openEditor"
>
{{ customPreset ? t('settings.editCustom', 'Edit Custom Theme') : t('settings.createCustom', 'Customize') }}
</VBtn>
</div>
<VRow>
<VCol
v-for="preset in themerStore.presets"
:key="preset.id"
cols="12"
sm="6"
md="4"
lg="3"
>
<PresetCard
:preset="preset"
:active="themerStore.activePresetId === preset.id"
:disabled="isLocked"
@select="themerStore.setPreset"
/>
</VCol>
<VCol v-if="customPreset" cols="12" sm="6" md="4" lg="3">
<PresetCard
:preset="customPreset"
:active="themerStore.activePresetId === 'custom'"
:disabled="isLocked"
@select="themerStore.setPreset"
/>
</VCol>
</VRow>
</PerfectScrollbar>
</template>