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,131 @@
import { DOCUMENT } from '@angular/common';
import {
Injectable,
inject,
signal
} from '@angular/core';
import { SettingsModalService, type SettingsPage } from '../../../../core/services/settings-modal.service';
import { ThemeRegistryService } from './theme-registry.service';
@Injectable({ providedIn: 'root' })
export class ElementPickerService {
readonly isPicking = signal(false);
readonly hoveredKey = signal<string | null>(null);
readonly selectedKey = signal<string | null>(null);
readonly documentRef = inject(DOCUMENT);
readonly modal = inject(SettingsModalService);
readonly registry = inject(ThemeRegistryService);
private removeListeners: (() => void)[] = [];
private resumePage: SettingsPage | null = null;
private shouldRestoreModalOnCancel = true;
start(page: SettingsPage = 'theme', restoreModalOnCancel = true): void {
if (this.isPicking()) {
return;
}
this.resumePage = page;
this.shouldRestoreModalOnCancel = restoreModalOnCancel;
this.modal.close();
this.attachListeners();
this.hoveredKey.set(null);
this.isPicking.set(true);
}
cancel(): void {
if (!this.isPicking()) {
return;
}
this.detachListeners();
this.hoveredKey.set(null);
this.isPicking.set(false);
if (this.shouldRestoreModalOnCancel && this.resumePage) {
this.modal.open(this.resumePage);
}
}
clearSelection(): void {
this.selectedKey.set(null);
}
private completePick(key: string): void {
this.selectedKey.set(key);
this.detachListeners();
this.hoveredKey.set(null);
this.isPicking.set(false);
if (this.resumePage) {
this.modal.open(this.resumePage);
}
}
private attachListeners(): void {
const onPointerMove = (event: Event) => {
const key = this.resolveThemeKeyFromTarget(event.target);
this.hoveredKey.set(key);
};
const onClick = (event: Event) => {
const key = this.resolveThemeKeyFromTarget(event.target);
if (!key) {
return;
}
event.preventDefault();
event.stopPropagation();
if ('stopImmediatePropagation' in event) {
(event as Event & { stopImmediatePropagation(): void }).stopImmediatePropagation();
}
this.completePick(key);
};
const onKeyDown = (event: Event) => {
const keyboardEvent = event as KeyboardEvent;
if (keyboardEvent.key === 'Escape') {
keyboardEvent.preventDefault();
keyboardEvent.stopPropagation();
this.cancel();
}
};
this.documentRef.addEventListener('pointermove', onPointerMove, true);
this.documentRef.addEventListener('click', onClick, true);
this.documentRef.addEventListener('keydown', onKeyDown, true);
this.removeListeners = [
() => this.documentRef.removeEventListener('pointermove', onPointerMove, true),
() => this.documentRef.removeEventListener('click', onClick, true),
() => this.documentRef.removeEventListener('keydown', onKeyDown, true)
];
}
private detachListeners(): void {
for (const removeListener of this.removeListeners) {
removeListener();
}
this.removeListeners = [];
}
private resolveThemeKeyFromTarget(target: EventTarget | null): string | null {
if (!(target instanceof Element)) {
return null;
}
const themedElement = target.closest<HTMLElement>('[data-theme-key]');
const key = themedElement?.dataset['themeKey'] ?? null;
const definition = this.registry.getDefinition(key);
return definition?.pickerVisible
? key
: null;
}
}

View File

