feat: add theme selection and customization #1

Merged
Sebastian merged 1 commits from feat/themer-module into main 2026-07-08 03:00:51 +00:00
26 changed files with 3485 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
# Frontend development
node_modules/
*.local
.env.local
.env.*.local
.cache/
.vite/
.temp/
.tmp/
# Frontend build
/static/
# Backend development
/lib/vendor/
coverage/
phpunit.xml.cache
.phpunit.result.cache
.php-cs-fixer.cache
.phpstan.cache
.phpactor/
# Editors
.DS_Store
.vscode/
.idea/
# Logs
*.log
+9
View File
@@ -0,0 +1,9 @@
{
"name": "ktxm/themer",
"type": "project",
"autoload": {
"psr-4": {
"KTXM\\Themer\\": "lib/"
}
}
}
+80
View File
@@ -0,0 +1,80 @@
{
"admin": {
"lockHint": "When locked, users keep their light/dark/system choice but cannot change colors or font.",
"lockLabel": "Lock users to the tenant theme",
"lockTitle": "Enforcement",
"modeHint": "Initial mode for users who have not chosen one themselves.",
"modeTitle": "Default mode",
"noPermission": "You do not have permission to manage tenant appearance settings.",
"presetsTitle": "Default theme",
"title": "Appearance"
},
"editor": {
"baseLabel": "Start from preset",
"cancel": "Cancel",
"coreColors": "Core colors",
"darkVariant": "Dark",
"extendedColors": "Extended palette",
"lightVariant": "Light",
"lockedNotice": "Theme selection is managed by your administrator; a saved custom theme will not be applied while the lock is active.",
"previewAlert": "Informational alert on the themed background.",
"previewCardText": "Body text on a surface, with medium emphasis details.",
"previewCardTitle": "Sample card",
"previewError": "Error",
"previewField": "Text field",
"previewInfo": "Info",
"previewOutlined": "Outlined",
"previewPrimary": "Primary",
"previewSecondary": "Secondary",
"previewSuccess": "Success",
"previewSwitch": "Switch",
"previewTitle": "Preview",
"previewWarning": "Warning",
"reset": "Reset to preset",
"save": "Save & Apply",
"saved": "Custom theme saved and applied",
"title": "Customize Theme"
},
"font": {
"default": "Default",
"label": "Font family"
},
"mode": {
"dark": "Dark",
"light": "Light",
"system": "System"
},
"nav": {
"appearance": "Appearance"
},
"presets": {
"amber": "Amber",
"custom": "Custom",
"default": "Indigo",
"emerald": "Emerald",
"graphite": "Graphite",
"ocean": "Ocean",
"rose": "Rose"
},
"settings": {
"createCustom": "Customize",
"deleteCustom": "Delete Custom Theme",
"editCustom": "Edit Custom Theme",
"fontTitle": "Font",
"lockedNotice": "Theme selection is managed by your administrator. You can still choose your preferred mode.",
"modeHint": "System follows your operating system preference.",
"modeTitle": "Mode",
"presetsTitle": "Themes",
"title": "Appearance"
},
"tokens": {
"background": "Background",
"error": "Error",
"info": "Info",
"primary": "Primary",
"secondary": "Secondary",
"success": "Success",
"surface": "Surface",
"warning": "Warning"
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"nav": {
"appearance": "Appearance"
},
"mode": {
"light": "Light",
"dark": "Dark",
"system": "System"
},
"presets": {
"default": "Indigo",
"emerald": "Emerald",
"rose": "Rose",
"amber": "Amber",
"ocean": "Ocean",
"graphite": "Graphite",
"custom": "Custom"
},
"tokens": {
"primary": "Primary",
"secondary": "Secondary",
"background": "Background",
"surface": "Surface",
"error": "Error",
"warning": "Warning",
"info": "Info",
"success": "Success"
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace KTXM\Themer;
use KTXF\Module\ModuleBrowserInterface;
use KTXF\Module\ModuleInstanceAbstract;
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
{
public function __construct()
{ }
public function handle(): string
{
return 'themer';
}
public function label(): string
{
return 'Appearance';
}
public function author(): string
{
return 'Ktrix';
}
public function description(): string
{
return 'Theme selection and customization for users and tenants.';
}
public function version(): string
{
return '0.0.1';
}
public function permissions(): array
{
return [
'themer' => [
'label' => 'Access Appearance Settings',
'description' => 'View and access the appearance settings module',
'group' => 'Themer'
],
];
}
public function registerBI(): array
{
return [
'handle' => $this->handle(),
'namespace' => 'Themer',
'version' => $this->version(),
'label' => $this->label(),
'author' => $this->author(),
'description' => $this->description(),
'boot' => 'static/module.mjs',
];
}
}
+1910
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "themer",
"description": "Ktrix Themer Module - Theme Selection and Customization",
"version": "1.0.0",
"private": true,
"license": "AGPL-3.0-or-later",
"author": "Ktrix",
"type": "module",
"scripts": {
"build": "vite build --mode production --config vite.config.ts",
"dev": "vite build --mode development --config vite.config.ts",
"watch": "vite build --mode development --watch --config vite.config.ts",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"dependencies": {
"@mdi/js": "^7.4.47",
"pinia": "^3.0.0",
"vue": "^3.5.18",
"vue-router": "^5.0.0",
"vuetify": "^4.0.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.9.0",
"typescript": "~6.0.0",
"vite": "^8.0.0",
"vue-tsc": "^3.0.5"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"enabledManagers": ["npm", "composer", "github-actions"],
"timezone": "UTC",
"schedule": ["* 0-3 * * *"],
"dependencyDashboard": true,
"prConcurrentLimit": 5,
"prHourlyLimit": 2
}
+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>
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": {
"@/*": ["./src/*"],
"@KTXC/*": ["../../core/src/*"]
}
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+66
View File
@@ -0,0 +1,66 @@
import { defineConfig, type PluginOption } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
import fs from 'fs'
// Copy translation catalogs (l10n/*.json, except the extractor-only
// en.manual.json) into static/l10n/ so the core shell can fetch them at
// /modules/{handle}/static/l10n/{locale}.json
const copyL10nCatalogs = (): PluginOption => ({
name: 'copy-l10n-catalogs',
apply: 'build',
closeBundle() {
const sourceDir = path.resolve(__dirname, 'l10n')
const outDir = path.resolve(__dirname, 'static/l10n')
if (!fs.existsSync(sourceDir)) return
fs.mkdirSync(outDir, { recursive: true })
for (const file of fs.readdirSync(sourceDir)) {
if (file.endsWith('.json') && !file.endsWith('.manual.json')) {
fs.copyFileSync(path.join(sourceDir, file), path.join(outDir, file))
}
}
},
})
// Rewrite the __CSS_FILENAME_PLACEHOLDER__ export in main.ts with the actual
// hashed stylesheet name, so the core loader can inject it
const injectCssFilename = (): PluginOption => ({
name: 'inject-css-filename',
enforce: 'post',
generateBundle(_options, bundle) {
const cssFile = Object.keys(bundle).find(name => name.endsWith('.css'))
if (!cssFile) return
for (const fileName of Object.keys(bundle)) {
const chunk = bundle[fileName]
if (chunk.type === 'chunk' && chunk.code.includes('__CSS_FILENAME_PLACEHOLDER__')) {
chunk.code = chunk.code.replace(/__CSS_FILENAME_PLACEHOLDER__/g, `static/${cssFile}`)
}
}
},
})
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), injectCssFilename(), copyL10nCatalogs()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@KTXC': path.resolve(__dirname, '../../core/src')
},
},
build: {
outDir: 'static',
sourcemap: true,
lib: {
entry: path.resolve(__dirname, 'src/main.ts'),
formats: ['es'],
fileName: () => 'module.mjs',
},
rollupOptions: {
external: ['vue', 'vue-router', 'pinia', '@KTXC'],
output: {
paths: (id) => (id === '@KTXC' ? '/js/ktxc.mjs' : id),
},
},
},
})