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

@@ -59,6 +59,11 @@ export class ElementPickerService {
this.hoveredKey.set(null);
this.isPicking.set(false);
if (this.resumePage === 'theme') {
this.modal.openThemeStudio();
return;
}
if (this.resumePage) {
this.modal.open(this.resumePage);
}
@@ -124,8 +129,6 @@ export class ElementPickerService {
const key = themedElement?.dataset['themeKey'] ?? null;
const definition = this.registry.getDefinition(key);
return definition?.pickerVisible
? key
: null;
return definition?.pickerVisible ? key : null;
}
}

View File

@@ -9,7 +9,7 @@ import {
ThemeGridEditorItem,
ThemeGridRect
} from '../../domain/models/theme.model';
import { createDefaultThemeDocument } from '../../domain/logic/theme-defaults.logic';
import { createDefaultThemeDocument, createDefaultThemeLayout } from '../../domain/logic/theme-defaults.logic';
import { ThemeRegistryService } from './theme-registry.service';
import { ThemeService } from './theme.service';
@@ -26,37 +26,46 @@ export class LayoutSyncService {
itemsForContainer(containerKey: ThemeContainerKey): ThemeGridEditorItem[] {
const draftTheme = this.theme.draftTheme();
const defaults = createDefaultThemeDocument();
const defaults = createDefaultThemeLayout();
return this.registry.entries()
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
grid: draftTheme.layout[entry.key]?.grid ?? defaults[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.`);
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();
const defaults = createDefaultThemeLayout();
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];
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[entry.key];
}
}
}
}, true, `${containerKey} restored to its default layout.`);
},
true,
`${containerKey} restored to its default layout.`
);
}
}

View File

@@ -0,0 +1,489 @@
import { DOCUMENT } from '@angular/common';
import { EnvironmentInjector, createEnvironmentInjector } from '@angular/core';
import { DEFAULT_THEME_JSON, createDefaultThemeDocument } from '../../domain/logic/theme-defaults.logic';
import { validateThemeDocument } from '../../domain/logic/theme-validation.logic';
import { ThemeService } from './theme.service';
describe('ThemeService theme application', () => {
let injector: EnvironmentInjector;
let styleElements: TestStyleElement[];
let service: ThemeService;
beforeEach(() => {
installLocalStorageMock();
styleElements = [];
injector = createEnvironmentInjector([
ThemeService,
{
provide: DOCUMENT,
useValue: createDocumentStub(styleElements)
}
]);
service = injector.get(ThemeService);
service.initialize();
});
afterEach(() => {
injector.destroy();
vi.unstubAllGlobals();
});
it('uses the compact Toju dark theme as the built-in default JSON', () => {
expect(JSON.parse(DEFAULT_THEME_JSON) as unknown).toEqual({
meta: {
name: 'Toju Default Dark',
version: '2.0.0',
description: 'Built-in dark glass theme for the full Toju app shell.'
},
tokens: {
colors: {
background: '224 28% 7%',
foreground: '210 40% 96%',
card: '224 25% 10%',
cardForeground: '210 40% 96%',
popover: '224 26% 9%',
popoverForeground: '210 40% 96%',
primary: '193 95% 68%',
primaryForeground: '222 47% 11%',
secondary: '223 19% 16%',
secondaryForeground: '210 40% 96%',
muted: '223 18% 14%',
mutedForeground: '215 20% 70%',
accent: '218 22% 18%',
accentForeground: '210 40% 98%',
destructive: '0 72% 55%',
destructiveForeground: '0 0% 100%',
border: '222 18% 22%',
input: '222 18% 22%',
ring: '193 95% 68%',
railBackground: '226 33% 8%',
workspaceBackground: '224 30% 9%',
panelBackground: '224 24% 11%',
panelBackgroundAlt: '222 22% 13%',
titleBarBackground: '226 34% 7%',
surfaceHighlight: '193 95% 68%',
surfaceHighlightAlt: '261 82% 72%'
},
spacing: {},
radii: {
radius: '0.875rem',
surface: '1.35rem',
pill: '999px'
},
effects: {
panelShadow: '0 24px 60px rgba(0, 0, 0, 0.42)',
softShadow: '0 14px 36px rgba(0, 0, 0, 0.28)',
glassBlur: 'blur(18px) saturate(135%)'
}
},
layout: {
serversRail: {
container: 'appShell',
grid: { x: 0, y: 0, w: 1, h: 1 }
},
appWorkspace: {
container: 'appShell',
grid: { x: 1, y: 0, w: 19, h: 1 }
},
chatRoomChannelsPanel: {
container: 'roomLayout',
grid: { x: 0, y: 0, w: 4, h: 12 }
},
chatRoomMainPanel: {
container: 'roomLayout',
grid: { x: 4, y: 0, w: 12, h: 12 }
},
chatRoomMembersPanel: {
container: 'roomLayout',
grid: { x: 16, y: 0, w: 4, h: 12 }
}
}
});
});
it('applies a JSON theme with tokens, layout, backgrounds, effects, metadata, and animations', () => {
const theme = createCompleteThemeDocument();
const loaded = service.loadThemeText(JSON.stringify(theme), 'apply', 'Theme applied.', 'complete JSON theme');
expect(loaded).toBe(true);
expect(service.activeThemeName()).toBe('Complete Theme Fixture');
expect(service.getTextOverride('titleBar')).toBe('MetoYou Lab');
expect(service.getIcon('titleBar')).toBe('MT');
expect(service.getLink('titleBar')).toBe('https://example.com/theme');
expect(service.getAnimationClass('titleBar')).toBe('themePulse');
expect(service.getHostStyles('appRoot')).toMatchObject({
'--background': '210 22% 8%',
'--theme-spacing-panel-gap': '14px',
'--radius': '1rem',
'--theme-radius-surface': '1.25rem',
'--theme-effect-glass-blur': 'blur(20px) saturate(140%)'
});
expect(service.getHostStyles('titleBar')).toMatchObject({
width: 'min(100%, 64rem)',
height: '4rem',
minWidth: '18rem',
minHeight: '3rem',
maxWidth: '72rem',
maxHeight: '5rem',
position: 'sticky',
top: '0',
right: '0',
bottom: 'auto',
left: '0',
padding: '0.75rem 1rem',
margin: '0 auto',
border: '1px solid hsl(var(--border) / 0.75)',
borderRadius: '1rem',
backgroundColor: 'rgba(7, 11, 20, 0.88)',
color: '#f8fafc',
backgroundImage: 'linear-gradient(90deg, rgba(14, 165, 233, 0.28), transparent), url("/themes/city.png")',
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
boxShadow: '0 18px 50px rgba(0, 0, 0, 0.35)',
backdropFilter: 'var(--theme-effect-glass-blur)',
opacity: '0.82'
});
expect(service.getLayoutContainerStyles('dmLayout')).toMatchObject({
display: 'grid',
gridTemplateColumns: 'repeat(4, 4.25rem) repeat(16, minmax(0, 1fr))'
});
expect(service.getLayoutItemStyles('dmChatPanel')).toMatchObject({
gridColumn: '7 / span 14',
gridRow: '2 / span 10'
});
const animationStylesheet = styleElements.find((styleElement) => styleElement.textContent?.includes('@keyframes themePulse'));
expect(animationStylesheet?.textContent).toContain('@keyframes themePulse');
expect(animationStylesheet?.textContent).toContain('animation-direction: alternate-reverse;');
expect(animationStylesheet?.textContent).toContain('transform: scale(1);');
});
it('applies raw CSS from the theme JSON', () => {
const loaded = service.loadThemeText(
JSON.stringify({
meta: {
name: 'CSS Theme',
version: '1.0.0'
},
css: '.theme-css-probe { color: hsl(var(--primary)); }'
}),
'apply',
'Theme applied.',
'CSS JSON theme'
);
expect(loaded).toBe(true);
expect(styleElements.some((styleElement) => styleElement.textContent === '.theme-css-probe { color: hsl(var(--primary)); }')).toBe(true);
expect(service.activeThemeText()).toContain('"css"');
});
it('applies a CSS-only theme over the default JSON theme', () => {
const applied = service.applyCssOnlyTheme('.css-only-theme { background: hsl(var(--background)); }');
expect(applied).toBe(true);
expect(service.activeThemeName()).toBe('Toju Default Dark');
expect(service.getLayoutItemStyles('dmChatPanel')).toMatchObject({
gridColumn: '5 / span 16',
gridRow: '1 / span 12'
});
expect(service.getHostStyles('appRoot')).toMatchObject({
'--background': '224 28% 7%'
});
expect(styleElements.some((styleElement) => styleElement.textContent === '.css-only-theme { background: hsl(var(--background)); }')).toBe(true);
});
it('applies CSS over the current JSON draft theme', () => {
const draftTheme = createDefaultThemeDocument();
draftTheme.meta = {
name: 'Draft Base Theme',
version: '1.0.0'
};
draftTheme.tokens.colors['background'] = '200 50% 10%';
draftTheme.layout['dmChatPanel'] = {
container: 'dmLayout',
grid: { x: 6, y: 0, w: 14, h: 12 }
};
const loadedDraft = service.loadThemeText(JSON.stringify(draftTheme), 'draft', 'Draft loaded.', 'draft JSON theme');
const applied = service.applyCssOnlyTheme('.draft-base-theme { color: hsl(var(--foreground)); }');
expect(loadedDraft).toBe(true);
expect(applied).toBe(true);
expect(service.activeThemeName()).toBe('Draft Base Theme');
expect(service.getHostStyles('appRoot')).toMatchObject({
'--background': '200 50% 10%'
});
expect(service.getLayoutItemStyles('dmChatPanel')).toMatchObject({
gridColumn: '7 / span 14',
gridRow: '1 / span 12'
});
expect(styleElements.some((styleElement) => styleElement.textContent === '.draft-base-theme { color: hsl(var(--foreground)); }')).toBe(true);
});
it('builds exportable JSON with CSS over the current draft without applying it', () => {
const draftTheme = createDefaultThemeDocument();
draftTheme.meta = {
name: 'Export Base Theme',
version: '1.0.0'
};
draftTheme.tokens.colors['background'] = '180 40% 12%';
const loadedDraft = service.loadThemeText(JSON.stringify(draftTheme), 'draft', 'Draft loaded.', 'draft JSON theme');
const exportText = service.buildDraftTextWithCss('.export-base-theme { color: hsl(var(--foreground)); }');
expect(loadedDraft).toBe(true);
expect(exportText).not.toBeNull();
expect(JSON.parse(exportText ?? '{}')).toMatchObject({
meta: {
name: 'Export Base Theme'
},
css: '.export-base-theme { color: hsl(var(--foreground)); }',
tokens: {
colors: {
background: '180 40% 12%'
}
}
});
expect(service.activeThemeName()).toBe('Toju Default Dark');
});
it('validates the dedicated DM workspace layout container', () => {
const theme = createDefaultThemeDocument();
theme.layout['dmConversationsPanel'] = {
container: 'dmLayout',
grid: { x: 0, y: 0, w: 5, h: 12 }
};
theme.layout['dmChatPanel'] = {
container: 'dmLayout',
grid: { x: 5, y: 0, w: 15, h: 12 }
};
const result = validateThemeDocument(theme);
expect(result.valid).toBe(true);
expect(result.value?.layout['dmChatPanel'].container).toBe('dmLayout');
});
it('allows compact JSON themes with omitted element sections', () => {
const loaded = service.loadThemeText(
JSON.stringify({
meta: {
name: 'Compact Theme',
version: '1.0.0'
},
elements: {
titleBar: {
backgroundImage: '/themes/city.png',
backgroundSize: 'cover'
}
}
}),
'apply',
'Theme applied.',
'compact JSON theme'
);
expect(loaded).toBe(true);
expect(service.getHostStyles('titleBar')).toMatchObject({
backgroundImage: 'url("/themes/city.png")',
backgroundSize: 'cover'
});
expect(service.getHostStyles('voiceWorkspace')).toEqual({});
expect(service.activeThemeText()).not.toContain('voiceWorkspace');
});
it('omits empty element stubs when formatting themes', () => {
const loaded = service.loadThemeText(
JSON.stringify({
meta: {
name: 'Theme With Empty Elements',
version: '1.0.0'
},
elements: {
titleBar: {},
voiceWorkspace: {}
}
}),
'apply',
'Theme applied.',
'theme with empty elements'
);
expect(loaded).toBe(true);
expect(service.activeThemeText()).not.toContain('"elements"');
});
it('keeps only non-empty element overrides when formatting themes', () => {
const loaded = service.loadThemeText(
JSON.stringify({
meta: {
name: 'Theme With Mixed Elements',
version: '1.0.0'
},
elements: {
titleBar: {},
chatRoomMainPanel: {
backgroundImage: '/themes/city.png'
}
}
}),
'apply',
'Theme applied.',
'theme with mixed elements'
);
const activeTheme = JSON.parse(service.activeThemeText()) as { elements?: Record<string, unknown> };
expect(loaded).toBe(true);
expect(activeTheme.elements).toEqual({
chatRoomMainPanel: {
backgroundImage: '/themes/city.png'
}
});
});
it('allows removing the entire elements section', () => {
const loaded = service.loadThemeText(
JSON.stringify({
meta: {
name: 'No Elements Theme',
version: '1.0.0'
}
}),
'apply',
'Theme applied.',
'compact JSON theme'
);
expect(loaded).toBe(true);
expect(service.getHostStyles('titleBar')).toEqual({});
expect(service.activeThemeText()).not.toContain('"elements"');
});
});
interface TestStyleElement {
textContent: string | null;
setAttribute(name: string, value: string): void;
}
function createCompleteThemeDocument() {
const theme = createDefaultThemeDocument();
theme.meta = {
name: 'Complete Theme Fixture',
version: '9.9.9',
description: 'Exercises every supported applied theme surface.'
};
theme.tokens.colors['background'] = '210 22% 8%';
theme.tokens.spacing['panelGap'] = '14px';
theme.tokens.radii['radius'] = '1rem';
theme.tokens.radii['surface'] = '1.25rem';
theme.tokens.effects['glassBlur'] = 'blur(20px) saturate(140%)';
theme.layout['dmChatPanel'] = {
container: 'dmLayout',
grid: { x: 6, y: 1, w: 14, h: 10 }
};
theme.elements['titleBar'] = {
width: 'min(100%, 64rem)',
height: '4rem',
minWidth: '18rem',
minHeight: '3rem',
maxWidth: '72rem',
maxHeight: '5rem',
position: 'sticky',
top: '0',
right: '0',
bottom: 'auto',
left: '0',
opacity: 0.82,
padding: '0.75rem 1rem',
margin: '0 auto',
border: '1px solid hsl(var(--border) / 0.75)',
borderRadius: '1rem',
backgroundColor: 'rgba(7, 11, 20, 0.88)',
color: '#f8fafc',
backgroundImage: '/themes/city.png',
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
gradient: 'linear-gradient(90deg, rgba(14, 165, 233, 0.28), transparent)',
boxShadow: '0 18px 50px rgba(0, 0, 0, 0.35)',
backdropFilter: 'var(--theme-effect-glass-blur)',
icon: 'MT',
textOverride: 'MetoYou Lab',
link: 'https://example.com/theme',
animationClass: 'themePulse'
};
theme.animations['themePulse'] = {
duration: '600ms',
easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
delay: '50ms',
iterationCount: 'infinite',
fillMode: 'both',
direction: 'alternate-reverse',
keyframes: {
from: {
opacity: 0,
transform: 'scale(0.98)'
},
to: {
opacity: 1,
transform: 'scale(1)'
}
}
};
return theme;
}
function createDocumentStub(styleElements: TestStyleElement[]): Document {
return {
createElement: () => {
const styleElement: TestStyleElement = {
textContent: null,
setAttribute: () => undefined
};
return styleElement;
},
head: {
appendChild: (styleElement: TestStyleElement) => {
styleElements.push(styleElement);
return styleElement;
}
}
} as unknown as Document;
}
function installLocalStorageMock(): void {
const values = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
clear: () => values.clear()
});
}

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 = [