feat: Theme studio v2
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user