feat: Theme studio v2

This commit is contained in:
2026-04-27 03:02:13 +02:00
parent 11c2588e45
commit 1b91eacb5b
52 changed files with 2792 additions and 844 deletions

View File

@@ -17,6 +17,7 @@ import {
import {
DEFAULT_THEME_JSON,
createDefaultThemeDocument,
createDefaultThemeLayout,
isLegacyDefaultThemeDocument
} from '../../domain/logic/theme-defaults.logic';
import { createAnimationStarterDefinition } from '../../domain/logic/theme-schema.logic';
@@ -35,14 +36,68 @@ function toKebabCase(value: string): string {
.toLowerCase();
}
function isEmptyRecord(value: Record<string, unknown>): boolean {
return Object.keys(value).length === 0;
}
function hasTokenOverrides(document: ThemeDocument): boolean {
return (
!isEmptyRecord(document.tokens.colors) ||
!isEmptyRecord(document.tokens.spacing) ||
!isEmptyRecord(document.tokens.radii) ||
!isEmptyRecord(document.tokens.effects)
);
}
function compactThemeElements(elements: ThemeDocument['elements']): ThemeDocument['elements'] {
return Object.fromEntries(Object.entries(elements).filter(([_key, styles]) => !isEmptyRecord(styles as Record<string, unknown>)));
}
function stringifyTheme(document: ThemeDocument): string {
return JSON.stringify(document, null, 2);
const jsonDocument: Partial<ThemeDocument> = {
meta: document.meta
};
const compactElements = compactThemeElements(document.elements);
if (document.css.trim().length > 0) {
jsonDocument.css = document.css;
}
if (hasTokenOverrides(document)) {
jsonDocument.tokens = document.tokens;
}
if (!isEmptyRecord(document.layout)) {
jsonDocument.layout = document.layout;
}
if (!isEmptyRecord(compactElements)) {
jsonDocument.elements = compactElements;
}
if (!isEmptyRecord(document.animations)) {
jsonDocument.animations = document.animations;
}
return JSON.stringify(jsonDocument, null, 2);
}
function looksLikeImageReference(value: string): boolean {
return value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/') || value.startsWith('./') || value.startsWith('../');
}
function normalizeBackgroundImageLayer(value: string): string {
const trimmedValue = value.trim();
if (!looksLikeImageReference(trimmedValue)) {
return trimmedValue;
}
return `url("${trimmedValue.replace(/"/g, '\\"')}")`;
}
function resolveBuiltInDefaultMigration(document: ThemeDocument): ThemeDocument {
return isLegacyDefaultThemeDocument(document)
? createDefaultThemeDocument()
: document;
return isLegacyDefaultThemeDocument(document) ? createDefaultThemeDocument() : document;
}
const hostStylePropertyKeys = [
@@ -96,6 +151,7 @@ export class ThemeService {
private initialized = false;
private statusTimeoutId: ReturnType<typeof setTimeout> | null = null;
private animationStyleElement: HTMLStyleElement | null = null;
private cssStyleElement: HTMLStyleElement | null = null;
constructor() {
this.activeTheme = this.activeThemeInternal.asReadonly();
@@ -159,6 +215,7 @@ export class ThemeService {
}
this.syncAnimationStylesheet();
this.syncCssStylesheet();
}
updateDraftText(text: string): void {
@@ -203,12 +260,26 @@ export class ThemeService {
return true;
}
loadThemeText(
text: string,
mode: 'draft' | 'apply',
successMessage: string,
sourceLabel = 'theme'
): boolean {
buildDraftTextWithCss(css: string): string | null {
const theme = this.composeDraftThemeWithCss(css);
return theme ? stringifyTheme(theme) : null;
}
applyCssOnlyTheme(css: string): boolean {
const theme = this.composeDraftThemeWithCss(css);
if (!theme) {
return false;
}
const formatted = stringifyTheme(theme);
this.commitTheme(theme, formatted, 'CSS applied over the JSON theme.');
return true;
}
loadThemeText(text: string, mode: 'draft' | 'apply', successMessage: string, sourceLabel = 'theme'): boolean {
const result = this.parseAndValidateTheme(text, sourceLabel);
if (!result.valid || !result.value) {
@@ -250,9 +321,8 @@ export class ThemeService {
saveActiveThemeText(defaultText);
saveDraftThemeText(defaultText);
this.syncAnimationStylesheet();
this.setStatusMessage(reason === 'shortcut'
? 'Theme reset to the default preset by shortcut.'
: 'Theme reset to the default preset.');
this.syncCssStylesheet();
this.setStatusMessage(reason === 'shortcut' ? 'Theme reset to the default preset by shortcut.' : 'Theme reset to the default preset.');
}
handleGlobalShortcut(event: KeyboardEvent): boolean {
@@ -268,41 +338,71 @@ export class ThemeService {
}
ensureElementEntry(key: string): void {
this.updateStructuredDraft((draft) => {
draft.elements[key] = draft.elements[key] ?? {};
}, false, `Prepared ${key} in the theme draft.`);
if (!this.draftIsValidInternal()) {
this.setStatusMessage('Fix JSON errors before using the structured theme tools.');
return;
}
const draftJson = JSON.parse(this.draftTextInternal()) as Record<string, unknown>;
const existingElements = draftJson['elements'];
const elements =
existingElements && typeof existingElements === 'object' && !Array.isArray(existingElements)
? { ...(existingElements as Record<string, unknown>) }
: {};
elements[key] = elements[key] ?? {};
draftJson['elements'] = elements;
const result = validateThemeDocument(draftJson);
if (!result.valid || !result.value) {
this.setStatusMessage('The structured change could not be validated.');
return;
}
const formatted = JSON.stringify(draftJson, null, 2);
this.draftThemeInternal.set(result.value);
this.draftTextInternal.set(formatted);
this.draftIsValidInternal.set(true);
this.draftErrorsInternal.set([]);
saveDraftThemeText(formatted);
this.setStatusMessage(`Prepared ${key} in the theme draft.`);
}
ensureLayoutEntry(key: string): void {
this.updateStructuredDraft((draft) => {
const defaults = createDefaultThemeDocument();
this.updateStructuredDraft(
(draft) => {
const defaults = createDefaultThemeLayout();
draft.layout[key] = draft.layout[key] ?? defaults.layout[key];
}, false, `Prepared ${key} layout in the theme draft.`);
draft.layout[key] = draft.layout[key] ?? defaults[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.`);
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.`);
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> {
@@ -314,7 +414,8 @@ export class ThemeService {
}
const backgroundLayers = [elementTheme.gradient, elementTheme.backgroundImage]
.filter((layer): layer is string => typeof layer === 'string' && layer.trim().length > 0);
.filter((layer): layer is string => typeof layer === 'string' && layer.trim().length > 0)
.map((layer) => normalizeBackgroundImageLayer(layer));
if (backgroundLayers.length > 0) {
styles['backgroundImage'] = backgroundLayers.join(', ');
@@ -338,9 +439,7 @@ export class ThemeService {
getAnimationClass(key: string): string | null {
const animationClass = this.activeThemeInternal().elements[key]?.animationClass?.trim();
return animationClass && animationClass.length > 0
? animationClass
: null;
return animationClass && animationClass.length > 0 ? animationClass : null;
}
getLink(key: string): string | null {
@@ -367,17 +466,15 @@ export class ThemeService {
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))`),
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];
const defaults = createDefaultThemeLayout();
const layoutEntry = this.activeThemeInternal().layout[key] ?? defaults[key];
if (!layoutEntry) {
return {};
@@ -391,11 +488,7 @@ export class ThemeService {
};
}
updateStructuredDraft(
mutator: (draft: ThemeDocument) => void,
applyImmediately: boolean,
successMessage: string
): void {
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;
@@ -438,9 +531,30 @@ export class ThemeService {
saveActiveThemeText(text);
saveDraftThemeText(text);
this.syncAnimationStylesheet();
this.syncCssStylesheet();
this.setStatusMessage(successMessage);
}
private composeDraftThemeWithCss(css: string): ThemeDocument | null {
if (!this.draftIsValidInternal()) {
this.setStatusMessage('Fix JSON errors before applying CSS over the theme draft.');
return null;
}
const theme = {
...structuredClone(this.draftThemeInternal()),
css
};
const result = validateThemeDocument(theme);
if (!result.valid || !result.value) {
this.setStatusMessage(result.errors[0] ?? 'The CSS-only theme could not be applied.');
return null;
}
return result.value;
}
private parseAndValidateTheme(text: string, label: string) {
try {
return validateThemeDocument(JSON.parse(text) as unknown);
@@ -465,9 +579,7 @@ export class ThemeService {
}
for (const [tokenName, tokenValue] of Object.entries(theme.tokens.radii)) {
const cssVariableName = tokenName === 'radius'
? '--radius'
: `--theme-radius-${toKebabCase(tokenName)}`;
const cssVariableName = tokenName === 'radius' ? '--radius' : `--theme-radius-${toKebabCase(tokenName)}`;
styles[cssVariableName] = tokenValue;
}
@@ -495,6 +607,18 @@ export class ThemeService {
this.animationStyleElement.textContent = css;
}
private syncCssStylesheet(): void {
const css = this.activeThemeInternal().css.trim();
if (!this.cssStyleElement) {
this.cssStyleElement = this.documentRef.createElement('style');
this.cssStyleElement.setAttribute('data-toju-theme-css', 'true');
this.documentRef.head.appendChild(this.cssStyleElement);
}
this.cssStyleElement.textContent = css;
}
private buildAnimationRule(className: string, definition: ThemeAnimationDefinition): string {
const animationClass = `.${className}`;
const declarationLines = [