@@ -0,0 +1,62 @@
import {
Injectable,
computed,
inject
} from '@angular/core';
import {
ThemeContainerKey,
ThemeGridEditorItem,
ThemeGridRect
} from '../../domain/models/theme.model';
import { createDefaultThemeDocument } from '../../domain/logic/theme-defaults.logic';
import { ThemeRegistryService } from './theme-registry.service';
import { ThemeService } from './theme.service';
@Injectable({ providedIn: 'root' })
export class LayoutSyncService {
readonly draftLayout = computed(() => this.theme.draftTheme().layout);
readonly registry = inject(ThemeRegistryService);
readonly theme = inject(ThemeService);
containers() {
return this.registry.layoutContainers();
}
itemsForContainer(containerKey: ThemeContainerKey): ThemeGridEditorItem[] {
const draftTheme = this.theme.draftTheme();
const defaults = createDefaultThemeDocument();
return this.registry.entries()
.filter((entry) => entry.layoutEditable && entry.container === containerKey)
.map((entry) => ({
key: entry.key,
label: entry.label,
description: entry.description,
grid: draftTheme.layout[entry.key]?.grid ?? defaults.layout[entry.key].grid
}));
}
updateGrid(key: string, grid: ThemeGridRect): void {
this.theme.ensureLayoutEntry(key);
this.theme.updateStructuredDraft((draft: ReturnType<typeof createDefaultThemeDocument>) => {
draft.layout[key] = {
...draft.layout[key],
grid
};
}, true, `${key} layout updated.`);
}
resetContainer(containerKey: ThemeContainerKey): void {
const defaults = createDefaultThemeDocument();
this.theme.updateStructuredDraft((draft: ReturnType<typeof createDefaultThemeDocument>) => {
for (const entry of this.registry.entries()) {
if (entry.container === containerKey && entry.layoutEditable) {
draft.layout[entry.key] = defaults.layout[entry.key];
}
}
}, true, `${containerKey} restored to its default layout.`);
}
}

View File

@@ -0,0 +1,181 @@
import {
Injectable,
computed,
inject,
signal
} from '@angular/core';
import type { SavedThemeSummary } from '../../domain/models/theme.model';
import { ThemeLibraryStorageService } from '../../infrastructure/services/theme-library-storage.service';
import { ThemeService } from './theme.service';
@Injectable({ providedIn: 'root' })
export class ThemeLibraryService {
readonly storage = inject(ThemeLibraryStorageService);
readonly theme = inject(ThemeService);
readonly isAvailable = signal(this.storage.isAvailable);
readonly entries = signal<SavedThemeSummary[]>([]);
readonly selectedFileName = signal<string | null>(null);
readonly isBusy = signal(false);
readonly savedThemesPath = signal<string | null>(null);
readonly selectedEntry = computed(() => {
const selectedFileName = this.selectedFileName();
return selectedFileName
? this.entries().find((entry) => entry.fileName === selectedFileName) ?? null
: null;
});
async refresh(preferredSelection?: string | null): Promise<void> {
if (!this.isAvailable() || this.isBusy()) {
return;
}
this.isBusy.set(true);
try {
await this.refreshEntries(preferredSelection);
} catch {
this.theme.announceStatus('Unable to refresh the saved themes library.');
} finally {
this.isBusy.set(false);
}
}
select(fileName: string | null): void {
this.selectedFileName.set(fileName);
}
async useSelectedTheme(): Promise<boolean> {
const selectedEntry = this.selectedEntry();
if (!selectedEntry || this.isBusy()) {
return false;
}
const themeText = await this.storage.readThemeText(selectedEntry.fileName);
if (!themeText) {
this.theme.announceStatus('Unable to read the selected saved theme.');
return false;
}
return this.theme.loadThemeText(themeText, 'apply', `${selectedEntry.themeName} applied from saved themes.`, 'saved theme');
}
async openSelectedThemeInDraft(): Promise<boolean> {
const selectedEntry = this.selectedEntry();
if (!selectedEntry || this.isBusy()) {
return false;
}
const themeText = await this.storage.readThemeText(selectedEntry.fileName);
if (!themeText) {
this.theme.announceStatus('Unable to read the selected saved theme.');
return false;
}
return this.theme.loadThemeText(themeText, 'draft', `${selectedEntry.themeName} loaded into the draft editor.`, 'saved theme');
}
async saveDraftAsNewTheme(): Promise<string | null> {
if (this.isBusy()) {
return null;
}
if (!this.theme.draftIsValid()) {
this.theme.announceStatus('Fix JSON errors before saving a theme.');
return null;
}
this.isBusy.set(true);
try {
const fileName = await this.storage.saveNewTheme(this.theme.draftTheme().meta.name, this.theme.draftText());
if (!fileName) {
this.theme.announceStatus('Unable to save the current draft as a new theme.');
return null;
}
await this.refreshEntries(fileName);
this.theme.announceStatus(`${this.theme.draftTheme().meta.name} saved to the Electron themes folder.`);
return fileName;
} finally {
this.isBusy.set(false);
}
}
async saveDraftToSelectedTheme(): Promise<boolean> {
const selectedEntry = this.selectedEntry();
if (!selectedEntry || this.isBusy()) {
return false;
}
if (!this.theme.draftIsValid()) {
this.theme.announceStatus('Fix JSON errors before updating a saved theme.');
return false;
}
this.isBusy.set(true);
try {
const saved = await this.storage.overwriteTheme(selectedEntry.fileName, this.theme.draftText());
if (!saved) {
this.theme.announceStatus('Unable to update the selected saved theme.');
return false;
}
await this.refreshEntries(selectedEntry.fileName);
this.theme.announceStatus(`${selectedEntry.themeName} updated in the Electron themes folder.`);
return true;
} finally {
this.isBusy.set(false);
}
}
async removeSelectedTheme(): Promise<boolean> {
const selectedEntry = this.selectedEntry();
if (!selectedEntry || this.isBusy()) {
return false;
}
this.isBusy.set(true);
try {
const deleted = await this.storage.deleteTheme(selectedEntry.fileName);
if (!deleted) {
this.theme.announceStatus('Unable to remove the selected saved theme.');
return false;
}
await this.refreshEntries(null);
this.theme.announceStatus(`${selectedEntry.themeName} removed from saved themes.`);
return true;
} finally {
this.isBusy.set(false);
}
}
private async refreshEntries(preferredSelection?: string | null): Promise<void> {
const entries = await this.storage.listThemes();
const savedThemesPath = await this.storage.getSavedThemesPath();
const nextSelection = preferredSelection ?? this.selectedFileName();
this.entries.set(entries);
this.savedThemesPath.set(savedThemesPath);
if (nextSelection && entries.some((entry) => entry.fileName === nextSelection)) {
this.selectedFileName.set(nextSelection);
return;
}
this.selectedFileName.set(entries[0]?.fileName ?? null);
}
}

