refactor: stricter domain: theme

This commit is contained in:
2026-04-11 15:01:39 +02:00
parent c8bb82feb5
commit cea3dccef1
19 changed files with 143 additions and 52 deletions

View File

@@ -0,0 +1,540 @@
import { DOCUMENT } from '@angular/common';
import {
Injectable,
Signal,
computed,
inject,
signal
} from '@angular/core';
import {
ThemeAnimationDefinition,
ThemeContainerKey,
ThemeDocument,
ThemeElementStyleProperty,
ThemeElementStyles
} from '../../domain/models/theme.model';
import {
DEFAULT_THEME_JSON,
createDefaultThemeDocument,
isLegacyDefaultThemeDocument
} from '../../domain/logic/theme-defaults.logic';
import { createAnimationStarterDefinition } from '../../domain/logic/theme-schema.logic';
import { findThemeLayoutContainer } from '../../domain/logic/theme-registry.logic';
import { validateThemeDocument } from '../../domain/logic/theme-validation.logic';
import {
loadThemeStorageSnapshot,
saveActiveThemeText,
saveDraftThemeText
} from '../../infrastructure/util/theme-storage.util';
function toKebabCase(value: string): string {
return value
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.replace(/[_\s]+/g, '-')
.toLowerCase();
}
function stringifyTheme(document: ThemeDocument): string {
return JSON.stringify(document, null, 2);
}
function resolveBuiltInDefaultMigration(document: ThemeDocument): ThemeDocument {
return isLegacyDefaultThemeDocument(document)
? createDefaultThemeDocument()
: document;
}
const hostStylePropertyKeys = [
'width',
'height',
'minWidth',
'minHeight',
'maxWidth',
'maxHeight',
'position',
'top',
'right',
'bottom',
'left',
'padding',
'margin',
'border',
'borderRadius',
'backgroundColor',
'color',
'backgroundSize',
'backgroundPosition',
'backgroundRepeat',
'boxShadow',
'backdropFilter'
] as const satisfies readonly (keyof ThemeElementStyles)[];
@Injectable({ providedIn: 'root' })
export class ThemeService {
readonly activeTheme: Signal<ThemeDocument>;
readonly activeThemeText: Signal<string>;
readonly draftTheme: Signal<ThemeDocument>;
readonly draftText: Signal<string>;
readonly draftIsValid: Signal<boolean>;
readonly draftErrors: Signal<string[]>;
readonly statusMessage: Signal<string | null>;
readonly activeThemeName: Signal<string>;
readonly knownAnimationClasses: Signal<string[]>;
readonly isDraftDirty: Signal<boolean>;
private readonly documentRef = inject(DOCUMENT);
private readonly activeThemeInternal = signal<ThemeDocument>(createDefaultThemeDocument());
private readonly activeThemeTextInternal = signal(DEFAULT_THEME_JSON);
private readonly draftThemeInternal = signal<ThemeDocument>(createDefaultThemeDocument());
private readonly draftTextInternal = signal(DEFAULT_THEME_JSON);
private readonly draftIsValidInternal = signal(true);
private readonly draftErrorsInternal = signal<string[]>([]);
private readonly statusMessageInternal = signal<string | null>(null);
private initialized = false;
private statusTimeoutId: ReturnType<typeof setTimeout> | null = null;
private animationStyleElement: HTMLStyleElement | null = null;
constructor() {
this.activeTheme = this.activeThemeInternal.asReadonly();
this.activeThemeText = this.activeThemeTextInternal.asReadonly();
this.draftTheme = this.draftThemeInternal.asReadonly();
this.draftText = this.draftTextInternal.asReadonly();
this.draftIsValid = this.draftIsValidInternal.asReadonly();
this.draftErrors = this.draftErrorsInternal.asReadonly();
this.statusMessage = this.statusMessageInternal.asReadonly();
this.activeThemeName = computed(() => this.activeThemeInternal().meta.name);
this.knownAnimationClasses = computed(() => Object.keys(this.draftThemeInternal().animations));
this.isDraftDirty = computed(() => {
return this.draftTextInternal().trim() !== this.activeThemeTextInternal().trim();
});
}
initialize(): void {
if (this.initialized) {
return;
}
this.initialized = true;
const storageSnapshot = loadThemeStorageSnapshot();
const activeText = storageSnapshot.activeText ?? DEFAULT_THEME_JSON;
const activeResult = this.parseAndValidateTheme(activeText, 'saved active theme');
if (activeResult.valid && activeResult.value) {
const resolvedTheme = resolveBuiltInDefaultMigration(activeResult.value);
const formatted = stringifyTheme(resolvedTheme);
this.activeThemeInternal.set(resolvedTheme);
this.activeThemeTextInternal.set(formatted);
saveActiveThemeText(formatted);
} else {
const defaultTheme = createDefaultThemeDocument();
const defaultText = stringifyTheme(defaultTheme);
this.activeThemeInternal.set(defaultTheme);
this.activeThemeTextInternal.set(defaultText);
saveActiveThemeText(defaultText);
}
const draftText = storageSnapshot.draftText ?? this.activeThemeTextInternal();
const draftResult = this.parseAndValidateTheme(draftText, 'saved draft theme');
if (draftResult.valid && draftResult.value) {
const resolvedDraftTheme = resolveBuiltInDefaultMigration(draftResult.value);
const formattedDraft = stringifyTheme(resolvedDraftTheme);
this.draftThemeInternal.set(resolvedDraftTheme);
this.draftTextInternal.set(formattedDraft);
this.draftIsValidInternal.set(true);
this.draftErrorsInternal.set([]);
saveDraftThemeText(formattedDraft);
} else {
this.draftThemeInternal.set(this.activeThemeInternal());
this.draftTextInternal.set(this.activeThemeTextInternal());
this.draftIsValidInternal.set(false);
this.draftErrorsInternal.set(draftResult.errors);
}
this.syncAnimationStylesheet();
}
updateDraftText(text: string): void {
this.draftTextInternal.set(text);
saveDraftThemeText(text);
const result = this.parseAndValidateTheme(text, 'theme draft');
if (result.valid && result.value) {
this.draftThemeInternal.set(result.value);
this.draftIsValidInternal.set(true);
this.draftErrorsInternal.set([]);
return;
}
this.draftIsValidInternal.set(false);
this.draftErrorsInternal.set(result.errors);
}
formatDraft(): void {
if (!this.draftIsValidInternal()) {
this.setStatusMessage('Fix JSON errors before formatting the theme draft.');
return;
}
const formatted = stringifyTheme(this.draftThemeInternal());
this.draftTextInternal.set(formatted);
saveDraftThemeText(formatted);
this.setStatusMessage('Theme draft formatted.');
}
applyDraft(): boolean {
if (!this.draftIsValidInternal()) {
this.setStatusMessage('The current draft has validation errors. The previous working theme is still active.');
return false;
}
const formatted = stringifyTheme(this.draftThemeInternal());
this.commitTheme(this.draftThemeInternal(), formatted, 'Theme applied.');
return true;
}
loadThemeText(
text: string,
mode: 'draft' | 'apply',
successMessage: string,
sourceLabel = 'theme'
): boolean {
const result = this.parseAndValidateTheme(text, sourceLabel);
if (!result.valid || !result.value) {
this.setStatusMessage(result.errors[0] ?? `The ${sourceLabel} could not be loaded.`);
return false;
}
const resolvedTheme = resolveBuiltInDefaultMigration(result.value);
const formatted = stringifyTheme(resolvedTheme);
if (mode === 'apply') {
this.commitTheme(resolvedTheme, formatted, successMessage);
return true;
}
this.draftThemeInternal.set(resolvedTheme);
this.draftTextInternal.set(formatted);
this.draftIsValidInternal.set(true);
this.draftErrorsInternal.set([]);
saveDraftThemeText(formatted);
this.setStatusMessage(successMessage);
return true;
}
announceStatus(message: string): void {
this.setStatusMessage(message);
}
resetToDefault(reason: 'button' | 'shortcut' = 'button'): void {
const defaultTheme = createDefaultThemeDocument();
const defaultText = stringifyTheme(defaultTheme);
this.activeThemeInternal.set(defaultTheme);
this.activeThemeTextInternal.set(defaultText);
this.draftThemeInternal.set(defaultTheme);
this.draftTextInternal.set(defaultText);
this.draftIsValidInternal.set(true);
this.draftErrorsInternal.set([]);
saveActiveThemeText(defaultText);
saveDraftThemeText(defaultText);
this.syncAnimationStylesheet();
this.setStatusMessage(reason === 'shortcut'
? 'Theme reset to the default preset by shortcut.'
: 'Theme reset to the default preset.');
}
handleGlobalShortcut(event: KeyboardEvent): boolean {
const usesModifier = event.ctrlKey || event.metaKey;
if (!usesModifier || !event.shiftKey || event.code !== 'Digit0') {
return false;
}
event.preventDefault();
this.resetToDefault('shortcut');
return true;
}
ensureElementEntry(key: string): void {
this.updateStructuredDraft((draft) => {
draft.elements[key] = draft.elements[key] ?? {};
}, false, `Prepared ${key} in the theme draft.`);
}
ensureLayoutEntry(key: string): void {
this.updateStructuredDraft((draft) => {
const defaults = createDefaultThemeDocument();
draft.layout[key] = draft.layout[key] ?? defaults.layout[key];
}, false, `Prepared ${key} layout in the theme draft.`);
}
setElementStyle(
key: string,
property: ThemeElementStyleProperty,
value: string | number,
applyImmediately = true
): void {
this.updateStructuredDraft((draft) => {
draft.elements[key] = {
...draft.elements[key],
[property]: value
};
}, applyImmediately, `${key} updated.`);
}
setAnimation(
key: string,
definition: ThemeAnimationDefinition = createAnimationStarterDefinition(),
applyImmediately = true
): void {
this.updateStructuredDraft((draft) => {
draft.animations[key] = definition;
}, applyImmediately, `Animation ${key} updated.`);
}
getHostStyles(key: string): Record<string, string> {
const elementTheme = this.activeThemeInternal().elements[key] ?? {};
const styles: Record<string, string> = {};
if (key === 'appRoot') {
Object.assign(styles, this.buildTokenStyles(this.activeThemeInternal()));
}
const backgroundLayers = [elementTheme.gradient, elementTheme.backgroundImage]
.filter((layer): layer is string => typeof layer === 'string' && layer.trim().length > 0);
if (backgroundLayers.length > 0) {
styles['backgroundImage'] = backgroundLayers.join(', ');
}
for (const property of hostStylePropertyKeys) {
const value = elementTheme[property];
if (value) {
styles[property] = value;
}
}
if (typeof elementTheme.opacity === 'number') {
styles['opacity'] = `${elementTheme.opacity}`;
}
return styles;
}
getAnimationClass(key: string): string | null {
const animationClass = this.activeThemeInternal().elements[key]?.animationClass?.trim();
return animationClass && animationClass.length > 0
? animationClass
: null;
}
getLink(key: string): string | null {
return this.activeThemeInternal().elements[key]?.link ?? null;
}
getTextOverride(key: string): string | null {
return this.activeThemeInternal().elements[key]?.textOverride ?? null;
}
getIcon(key: string): string | null {
return this.activeThemeInternal().elements[key]?.icon ?? null;
}
getLayoutContainerStyles(containerKey: ThemeContainerKey): Record<string, string> {
const container = findThemeLayoutContainer(containerKey);
if (!container) {
return {
display: 'grid'
};
}
return {
display: 'grid',
gridTemplateColumns: container.templateColumns ?? `repeat(${container.columns}, minmax(0, 1fr))`,
gridTemplateRows: container.templateRows ?? (container.rows === 1
? 'minmax(0, 1fr)'
: `repeat(${container.rows}, minmax(0, 1fr))`),
minHeight: '0',
minWidth: '0'
};
}
getLayoutItemStyles(key: string): Record<string, string> {
const defaults = createDefaultThemeDocument();
const layoutEntry = this.activeThemeInternal().layout[key] ?? defaults.layout[key];
if (!layoutEntry) {
return {};
}
return {
gridColumn: `${layoutEntry.grid.x + 1} / span ${layoutEntry.grid.w}`,
gridRow: `${layoutEntry.grid.y + 1} / span ${layoutEntry.grid.h}`,
minWidth: '0',
minHeight: '0'
};
}
updateStructuredDraft(
mutator: (draft: ThemeDocument) => void,
applyImmediately: boolean,
successMessage: string
): void {
if (!this.draftIsValidInternal()) {
this.setStatusMessage('Fix JSON errors before using the structured theme tools.');
return;
}
const nextDraft = structuredClone(this.draftThemeInternal());
mutator(nextDraft);
const result = validateThemeDocument(nextDraft);
if (!result.valid || !result.value) {
this.setStatusMessage('The structured change could not be validated.');
return;
}
const formatted = stringifyTheme(result.value);
this.draftThemeInternal.set(result.value);
this.draftTextInternal.set(formatted);
this.draftIsValidInternal.set(true);
this.draftErrorsInternal.set([]);
saveDraftThemeText(formatted);
if (applyImmediately) {
this.commitTheme(result.value, formatted, successMessage);
return;
}
this.setStatusMessage(successMessage);
}
private commitTheme(theme: ThemeDocument, text: string, successMessage: string): void {
this.activeThemeInternal.set(theme);
this.activeThemeTextInternal.set(text);
this.draftThemeInternal.set(theme);
this.draftTextInternal.set(text);
this.draftIsValidInternal.set(true);
this.draftErrorsInternal.set([]);
saveActiveThemeText(text);
saveDraftThemeText(text);
this.syncAnimationStylesheet();
this.setStatusMessage(successMessage);
}
private parseAndValidateTheme(text: string, label: string) {
try {
return validateThemeDocument(JSON.parse(text) as unknown);
} catch (error) {
return {
valid: false,
errors: [`${label} could not be parsed: ${error instanceof Error ? error.message : 'unknown JSON error'}`],
value: null
};
}
}
private buildTokenStyles(theme: ThemeDocument): Record<string, string> {
const styles: Record<string, string> = {};
for (const [tokenName, tokenValue] of Object.entries(theme.tokens.colors)) {
styles[`--${toKebabCase(tokenName)}`] = tokenValue;
}
for (const [tokenName, tokenValue] of Object.entries(theme.tokens.spacing)) {
styles[`--theme-spacing-${toKebabCase(tokenName)}`] = tokenValue;
}
for (const [tokenName, tokenValue] of Object.entries(theme.tokens.radii)) {
const cssVariableName = tokenName === 'radius'
? '--radius'
: `--theme-radius-${toKebabCase(tokenName)}`;
styles[cssVariableName] = tokenValue;
}
for (const [tokenName, tokenValue] of Object.entries(theme.tokens.effects)) {
styles[`--theme-effect-${toKebabCase(tokenName)}`] = tokenValue;
}
return styles;
}
private syncAnimationStylesheet(): void {
const theme = this.activeThemeInternal();
const css = Object.entries(theme.animations)
.map(([className, definition]) => this.buildAnimationRule(className, definition))
.filter((rule) => rule.length > 0)
.join('\n\n');
if (!this.animationStyleElement) {
this.animationStyleElement = this.documentRef.createElement('style');
this.animationStyleElement.setAttribute('data-toju-theme-animations', 'true');
this.documentRef.head.appendChild(this.animationStyleElement);
}
this.animationStyleElement.textContent = css;
}
private buildAnimationRule(className: string, definition: ThemeAnimationDefinition): string {
const animationClass = `.${className}`;
const declarationLines = [
`animation-name: ${className};`,
`animation-duration: ${definition.duration ?? '240ms'};`,
`animation-timing-function: ${definition.easing ?? 'ease'};`,
`animation-delay: ${definition.delay ?? '0ms'};`,
`animation-iteration-count: ${definition.iterationCount ?? '1'};`,
`animation-fill-mode: ${definition.fillMode ?? 'both'};`,
`animation-direction: ${definition.direction ?? 'normal'};`
];
const classRule = `${animationClass} {\n ${declarationLines.join('\n ')}\n}`;
if (!definition.keyframes || Object.keys(definition.keyframes).length === 0) {
return classRule;
}
const keyframeRule = `@keyframes ${className} {\n${Object.entries(definition.keyframes)
.map(([step, declarations]) => {
const lines = Object.entries(declarations)
.map(([property, value]) => ` ${toKebabCase(property)}: ${value};`)
.join('\n');
return ` ${step} {\n${lines}\n }`;
})
.join('\n')}\n}`;
return `${keyframeRule}\n\n${classRule}`;
}
private setStatusMessage(message: string): void {
this.statusMessageInternal.set(message);
if (this.statusTimeoutId) {
clearTimeout(this.statusTimeoutId);
}
this.statusTimeoutId = setTimeout(() => {
this.statusMessageInternal.set(null);
this.statusTimeoutId = null;
}, 5000);
}
}