View File

@@ -0,0 +1,85 @@
import { Injectable, signal } from '@angular/core';
import { ThemeLayoutContainerDefinition, ThemeRegistryEntry } from '../../domain/models/theme.model';
import {
THEME_LAYOUT_CONTAINERS,
THEME_REGISTRY,
findThemeLayoutContainer,
findThemeRegistryEntry
} from '../../domain/logic/theme-registry.logic';
@Injectable({ providedIn: 'root' })
export class ThemeRegistryService {
private readonly mountedCounts = signal<Record<string, number>>({});
private readonly mountedHosts = new Map<string, Set<HTMLElement>>();
entries(): readonly ThemeRegistryEntry[] {
return THEME_REGISTRY;
}
layoutContainers(): readonly ThemeLayoutContainerDefinition[] {
return THEME_LAYOUT_CONTAINERS;
}
mountedKeyCounts() {
return this.mountedCounts();
}
getDefinition(key: string | null | undefined): ThemeRegistryEntry | null {
return key
? findThemeRegistryEntry(key)
: null;
}
getContainer(key: string | null | undefined): ThemeLayoutContainerDefinition | null {
return key
? findThemeLayoutContainer(key)
: null;
}
registerHost(key: string, host: HTMLElement): void {
const existingHosts = this.mountedHosts.get(key) ?? new Set<HTMLElement>();
existingHosts.add(host);
this.mountedHosts.set(key, existingHosts);
this.syncMountedCounts();
}
unregisterHost(key: string, host: HTMLElement): void {
const existingHosts = this.mountedHosts.get(key);
if (!existingHosts) {
return;
}
existingHosts.delete(host);
if (existingHosts.size === 0) {
this.mountedHosts.delete(key);
}
this.syncMountedCounts();
}
firstMountedHost(key: string): HTMLElement | null {
const hosts = this.mountedHosts.get(key);
return hosts
? Array.from(hosts)[0] ?? null
: null;
}
isMounted(key: string): boolean {
return (this.mountedCounts()[key] ?? 0) > 0;
}
private syncMountedCounts(): void {
const nextCounts: Record<string, number> = {};
for (const [key, hosts] of this.mountedHosts.entries()) {
nextCounts[key] = hosts.size;
}
this.mountedCounts.set(nextCounts);
}
}

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);
}
}