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 = [
|
||||
|
||||
@@ -7,15 +7,11 @@ import {
|
||||
} from '../logic/theme-schema.logic';
|
||||
|
||||
function formatExample(example: string | number): string {
|
||||
return typeof example === 'number'
|
||||
? `${example}`
|
||||
: JSON.stringify(example);
|
||||
return typeof example === 'number' ? `${example}` : JSON.stringify(example);
|
||||
}
|
||||
|
||||
function getLayoutKeysForContainer(containerKey: string): string[] {
|
||||
return THEME_REGISTRY
|
||||
.filter((entry) => entry.container === containerKey && entry.layoutEditable)
|
||||
.map((entry) => entry.key);
|
||||
return THEME_REGISTRY.filter((entry) => entry.container === containerKey && entry.layoutEditable).map((entry) => entry.key);
|
||||
}
|
||||
|
||||
function describeCapabilities(entry: (typeof THEME_REGISTRY)[number]): string {
|
||||
@@ -26,9 +22,7 @@ function describeCapabilities(entry: (typeof THEME_REGISTRY)[number]): string {
|
||||
entry.supportsIcon ? 'icon' : null
|
||||
].filter((value): value is string => value !== null);
|
||||
|
||||
return capabilities.length > 0
|
||||
? capabilities.join(', ')
|
||||
: 'visual style overrides only';
|
||||
return capabilities.length > 0 ? capabilities.join(', ') : 'visual style overrides only';
|
||||
}
|
||||
|
||||
function describeLayoutContainer(container: (typeof THEME_LAYOUT_CONTAINERS)[number]): string {
|
||||
@@ -56,15 +50,16 @@ function describeThemeEntry(entry: (typeof THEME_REGISTRY)[number]): string {
|
||||
const colorTokenKeys = Object.keys(DEFAULT_THEME_DOCUMENT.tokens.colors);
|
||||
const radiusTokenKeys = Object.keys(DEFAULT_THEME_DOCUMENT.tokens.radii);
|
||||
const effectTokenKeys = Object.keys(DEFAULT_THEME_DOCUMENT.tokens.effects);
|
||||
const layoutEditableKeys = THEME_REGISTRY
|
||||
.filter((entry) => entry.layoutEditable)
|
||||
.map((entry) => entry.key);
|
||||
const layoutEditableKeys = THEME_REGISTRY.filter((entry) => entry.layoutEditable).map((entry) => entry.key);
|
||||
const layoutContainerKeys = THEME_LAYOUT_CONTAINERS.map((container) => container.key);
|
||||
const layoutContainerUnion = layoutContainerKeys.map((key) => `"${key}"`).join(' | ');
|
||||
const guideTemplateDocument = {
|
||||
meta: {
|
||||
name: 'Theme Name',
|
||||
version: '1.0.0',
|
||||
description: 'Short mood and material direction.'
|
||||
},
|
||||
css: '',
|
||||
tokens: {
|
||||
colors: {
|
||||
background: '224 28% 7%',
|
||||
@@ -102,6 +97,9 @@ const guideTemplateDocument = {
|
||||
},
|
||||
chatRoomMainPanel: {
|
||||
backgroundColor: 'hsl(var(--panel-background) / 0.82)',
|
||||
backgroundImage: '/assets/themes/paper-noise.png',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
borderRadius: 'var(--theme-radius-surface)',
|
||||
boxShadow: 'var(--theme-effect-panel-shadow)'
|
||||
}
|
||||
@@ -119,12 +117,13 @@ export const THEME_LLM_GUIDE = [
|
||||
'- Return JSON only when asked to generate a theme. Do not wrap the result in Markdown fences or add commentary.',
|
||||
'',
|
||||
'Core rules',
|
||||
'- Keep the top-level keys exactly: meta, tokens, layout, elements, animations.',
|
||||
'- Supported top-level keys are meta, css, tokens, layout, elements, and animations. Omit sections that do not need overrides.',
|
||||
'- Use strict JSON with double-quoted keys, no comments, and no trailing commas.',
|
||||
'- Omitted optional keys inherit from the built-in default theme, so leave out anything you are not intentionally changing.',
|
||||
'- Omitted layout keys use the app default placement. Omitted element keys apply no Theme Studio visual override.',
|
||||
'- Do not invent new top-level sections, layout containers, or element style properties.',
|
||||
'- links must be absolute http or https URLs.',
|
||||
'- animationClass must be a safe CSS class token and should match an entry in animations or an existing class already shipped by the app.',
|
||||
'- css is optional raw CSS applied after the JSON theme is active. Keep selectors scoped to app/theme surfaces when possible.',
|
||||
'- layout.grid values must be integers. x and y are zero-based. w and h must be greater than 0.',
|
||||
'- opacity must be a number between 0 and 1.',
|
||||
'',
|
||||
@@ -139,6 +138,8 @@ export const THEME_LLM_GUIDE = [
|
||||
'- tokens.colors entries become CSS variables like --background or --surface-highlight-alt.',
|
||||
'- Color token values should usually be raw HSL channels such as "224 28% 7%". The shell wraps many built-in color tokens with hsl(var(--token)).',
|
||||
'- You may add extra color tokens if you also reference them with CSS variables in element overrides.',
|
||||
'- elements.<key>.backgroundImage accepts a CSS background-image value, a local image path, or an http/https image URL.',
|
||||
'- Use backgroundSize and backgroundPosition with backgroundImage when the image needs cover/center behavior.',
|
||||
'- tokens.spacing entries become --theme-spacing-<kebab-case-key>.',
|
||||
'- tokens.radii.radius maps to --radius. Other radius keys become --theme-radius-<kebab-case-key>.',
|
||||
'- tokens.effects entries become --theme-effect-<kebab-case-key>.',
|
||||
@@ -148,8 +149,9 @@ export const THEME_LLM_GUIDE = [
|
||||
'',
|
||||
'Top-level schema reference',
|
||||
'- meta: { name: string, version: string, description?: string }',
|
||||
'- css?: string',
|
||||
'- tokens: { colors: Record<string, string>, spacing: Record<string, string>, radii: Record<string, string>, effects: Record<string, string> }',
|
||||
'- layout: Record<string, { container: "appShell" | "roomLayout", grid: { x: number, y: number, w: number, h: number } }>',
|
||||
`- layout: Record<string, { container: ${layoutContainerUnion}, grid: { x: number, y: number, w: number, h: number } }>`,
|
||||
'- elements: Record<string, ThemeElementStyles>',
|
||||
'- animations: Record<string, { duration?, easing?, delay?, iterationCount?, fillMode?, direction?, keyframes? }>',
|
||||
'',
|
||||
|
||||
@@ -3,50 +3,41 @@ import {
|
||||
ThemeElementStyles,
|
||||
ThemeLayoutEntry
|
||||
} from '../models/theme.model';
|
||||
import {
|
||||
THEME_LAYOUT_CONTAINERS,
|
||||
THEME_REGISTRY,
|
||||
getLayoutEditableThemeKeys
|
||||
} from './theme-registry.logic';
|
||||
import { THEME_LAYOUT_CONTAINERS, getLayoutEditableThemeKeys } from './theme-registry.logic';
|
||||
|
||||
const APP_ROOT_BASE_GRADIENT =
|
||||
'radial-gradient(circle at top, '
|
||||
+ 'hsl(var(--surface-highlight) / 0.18), transparent 34%)';
|
||||
const APP_ROOT_OVERLAY_GRADIENT = 'linear-gradient(180deg, rgba(5, 8, 15, 0.98), rgba(9, 12, 20, 1))';
|
||||
const APP_ROOT_GRADIENT_LAYERS = [APP_ROOT_BASE_GRADIENT, APP_ROOT_OVERLAY_GRADIENT] as const;
|
||||
const APP_WORKSPACE_BASE_GRADIENT =
|
||||
'radial-gradient(circle at top right, '
|
||||
+ 'hsl(var(--surface-highlight-alt) / 0.14), transparent 30%)';
|
||||
const APP_WORKSPACE_OVERLAY_GRADIENT = 'linear-gradient(180deg, rgba(9, 12, 21, 0.96), rgba(7, 10, 18, 1))';
|
||||
const APP_WORKSPACE_GRADIENT_LAYERS = [APP_WORKSPACE_BASE_GRADIENT, APP_WORKSPACE_OVERLAY_GRADIENT] as const;
|
||||
const CHAT_ROOM_MAIN_PANEL_BASE_GRADIENT =
|
||||
'radial-gradient(circle at top, '
|
||||
+ 'hsl(var(--surface-highlight) / 0.12), transparent 28%)';
|
||||
const CHAT_ROOM_MAIN_PANEL_OVERLAY_GRADIENT = 'linear-gradient(180deg, rgba(16, 20, 34, 0.82), rgba(8, 11, 19, 0.92))';
|
||||
const CHAT_ROOM_MAIN_PANEL_GRADIENT_LAYERS = [CHAT_ROOM_MAIN_PANEL_BASE_GRADIENT, CHAT_ROOM_MAIN_PANEL_OVERLAY_GRADIENT] as const;
|
||||
const VOICE_WORKSPACE_BASE_GRADIENT =
|
||||
'radial-gradient(circle at top right, '
|
||||
+ 'hsl(var(--surface-highlight) / 0.14), transparent 32%)';
|
||||
const VOICE_WORKSPACE_OVERLAY_GRADIENT = 'linear-gradient(180deg, rgba(18, 23, 37, 0.78), rgba(9, 12, 21, 0.88))';
|
||||
const VOICE_WORKSPACE_GRADIENT_LAYERS = [VOICE_WORKSPACE_BASE_GRADIENT, VOICE_WORKSPACE_OVERLAY_GRADIENT] as const;
|
||||
|
||||
function createDefaultElements(): Record<string, ThemeElementStyles> {
|
||||
return Object.fromEntries(
|
||||
THEME_REGISTRY.map((entry) => [entry.key, {}])
|
||||
) as Record<string, ThemeElementStyles>;
|
||||
function createProvidedDefaultLayout(): Record<string, ThemeLayoutEntry> {
|
||||
return {
|
||||
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 }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
|
||||
export function createDefaultThemeLayout(): Record<string, ThemeLayoutEntry> {
|
||||
const layoutEntries: Record<string, ThemeLayoutEntry> = {};
|
||||
|
||||
for (const key of getLayoutEditableThemeKeys()) {
|
||||
if (key === 'serversRail') {
|
||||
layoutEntries[key] = {
|
||||
container: 'appShell',
|
||||
grid: { x: 0,
|
||||
y: 0,
|
||||
w: 1,
|
||||
h: 1 }
|
||||
grid: { x: 0, y: 0, w: 1, h: 1 }
|
||||
};
|
||||
|
||||
continue;
|
||||
@@ -57,10 +48,7 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
|
||||
|
||||
layoutEntries[key] = {
|
||||
container: 'appShell',
|
||||
grid: { x: 1,
|
||||
y: 0,
|
||||
w: (appShell?.columns ?? 20) - 1,
|
||||
h: 1 }
|
||||
grid: { x: 1, y: 0, w: (appShell?.columns ?? 20) - 1, h: 1 }
|
||||
};
|
||||
|
||||
continue;
|
||||
@@ -69,10 +57,7 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
|
||||
if (key === 'chatRoomChannelsPanel') {
|
||||
layoutEntries[key] = {
|
||||
container: 'roomLayout',
|
||||
grid: { x: 0,
|
||||
y: 0,
|
||||
w: 4,
|
||||
h: 12 }
|
||||
grid: { x: 0, y: 0, w: 4, h: 12 }
|
||||
};
|
||||
|
||||
continue;
|
||||
@@ -81,10 +66,7 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
|
||||
if (key === 'chatRoomMainPanel') {
|
||||
layoutEntries[key] = {
|
||||
container: 'roomLayout',
|
||||
grid: { x: 4,
|
||||
y: 0,
|
||||
w: 12,
|
||||
h: 12 }
|
||||
grid: { x: 4, y: 0, w: 12, h: 12 }
|
||||
};
|
||||
|
||||
continue;
|
||||
@@ -93,10 +75,7 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
|
||||
if (key === 'chatRoomMembersPanel') {
|
||||
layoutEntries[key] = {
|
||||
container: 'roomLayout',
|
||||
grid: { x: 16,
|
||||
y: 0,
|
||||
w: 4,
|
||||
h: 12 }
|
||||
grid: { x: 16, y: 0, w: 4, h: 12 }
|
||||
};
|
||||
|
||||
continue;
|
||||
@@ -104,11 +83,8 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
|
||||
|
||||
if (key === 'dmConversationsPanel') {
|
||||
layoutEntries[key] = {
|
||||
container: 'roomLayout',
|
||||
grid: { x: 0,
|
||||
y: 0,
|
||||
w: 4,
|
||||
h: 12 }
|
||||
container: 'dmLayout',
|
||||
grid: { x: 0, y: 0, w: 4, h: 12 }
|
||||
};
|
||||
|
||||
continue;
|
||||
@@ -116,11 +92,8 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
|
||||
|
||||
if (key === 'dmChatPanel') {
|
||||
layoutEntries[key] = {
|
||||
container: 'roomLayout',
|
||||
grid: { x: 4,
|
||||
y: 0,
|
||||
w: 16,
|
||||
h: 12 }
|
||||
container: 'dmLayout',
|
||||
grid: { x: 4, y: 0, w: 16, h: 12 }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -128,105 +101,6 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
|
||||
return layoutEntries;
|
||||
}
|
||||
|
||||
function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
|
||||
const elements = createDefaultElements();
|
||||
|
||||
elements['appRoot'] = {
|
||||
backgroundColor: 'hsl(var(--background))',
|
||||
color: 'hsl(var(--foreground))',
|
||||
gradient: APP_ROOT_GRADIENT_LAYERS.join(', ')
|
||||
};
|
||||
|
||||
elements['serversRail'] = {
|
||||
backgroundColor: 'hsl(var(--rail-background) / 0.96)',
|
||||
gradient: 'linear-gradient(180deg, rgba(10, 14, 25, 0.92), rgba(6, 9, 16, 0.98))',
|
||||
boxShadow: 'inset -1px 0 0 hsl(var(--border) / 0.82), 18px 0 38px rgba(0, 0, 0, 0.22)',
|
||||
backdropFilter: 'var(--theme-effect-glass-blur)'
|
||||
};
|
||||
|
||||
elements['appWorkspace'] = {
|
||||
backgroundColor: 'hsl(var(--workspace-background))',
|
||||
gradient: APP_WORKSPACE_GRADIENT_LAYERS.join(', ')
|
||||
};
|
||||
|
||||
elements['titleBar'] = {
|
||||
backgroundColor: 'hsl(var(--title-bar-background) / 0.82)',
|
||||
color: 'hsl(var(--foreground))',
|
||||
gradient: 'linear-gradient(180deg, rgba(20, 26, 41, 0.52), rgba(7, 10, 18, 0.18))',
|
||||
boxShadow: ['inset 0 -1px 0 hsl(var(--border) / 0.78)', '0 12px 28px rgba(0, 0, 0, 0.18)'].join(', '),
|
||||
backdropFilter: 'var(--theme-effect-glass-blur)'
|
||||
};
|
||||
|
||||
elements['chatRoomChannelsPanel'] = {
|
||||
backgroundColor: 'hsl(var(--panel-background) / 0.9)',
|
||||
color: 'hsl(var(--foreground))',
|
||||
border: '1px solid hsl(var(--border) / 0.7)',
|
||||
borderRadius: 'var(--theme-radius-surface)',
|
||||
gradient: 'linear-gradient(180deg, rgba(18, 24, 38, 0.82), rgba(10, 13, 23, 0.88))',
|
||||
boxShadow: 'var(--theme-effect-soft-shadow)',
|
||||
backdropFilter: 'var(--theme-effect-glass-blur)'
|
||||
};
|
||||
|
||||
elements['chatRoomMainPanel'] = {
|
||||
backgroundColor: 'hsl(var(--panel-background) / 0.82)',
|
||||
color: 'hsl(var(--foreground))',
|
||||
border: '1px solid hsl(var(--border) / 0.62)',
|
||||
borderRadius: 'var(--theme-radius-surface)',
|
||||
gradient: CHAT_ROOM_MAIN_PANEL_GRADIENT_LAYERS.join(', '),
|
||||
boxShadow: 'var(--theme-effect-panel-shadow)',
|
||||
backdropFilter: 'var(--theme-effect-glass-blur)'
|
||||
};
|
||||
|
||||
elements['chatRoomMembersPanel'] = {
|
||||
backgroundColor: 'hsl(var(--panel-background-alt) / 0.92)',
|
||||
color: 'hsl(var(--foreground))',
|
||||
border: '1px solid hsl(var(--border) / 0.7)',
|
||||
borderRadius: 'var(--theme-radius-surface)',
|
||||
gradient: 'linear-gradient(180deg, rgba(22, 27, 41, 0.82), rgba(11, 14, 24, 0.9))',
|
||||
boxShadow: 'var(--theme-effect-soft-shadow)',
|
||||
backdropFilter: 'var(--theme-effect-glass-blur)'
|
||||
};
|
||||
|
||||
elements['dmConversationsPanel'] = {
|
||||
...elements['chatRoomChannelsPanel']
|
||||
};
|
||||
|
||||
elements['dmChatPanel'] = {
|
||||
...elements['chatRoomMainPanel']
|
||||
};
|
||||
|
||||
elements['chatRoomEmptyState'] = {
|
||||
backgroundColor: 'hsl(var(--panel-background-alt) / 0.88)',
|
||||
color: 'hsl(var(--muted-foreground))',
|
||||
border: '1px dashed hsl(var(--border) / 0.7)',
|
||||
borderRadius: 'var(--theme-radius-surface)',
|
||||
gradient: 'radial-gradient(circle at top, hsl(var(--surface-highlight) / 0.08), transparent 45%)',
|
||||
boxShadow: 'var(--theme-effect-soft-shadow)'
|
||||
};
|
||||
|
||||
elements['voiceWorkspace'] = {
|
||||
backgroundColor: 'hsl(var(--panel-background) / 0.74)',
|
||||
color: 'hsl(var(--foreground))',
|
||||
border: '1px solid hsl(var(--border) / 0.62)',
|
||||
borderRadius: 'calc(var(--theme-radius-surface) + 0.2rem)',
|
||||
gradient: VOICE_WORKSPACE_GRADIENT_LAYERS.join(', '),
|
||||
boxShadow: 'var(--theme-effect-panel-shadow)',
|
||||
backdropFilter: 'var(--theme-effect-glass-blur)'
|
||||
};
|
||||
|
||||
elements['floatingVoiceControls'] = {
|
||||
backgroundColor: 'hsl(var(--panel-background-alt) / 0.94)',
|
||||
color: 'hsl(var(--foreground))',
|
||||
border: '1px solid hsl(var(--border) / 0.78)',
|
||||
borderRadius: 'var(--theme-radius-pill)',
|
||||
gradient: 'linear-gradient(180deg, rgba(24, 31, 47, 0.92), rgba(13, 17, 29, 0.96))',
|
||||
boxShadow: 'var(--theme-effect-soft-shadow)',
|
||||
backdropFilter: 'var(--theme-effect-glass-blur)'
|
||||
};
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
function hasOnlyLegacyRadius(radii: Record<string, string>): boolean {
|
||||
const keys = Object.keys(radii);
|
||||
|
||||
@@ -239,6 +113,7 @@ function allElementsEmpty(elements: Record<string, ThemeElementStyles>): boolean
|
||||
|
||||
export function createDefaultThemeDocument(): ThemeDocument {
|
||||
return {
|
||||
css: '',
|
||||
meta: {
|
||||
name: 'Toju Default Dark',
|
||||
version: '2.0.0',
|
||||
@@ -285,23 +160,34 @@ export function createDefaultThemeDocument(): ThemeDocument {
|
||||
glassBlur: 'blur(18px) saturate(135%)'
|
||||
}
|
||||
},
|
||||
layout: createDefaultLayout(),
|
||||
elements: createDarkDefaultElements(),
|
||||
layout: createProvidedDefaultLayout(),
|
||||
elements: {},
|
||||
animations: {}
|
||||
};
|
||||
}
|
||||
|
||||
export function isLegacyDefaultThemeDocument(document: ThemeDocument): boolean {
|
||||
return document.meta.name === 'Toju Default Theme'
|
||||
&& document.meta.version === '1.0.0'
|
||||
&& document.meta.description === 'Safe baseline theme that matches the built-in Toju shell layout.'
|
||||
&& Object.keys(document.tokens.colors).length === 0
|
||||
&& Object.keys(document.tokens.spacing).length === 0
|
||||
&& hasOnlyLegacyRadius(document.tokens.radii)
|
||||
&& Object.keys(document.tokens.effects).length === 0
|
||||
&& allElementsEmpty(document.elements)
|
||||
&& Object.keys(document.animations).length === 0;
|
||||
return (
|
||||
document.meta.name === 'Toju Default Theme' &&
|
||||
document.meta.version === '1.0.0' &&
|
||||
document.meta.description === 'Safe baseline theme that matches the built-in Toju shell layout.' &&
|
||||
Object.keys(document.tokens.colors).length === 0 &&
|
||||
Object.keys(document.tokens.spacing).length === 0 &&
|
||||
hasOnlyLegacyRadius(document.tokens.radii) &&
|
||||
Object.keys(document.tokens.effects).length === 0 &&
|
||||
allElementsEmpty(document.elements) &&
|
||||
Object.keys(document.animations).length === 0 &&
|
||||
document.css.length === 0
|
||||
);
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME_DOCUMENT: ThemeDocument = createDefaultThemeDocument();
|
||||
export const DEFAULT_THEME_JSON = JSON.stringify(DEFAULT_THEME_DOCUMENT, null, 2);
|
||||
export const DEFAULT_THEME_JSON = JSON.stringify(
|
||||
{
|
||||
meta: DEFAULT_THEME_DOCUMENT.meta,
|
||||
tokens: DEFAULT_THEME_DOCUMENT.tokens,
|
||||
layout: DEFAULT_THEME_DOCUMENT.layout
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
|
||||
@@ -16,6 +16,14 @@ export const THEME_LAYOUT_CONTAINERS: readonly ThemeLayoutContainerDefinition[]
|
||||
columns: 20,
|
||||
rows: 12,
|
||||
templateColumns: 'repeat(4, 4.25rem) repeat(12, minmax(0, 1fr)) repeat(4, 4.25rem)'
|
||||
},
|
||||
{
|
||||
key: 'dmLayout',
|
||||
label: 'DM Workspace',
|
||||
description: 'Controls the conversations list and private chat panel inside direct messages.',
|
||||
columns: 20,
|
||||
rows: 12,
|
||||
templateColumns: 'repeat(4, 4.25rem) repeat(16, minmax(0, 1fr))'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -66,6 +74,39 @@ export const THEME_REGISTRY: readonly ThemeRegistryEntry[] = [
|
||||
supportsLink: true,
|
||||
supportsIcon: true
|
||||
},
|
||||
{
|
||||
key: 'serversRailCreateButton',
|
||||
label: 'Create Server Button',
|
||||
description: 'The primary action at the top of the server rail for creating or joining servers.',
|
||||
category: 'shell',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: true
|
||||
},
|
||||
{
|
||||
key: 'serversRailList',
|
||||
label: 'Servers List',
|
||||
description: 'The scrollable stack of saved server shortcuts in the left rail.',
|
||||
category: 'shell',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'serversRailItem',
|
||||
label: 'Server Shortcut',
|
||||
description: 'An individual saved server icon button in the left rail.',
|
||||
category: 'shell',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: true
|
||||
},
|
||||
{
|
||||
key: 'chatRoomChannelsPanel',
|
||||
label: 'Channels Panel',
|
||||
@@ -78,6 +119,72 @@ export const THEME_REGISTRY: readonly ThemeRegistryEntry[] = [
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'roomPanelHeader',
|
||||
label: 'Room Panel Header',
|
||||
description: 'The header at the top of the room side panel with server or member context.',
|
||||
category: 'room',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: true
|
||||
},
|
||||
{
|
||||
key: 'roomTextChannelsSection',
|
||||
label: 'Text Channels Section',
|
||||
description: 'The side-panel section that groups text channels.',
|
||||
category: 'room',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'roomTextChannelItem',
|
||||
label: 'Text Channel Item',
|
||||
description: 'An individual text channel row in the room side panel.',
|
||||
category: 'room',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: true
|
||||
},
|
||||
{
|
||||
key: 'roomVoiceChannelsSection',
|
||||
label: 'Voice Channels Section',
|
||||
description: 'The side-panel section that groups voice channels.',
|
||||
category: 'room',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'roomVoiceChannelItem',
|
||||
label: 'Voice Channel Item',
|
||||
description: 'An individual voice channel row in the room side panel.',
|
||||
category: 'room',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: true
|
||||
},
|
||||
{
|
||||
key: 'roomVoiceUserItem',
|
||||
label: 'Voice User Item',
|
||||
description: 'A user row nested under a voice channel.',
|
||||
category: 'room',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: true
|
||||
},
|
||||
{
|
||||
key: 'chatRoomMainPanel',
|
||||
label: 'Chat Panel',
|
||||
@@ -90,6 +197,182 @@ export const THEME_REGISTRY: readonly ThemeRegistryEntry[] = [
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'chatSurface',
|
||||
label: 'Chat Surface',
|
||||
description: 'The message area surface shared by room chat views.',
|
||||
category: 'room',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'chatMessageList',
|
||||
label: 'Message List',
|
||||
description: 'The scrollable list that contains message bubbles, date separators, and load-more states.',
|
||||
category: 'room',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'chatDateSeparator',
|
||||
label: 'Date Separator',
|
||||
description: 'The horizontal date divider inserted between groups of chat messages.',
|
||||
category: 'room',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'chatNewMessagesBar',
|
||||
label: 'New Messages Bar',
|
||||
description: 'The sticky prompt for jumping to unread messages.',
|
||||
category: 'room',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: true,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'chatMessageBubble',
|
||||
label: 'Message Bubble',
|
||||
description: 'The main row for an individual room or direct-message chat message.',
|
||||
category: 'room',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'chatMessageAvatar',
|
||||
label: 'Message Avatar',
|
||||
description: 'The sender avatar hit target inside each message row.',
|
||||
category: 'room',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: true
|
||||
},
|
||||
{
|
||||
key: 'chatMessageContent',
|
||||
label: 'Message Content',
|
||||
description: 'The text and rich content column inside each chat message.',
|
||||
category: 'room',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'chatAttachmentCard',
|
||||
label: 'Attachment Card',
|
||||
description: 'Cards that display attachment transfer, preview, request, and download states.',
|
||||
category: 'room',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: true
|
||||
},
|
||||
{
|
||||
key: 'chatReactionPill',
|
||||
label: 'Reaction Pill',
|
||||
description: 'A compact emoji reaction button under a message.',
|
||||
category: 'room',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: true
|
||||
},
|
||||
{
|
||||
key: 'chatMessageActions',
|
||||
label: 'Message Actions',
|
||||
description: 'The hover toolbar for reacting, replying, editing, and deleting a message.',
|
||||
category: 'room',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'chatComposerBar',
|
||||
label: 'Composer Bar',
|
||||
description: 'The pinned area that holds reply state, typing indicator, markdown tools, and the message input.',
|
||||
category: 'room',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'chatComposerReplyBar',
|
||||
label: 'Composer Reply Bar',
|
||||
description: 'The reply preview shown above the message composer.',
|
||||
category: 'room',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: true,
|
||||
supportsLink: false,
|
||||
supportsIcon: true
|
||||
},
|
||||
{
|
||||
key: 'chatComposerToolbar',
|
||||
label: 'Composer Toolbar',
|
||||
description: 'The inline markdown formatting toolbar above the composer input.',
|
||||
category: 'room',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'chatComposerInput',
|
||||
label: 'Composer Input',
|
||||
description: 'The textarea region where chat messages are written.',
|
||||
category: 'room',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'chatComposerSendButton',
|
||||
label: 'Send Button',
|
||||
description: 'The send action button inside the message composer.',
|
||||
category: 'room',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: true
|
||||
},
|
||||
{
|
||||
key: 'chatGifPickerSurface',
|
||||
label: 'GIF Picker Surface',
|
||||
description: 'The floating GIF picker container opened from the chat composer.',
|
||||
category: 'overlay',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'chatRoomMembersPanel',
|
||||
label: 'Members Panel',
|
||||
@@ -107,25 +390,102 @@ export const THEME_REGISTRY: readonly ThemeRegistryEntry[] = [
|
||||
label: 'DM Conversations Panel',
|
||||
description: 'The direct-message sidebar showing private chat conversations.',
|
||||
category: 'room',
|
||||
container: 'roomLayout',
|
||||
container: 'dmLayout',
|
||||
layoutEditable: true,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'dmConversationsHeader',
|
||||
label: 'DM Conversations Header',
|
||||
description: 'The header above the direct-message conversations list.',
|
||||
category: 'room',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: true,
|
||||
supportsLink: false,
|
||||
supportsIcon: true
|
||||
},
|
||||
{
|
||||
key: 'dmConversationList',
|
||||
label: 'DM Conversation List',
|
||||
description: 'The scrollable list of direct-message conversations.',
|
||||
category: 'room',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'dmConversationItem',
|
||||
label: 'DM Conversation Item',
|
||||
description: 'An individual direct-message conversation row.',
|
||||
category: 'room',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: true
|
||||
},
|
||||
{
|
||||
key: 'dmVoiceControlsArea',
|
||||
label: 'DM Voice Controls Area',
|
||||
description: 'The voice controls slot at the bottom of the direct-message sidebar.',
|
||||
category: 'room',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'dmChatPanel',
|
||||
label: 'DM Chat Panel',
|
||||
description: 'The main direct-message panel that hosts private chat messages.',
|
||||
category: 'room',
|
||||
container: 'roomLayout',
|
||||
container: 'dmLayout',
|
||||
layoutEditable: true,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'dmChatSurface',
|
||||
label: 'DM Chat Surface',
|
||||
description: 'The direct-message chat surface containing the DM header, messages, and composer.',
|
||||
category: 'room',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'dmChatHeader',
|
||||
label: 'DM Chat Header',
|
||||
description: 'The header above a direct-message conversation.',
|
||||
category: 'room',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: true,
|
||||
supportsLink: false,
|
||||
supportsIcon: true
|
||||
},
|
||||
{
|
||||
key: 'dmMessageRegion',
|
||||
label: 'DM Message Region',
|
||||
description: 'The absolute region that holds direct-message history and status markers.',
|
||||
category: 'room',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'chatRoomEmptyState',
|
||||
label: 'Room Empty State',
|
||||
@@ -148,6 +508,39 @@ export const THEME_REGISTRY: readonly ThemeRegistryEntry[] = [
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'voiceControlsPanel',
|
||||
label: 'Voice Controls Panel',
|
||||
description: 'The voice connection card embedded in sidebars and direct-message views.',
|
||||
category: 'overlay',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'voiceControlsUserRow',
|
||||
label: 'Voice Controls User Row',
|
||||
description: 'The current-user row inside the voice controls panel.',
|
||||
category: 'overlay',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: true
|
||||
},
|
||||
{
|
||||
key: 'voiceControlsButtons',
|
||||
label: 'Voice Controls Buttons',
|
||||
description: 'The mute, deafen, camera, screen-share, and disconnect button group.',
|
||||
category: 'overlay',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'floatingVoiceControls',
|
||||
label: 'Floating Voice Controls',
|
||||
@@ -158,6 +551,127 @@ export const THEME_REGISTRY: readonly ThemeRegistryEntry[] = [
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'settingsModalSurface',
|
||||
label: 'Settings Modal Surface',
|
||||
description: 'The main settings modal frame.',
|
||||
category: 'overlay',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'settingsModalNav',
|
||||
label: 'Settings Navigation',
|
||||
description: 'The left navigation column inside Settings.',
|
||||
category: 'overlay',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'settingsModalHeader',
|
||||
label: 'Settings Header',
|
||||
description: 'The title and close-button row at the top of Settings content.',
|
||||
category: 'overlay',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: true,
|
||||
supportsLink: false,
|
||||
supportsIcon: true
|
||||
},
|
||||
{
|
||||
key: 'settingsModalContent',
|
||||
label: 'Settings Content',
|
||||
description: 'The scrollable settings page content area.',
|
||||
category: 'overlay',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'contextMenuSurface',
|
||||
label: 'Context Menu Surface',
|
||||
description: 'The floating context menu panel used throughout the app.',
|
||||
category: 'overlay',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'confirmDialogSurface',
|
||||
label: 'Confirm Dialog Surface',
|
||||
description: 'The shared confirmation dialog frame.',
|
||||
category: 'overlay',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: true,
|
||||
supportsLink: false,
|
||||
supportsIcon: true
|
||||
},
|
||||
{
|
||||
key: 'profileCardSurface',
|
||||
label: 'Profile Card Surface',
|
||||
description: 'The user profile popover card.',
|
||||
category: 'overlay',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'profileCardBanner',
|
||||
label: 'Profile Card Banner',
|
||||
description: 'The decorative banner strip at the top of a profile card.',
|
||||
category: 'overlay',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: true
|
||||
},
|
||||
{
|
||||
key: 'profileCardBody',
|
||||
label: 'Profile Card Body',
|
||||
description: 'The main text and status content inside a profile card.',
|
||||
category: 'overlay',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'screenShareSourcePicker',
|
||||
label: 'Screen Share Source Picker',
|
||||
description: 'The dialog for choosing a screen or window to share.',
|
||||
category: 'overlay',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: true,
|
||||
supportsLink: false,
|
||||
supportsIcon: true
|
||||
},
|
||||
{
|
||||
key: 'screenShareSourceCard',
|
||||
label: 'Screen Share Source Card',
|
||||
description: 'An individual screen or window option in the source picker.',
|
||||
category: 'overlay',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: true
|
||||
}
|
||||
];
|
||||
|
||||
@@ -170,13 +684,9 @@ export function findThemeLayoutContainer(key: string): ThemeLayoutContainerDefin
|
||||
}
|
||||
|
||||
export function getLayoutEditableThemeKeys(): string[] {
|
||||
return THEME_REGISTRY
|
||||
.filter((entry) => entry.layoutEditable)
|
||||
.map((entry) => entry.key);
|
||||
return THEME_REGISTRY.filter((entry) => entry.layoutEditable).map((entry) => entry.key);
|
||||
}
|
||||
|
||||
export function getPickerVisibleThemeKeys(): string[] {
|
||||
return THEME_REGISTRY
|
||||
.filter((entry) => entry.pickerVisible)
|
||||
.map((entry) => entry.key);
|
||||
return THEME_REGISTRY.filter((entry) => entry.pickerVisible).map((entry) => entry.key);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,16 @@ import {
|
||||
ThemeSchemaField
|
||||
} from '../models/theme.model';
|
||||
|
||||
const RADIAL_GRADIENT_EXAMPLE =
|
||||
'radial-gradient(circle at top, rgba(255,255,255,0.12), '
|
||||
+ 'transparent 60%)';
|
||||
const RADIAL_GRADIENT_EXAMPLE = 'radial-gradient(circle at top, rgba(255,255,255,0.12), ' + 'transparent 60%)';
|
||||
|
||||
export const THEME_TOP_LEVEL_FIELDS: readonly ThemeSchemaField[] = [
|
||||
{
|
||||
key: 'css',
|
||||
description: 'Optional raw CSS applied after the theme JSON is active.',
|
||||
type: 'string',
|
||||
example: '.theme-settings { outline: 1px solid hsl(var(--primary)); }',
|
||||
examples: ['.theme-settings { outline: 1px solid hsl(var(--primary)); }']
|
||||
},
|
||||
{
|
||||
key: 'meta',
|
||||
description: 'Theme metadata used for naming, versioning, and describing the preset.',
|
||||
@@ -27,8 +32,8 @@ export const THEME_TOP_LEVEL_FIELDS: readonly ThemeSchemaField[] = [
|
||||
key: 'layout',
|
||||
description: 'Grid layout entries for registered moveable surfaces.',
|
||||
type: 'object',
|
||||
example: '{ "chatRoomMainPanel": { "container": "roomLayout", "grid": { "x": 4, "y": 0, "w": 12, "h": 12 } } }',
|
||||
examples: ['{ "chatRoomMainPanel": { "container": "roomLayout", "grid": { "x": 4, "y": 0, "w": 12, "h": 12 } } }']
|
||||
example: '{ "dmChatPanel": { "container": "dmLayout", "grid": { "x": 4, "y": 0, "w": 16, "h": 12 } } }',
|
||||
examples: ['{ "dmChatPanel": { "container": "dmLayout", "grid": { "x": 4, "y": 0, "w": 16, "h": 12 } } }']
|
||||
},
|
||||
{
|
||||
key: 'elements',
|
||||
@@ -83,7 +88,11 @@ export const THEME_LAYOUT_FIELDS: readonly ThemeSchemaField[] = [
|
||||
description: 'The registered layout container that owns this grid item.',
|
||||
type: 'string',
|
||||
example: 'roomLayout',
|
||||
examples: ['appShell', 'roomLayout']
|
||||
examples: [
|
||||
'appShell',
|
||||
'roomLayout',
|
||||
'dmLayout'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'grid',
|
||||
@@ -384,10 +393,14 @@ export const THEME_ELEMENT_STYLE_FIELDS: readonly ThemeSchemaField<ThemeElementS
|
||||
},
|
||||
{
|
||||
key: 'backgroundImage',
|
||||
description: 'CSS background image or image URL.',
|
||||
description: 'CSS background-image value, local image path, or http/https image URL.',
|
||||
type: 'string',
|
||||
example: "url('/assets/themes/paper-noise.png')",
|
||||
examples: ["url('/assets/themes/paper-noise.png')", "url('https://example.com/bg.jpg')"]
|
||||
example: '/assets/themes/paper-noise.png',
|
||||
examples: [
|
||||
'/assets/themes/paper-noise.png',
|
||||
'https://example.com/bg.jpg',
|
||||
"url('/assets/themes/paper-noise.png')"
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'backgroundSize',
|
||||
@@ -497,10 +510,7 @@ export function getSuggestedValueOptions(
|
||||
return findThemeElementStyleField(field)?.examples ?? [];
|
||||
}
|
||||
|
||||
export function getSuggestedFieldDefault(
|
||||
field: ThemeElementStyleProperty,
|
||||
animationKeys: readonly string[] = []
|
||||
): string | number {
|
||||
export function getSuggestedFieldDefault(field: ThemeElementStyleProperty, animationKeys: readonly string[] = []): string | number {
|
||||
return getSuggestedValueOptions(field, animationKeys)[0] ?? '';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import {
|
||||
ThemeAnimationDefinition,
|
||||
ThemeContainerKey,
|
||||
ThemeDocument,
|
||||
ThemeElementStyleProperty,
|
||||
ThemeElementStyles,
|
||||
ThemeGridRect,
|
||||
ThemeLayoutEntry,
|
||||
ThemeValidationResult
|
||||
} from '../models/theme.model';
|
||||
import { createDefaultThemeDocument } from './theme-defaults.logic';
|
||||
import { createDefaultThemeDocument, createDefaultThemeLayout } from './theme-defaults.logic';
|
||||
import {
|
||||
THEME_LAYOUT_CONTAINERS,
|
||||
THEME_REGISTRY,
|
||||
@@ -13,6 +16,7 @@ import {
|
||||
} from './theme-registry.logic';
|
||||
|
||||
const TOP_LEVEL_KEYS = [
|
||||
'css',
|
||||
'meta',
|
||||
'tokens',
|
||||
'layout',
|
||||
@@ -103,12 +107,7 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function validateUnknownKeys(
|
||||
value: Record<string, unknown>,
|
||||
allowedKeys: readonly string[],
|
||||
path: string,
|
||||
errors: string[]
|
||||
): void {
|
||||
function validateUnknownKeys(value: Record<string, unknown>, allowedKeys: readonly string[], path: string, errors: string[]): void {
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!allowedKeys.includes(key)) {
|
||||
errors.push(`${path}.${key} is not part of the supported theme schema.`);
|
||||
@@ -130,12 +129,7 @@ function validateString(value: unknown, path: string, errors: string[], allowEmp
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateEnum<T extends readonly string[]>(
|
||||
value: unknown,
|
||||
allowedValues: T,
|
||||
path: string,
|
||||
errors: string[]
|
||||
): value is T[number] {
|
||||
function validateEnum<T extends readonly string[]>(value: unknown, allowedValues: T, path: string, errors: string[]): value is T[number] {
|
||||
if (typeof value !== 'string' || !allowedValues.includes(value)) {
|
||||
errors.push(`${path} must be one of: ${allowedValues.join(', ')}.`);
|
||||
return false;
|
||||
@@ -144,13 +138,7 @@ function validateEnum<T extends readonly string[]>(
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateInteger(
|
||||
value: unknown,
|
||||
path: string,
|
||||
errors: string[],
|
||||
minimum: number,
|
||||
allowZero: boolean
|
||||
): value is number {
|
||||
function validateInteger(value: unknown, path: string, errors: string[], minimum: number, allowZero: boolean): value is number {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
||||
errors.push(`${path} must be an integer.`);
|
||||
return false;
|
||||
@@ -164,13 +152,7 @@ function validateInteger(
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateNumberRange(
|
||||
value: unknown,
|
||||
path: string,
|
||||
errors: string[],
|
||||
minimum: number,
|
||||
maximum: number
|
||||
): value is number {
|
||||
function validateNumberRange(value: unknown, path: string, errors: string[], minimum: number, maximum: number): value is number {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||
errors.push(`${path} must be a number.`);
|
||||
return false;
|
||||
@@ -305,49 +287,56 @@ function validateAnimationDefinition(value: unknown, path: string, errors: strin
|
||||
return true;
|
||||
}
|
||||
|
||||
function normaliseThemeDocument(input: Partial<ThemeDocument>): ThemeDocument {
|
||||
const document = createDefaultThemeDocument();
|
||||
function normaliseThemeDocument(input: Record<string, unknown>): ThemeDocument {
|
||||
const defaults = createDefaultThemeDocument();
|
||||
const meta = isPlainObject(input['meta']) ? input['meta'] : {};
|
||||
const tokens = isPlainObject(input['tokens']) ? input['tokens'] : {};
|
||||
const layout = isPlainObject(input['layout']) ? input['layout'] : {};
|
||||
|
||||
document.meta = {
|
||||
...document.meta,
|
||||
...input.meta
|
||||
return {
|
||||
css: typeof input['css'] === 'string' ? input['css'] : '',
|
||||
meta: {
|
||||
name: typeof meta['name'] === 'string' ? meta['name'] : defaults.meta.name,
|
||||
version: typeof meta['version'] === 'string' ? meta['version'] : defaults.meta.version,
|
||||
description: typeof meta['description'] === 'string' ? meta['description'] : defaults.meta.description
|
||||
},
|
||||
tokens: {
|
||||
colors: isPlainObject(tokens['colors']) ? (tokens['colors'] as Record<string, string>) : {},
|
||||
spacing: isPlainObject(tokens['spacing']) ? (tokens['spacing'] as Record<string, string>) : {},
|
||||
radii: isPlainObject(tokens['radii']) ? (tokens['radii'] as Record<string, string>) : {},
|
||||
effects: isPlainObject(tokens['effects']) ? (tokens['effects'] as Record<string, string>) : {}
|
||||
},
|
||||
layout: normaliseThemeLayout(layout, createDefaultThemeLayout()),
|
||||
elements: isPlainObject(input['elements']) ? (input['elements'] as Record<string, ThemeElementStyles>) : {},
|
||||
animations: isPlainObject(input['animations']) ? (input['animations'] as Record<string, ThemeAnimationDefinition>) : {}
|
||||
};
|
||||
}
|
||||
|
||||
document.tokens = {
|
||||
colors: {
|
||||
...document.tokens.colors,
|
||||
...(input.tokens?.colors ?? {})
|
||||
},
|
||||
spacing: {
|
||||
...document.tokens.spacing,
|
||||
...(input.tokens?.spacing ?? {})
|
||||
},
|
||||
radii: {
|
||||
...document.tokens.radii,
|
||||
...(input.tokens?.radii ?? {})
|
||||
},
|
||||
effects: {
|
||||
...document.tokens.effects,
|
||||
...(input.tokens?.effects ?? {})
|
||||
function normaliseThemeLayout(input: Record<string, unknown>, defaults: Record<string, ThemeLayoutEntry>): Record<string, ThemeLayoutEntry> {
|
||||
const layout: Record<string, ThemeLayoutEntry> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (!isPlainObject(value)) {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
document.layout = {
|
||||
...document.layout,
|
||||
...(input.layout ?? {})
|
||||
};
|
||||
const defaultEntry = defaults[key];
|
||||
const grid = isPlainObject(value['grid']) ? (value['grid'] as Partial<ThemeGridRect>) : {};
|
||||
|
||||
document.elements = {
|
||||
...document.elements,
|
||||
...(input.elements ?? {})
|
||||
};
|
||||
if (!defaultEntry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
document.animations = {
|
||||
...document.animations,
|
||||
...(input.animations ?? {})
|
||||
};
|
||||
layout[key] = {
|
||||
container: typeof value['container'] === 'string' ? (value['container'] as ThemeContainerKey) : defaultEntry.container,
|
||||
grid: {
|
||||
...defaultEntry.grid,
|
||||
...grid
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return document;
|
||||
return layout;
|
||||
}
|
||||
|
||||
export function validateThemeDocument(input: unknown): ThemeValidationResult {
|
||||
@@ -363,14 +352,24 @@ export function validateThemeDocument(input: unknown): ThemeValidationResult {
|
||||
|
||||
validateUnknownKeys(input, TOP_LEVEL_KEYS, 'theme', errors);
|
||||
|
||||
if (input['css'] !== undefined) {
|
||||
validateString(input['css'], 'theme.css', errors, true);
|
||||
}
|
||||
|
||||
const meta = input['meta'];
|
||||
|
||||
if (!isPlainObject(meta)) {
|
||||
if (meta !== undefined && !isPlainObject(meta)) {
|
||||
errors.push('theme.meta must be an object.');
|
||||
} else {
|
||||
} else if (isPlainObject(meta)) {
|
||||
validateUnknownKeys(meta, META_KEYS, 'theme.meta', errors);
|
||||
validateString(meta['name'], 'theme.meta.name', errors);
|
||||
validateString(meta['version'], 'theme.meta.version', errors);
|
||||
|
||||
if (meta['name'] !== undefined) {
|
||||
validateString(meta['name'], 'theme.meta.name', errors);
|
||||
}
|
||||
|
||||
if (meta['version'] !== undefined) {
|
||||
validateString(meta['version'], 'theme.meta.version', errors);
|
||||
}
|
||||
|
||||
if (meta['description'] !== undefined) {
|
||||
validateString(meta['description'], 'theme.meta.description', errors, true);
|
||||
@@ -492,6 +491,6 @@ export function validateThemeDocument(input: unknown): ThemeValidationResult {
|
||||
return {
|
||||
valid: true,
|
||||
errors: [],
|
||||
value: normaliseThemeDocument(input as Partial<ThemeDocument>)
|
||||
value: normaliseThemeDocument(input)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type ThemeContainerKey = 'appShell' | 'roomLayout';
|
||||
export type ThemeContainerKey = 'appShell' | 'roomLayout' | 'dmLayout';
|
||||
|
||||
export interface ThemeMeta {
|
||||
name: string;
|
||||
@@ -70,6 +70,7 @@ export interface ThemeAnimationDefinition {
|
||||
}
|
||||
|
||||
export interface ThemeDocument {
|
||||
css: string;
|
||||
meta: ThemeMeta;
|
||||
tokens: ThemeTokenGroups;
|
||||
layout: Record<string, ThemeLayoutEntry>;
|
||||
|
||||
@@ -15,11 +15,18 @@
|
||||
class="theme-grid-editor__frame relative overflow-hidden rounded-lg border border-border/80"
|
||||
[ngStyle]="frameStyle()"
|
||||
>
|
||||
<div class="theme-grid-editor__grid"></div>
|
||||
<div
|
||||
class="theme-grid-editor__grid"
|
||||
[ngStyle]="frameStyle()"
|
||||
>
|
||||
@for (cell of gridCells(); track cell) {
|
||||
<div class="theme-grid-editor__cell"></div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@for (item of items(); track item.key) {
|
||||
<div
|
||||
class="theme-grid-editor__item absolute"
|
||||
class="theme-grid-editor__item"
|
||||
[class.theme-grid-editor__item--selected]="selectedKey() === item.key"
|
||||
[class.theme-grid-editor__item--disabled]="disabled()"
|
||||
[ngStyle]="itemStyle(item)"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
}
|
||||
|
||||
.theme-grid-editor__frame {
|
||||
display: grid;
|
||||
aspect-ratio: 16 / 9;
|
||||
background:
|
||||
radial-gradient(circle at top, hsl(var(--primary) / 0.05), transparent 45%),
|
||||
@@ -12,15 +13,18 @@
|
||||
.theme-grid-editor__grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(to right, hsl(var(--border) / 0.65) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, hsl(var(--border) / 0.65) 1px, transparent 1px);
|
||||
background-size:
|
||||
calc(100% / var(--theme-grid-columns)) calc(100% / var(--theme-grid-rows)),
|
||||
calc(100% / var(--theme-grid-columns)) calc(100% / var(--theme-grid-rows));
|
||||
display: grid;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.theme-grid-editor__cell {
|
||||
border-top: 1px solid hsl(var(--border) / 0.65);
|
||||
border-left: 1px solid hsl(var(--border) / 0.65);
|
||||
}
|
||||
|
||||
.theme-grid-editor__item {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,8 +45,11 @@ export class ThemeGridEditorComponent {
|
||||
readonly canvasRef = viewChild.required<ElementRef<HTMLElement>>('canvasRef');
|
||||
readonly frameStyle = computed(() => ({
|
||||
'--theme-grid-columns': `${this.container().columns}`,
|
||||
'--theme-grid-rows': `${this.container().rows}`
|
||||
'--theme-grid-rows': `${this.container().rows}`,
|
||||
gridTemplateColumns: this.container().templateColumns ?? `repeat(${this.container().columns}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: this.container().templateRows ?? `repeat(${this.container().rows}, minmax(0, 1fr))`
|
||||
}));
|
||||
readonly gridCells = computed(() => Array.from({ length: this.container().columns * this.container().rows }, (_, index) => index));
|
||||
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private dragState: DragState | null = null;
|
||||
@@ -57,11 +60,8 @@ export class ThemeGridEditorComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvasRect = this.canvasRef().nativeElement.getBoundingClientRect();
|
||||
const columnWidth = canvasRect.width / this.container().columns;
|
||||
const rowHeight = canvasRect.height / this.container().rows;
|
||||
const deltaColumns = Math.round((event.clientX - this.dragState.startClientX) / columnWidth);
|
||||
const deltaRows = Math.round((event.clientY - this.dragState.startClientY) / rowHeight);
|
||||
const deltaColumns = this.gridDelta('columns', this.dragState.startClientX, event.clientX);
|
||||
const deltaRows = this.gridDelta('rows', this.dragState.startClientY, event.clientY);
|
||||
const nextGrid = { ...this.dragState.startGrid };
|
||||
|
||||
if (this.dragState.mode === 'move') {
|
||||
@@ -90,13 +90,9 @@ export class ThemeGridEditorComponent {
|
||||
}
|
||||
|
||||
itemStyle(item: ThemeGridEditorItem): Record<string, string> {
|
||||
const { columns, rows } = this.container();
|
||||
|
||||
return {
|
||||
left: `${(item.grid.x / columns) * 100}%`,
|
||||
top: `${(item.grid.y / rows) * 100}%`,
|
||||
width: `${(item.grid.w / columns) * 100}%`,
|
||||
height: `${(item.grid.h / rows) * 100}%`
|
||||
gridColumn: `${item.grid.x + 1} / span ${item.grid.w}`,
|
||||
gridRow: `${item.grid.y + 1} / span ${item.grid.h}`
|
||||
};
|
||||
}
|
||||
|
||||
@@ -132,4 +128,61 @@ export class ThemeGridEditorComponent {
|
||||
private clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.min(Math.max(value, minimum), maximum);
|
||||
}
|
||||
|
||||
private gridDelta(axis: 'columns' | 'rows', startClientPosition: number, currentClientPosition: number): number {
|
||||
const canvasRect = this.canvasRef().nativeElement.getBoundingClientRect();
|
||||
const linePositions = this.measureGridLines(axis, canvasRect);
|
||||
const startOffset = startClientPosition - (axis === 'columns' ? canvasRect.left : canvasRect.top);
|
||||
const currentOffset = currentClientPosition - (axis === 'columns' ? canvasRect.left : canvasRect.top);
|
||||
|
||||
return this.nearestGridLineIndex(currentOffset, linePositions) - this.nearestGridLineIndex(startOffset, linePositions);
|
||||
}
|
||||
|
||||
private measureGridLines(axis: 'columns' | 'rows', canvasRect: DOMRect): number[] {
|
||||
const canvas = this.canvasRef().nativeElement;
|
||||
const computedStyles = getComputedStyle(canvas);
|
||||
const expectedTracks = axis === 'columns' ? this.container().columns : this.container().rows;
|
||||
const availableSize = axis === 'columns' ? canvasRect.width : canvasRect.height;
|
||||
const template = axis === 'columns' ? computedStyles.gridTemplateColumns : computedStyles.gridTemplateRows;
|
||||
const trackSizes = template
|
||||
.split(/\s+/)
|
||||
.map((track) => Number.parseFloat(track))
|
||||
.filter((size) => Number.isFinite(size) && size > 0);
|
||||
|
||||
if (trackSizes.length !== expectedTracks) {
|
||||
return Array.from({ length: expectedTracks + 1 }, (_, index) => (availableSize / expectedTracks) * index);
|
||||
}
|
||||
|
||||
const totalTrackSize = trackSizes.reduce((sum, size) => sum + size, 0);
|
||||
const scale = totalTrackSize > 0 ? availableSize / totalTrackSize : 1;
|
||||
const linePositions = [0];
|
||||
|
||||
for (const trackSize of trackSizes) {
|
||||
const previousLinePosition = linePositions[linePositions.length - 1] ?? 0;
|
||||
|
||||
linePositions.push(previousLinePosition + trackSize * scale);
|
||||
}
|
||||
|
||||
linePositions[linePositions.length - 1] = availableSize;
|
||||
|
||||
return linePositions;
|
||||
}
|
||||
|
||||
private nearestGridLineIndex(offset: number, linePositions: number[]): number {
|
||||
const clampedOffset = this.clamp(offset, linePositions[0] ?? 0, linePositions.at(-1) ?? 0);
|
||||
|
||||
let nearestIndex = 0;
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (let index = 0; index < linePositions.length; index++) {
|
||||
const distance = Math.abs(linePositions[index] - clampedOffset);
|
||||
|
||||
if (distance < nearestDistance) {
|
||||
nearestDistance = distance;
|
||||
nearestIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
return nearestIndex;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,75 +11,149 @@ import {
|
||||
viewChild
|
||||
} from '@angular/core';
|
||||
import { indentWithTab } from '@codemirror/commands';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { indentUnit } from '@codemirror/language';
|
||||
import { EditorSelection, EditorState } from '@codemirror/state';
|
||||
import { css } from '@codemirror/lang-css';
|
||||
import { json, jsonParseLinter } from '@codemirror/lang-json';
|
||||
import { indentUnit, syntaxTree } from '@codemirror/language';
|
||||
import {
|
||||
Diagnostic,
|
||||
lintGutter,
|
||||
linter
|
||||
} from '@codemirror/lint';
|
||||
import {
|
||||
EditorSelection,
|
||||
EditorState,
|
||||
Extension
|
||||
} from '@codemirror/state';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { EditorView, keymap } from '@codemirror/view';
|
||||
import { basicSetup } from 'codemirror';
|
||||
|
||||
const THEME_JSON_EDITOR_THEME = EditorView.theme({
|
||||
'&': {
|
||||
height: '100%',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#e7eef9'
|
||||
import { formatPastedJsonText } from './theme-json-format.logic';
|
||||
|
||||
type ThemeCodeEditorLanguage = 'json' | 'css';
|
||||
|
||||
const ERROR_SQUIGGLE_BACKGROUND =
|
||||
'linear-gradient(45deg, transparent 65%, #fb7185 65%, #fb7185 80%, transparent 80%), '
|
||||
+ 'linear-gradient(135deg, transparent 65%, #fb7185 65%, #fb7185 80%, transparent 80%)';
|
||||
const THEME_JSON_EDITOR_THEME = EditorView.theme(
|
||||
{
|
||||
'&': {
|
||||
height: '100%',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#e7eef9'
|
||||
},
|
||||
'&.cm-focused': {
|
||||
outline: 'none'
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
fontFamily: "'IBM Plex Mono', 'JetBrains Mono', 'Fira Code', monospace",
|
||||
lineHeight: '1.55'
|
||||
},
|
||||
'.cm-content': {
|
||||
minHeight: '100%',
|
||||
padding: '1rem 0',
|
||||
caretColor: '#f8fafc'
|
||||
},
|
||||
'.cm-line': {
|
||||
padding: '0 1rem 0 0.5rem'
|
||||
},
|
||||
'.cm-cursor, .cm-dropCursor': {
|
||||
borderLeftColor: '#f8fafc'
|
||||
},
|
||||
'.cm-gutters': {
|
||||
minHeight: '100%',
|
||||
borderRight: '1px solid #2f405c',
|
||||
backgroundColor: '#172033',
|
||||
color: '#7b8aa5'
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'rgb(148 163 184 / 0.08)'
|
||||
},
|
||||
'.cm-activeLineGutter': {
|
||||
backgroundColor: 'rgb(148 163 184 / 0.12)',
|
||||
color: '#d7e2f2'
|
||||
},
|
||||
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground, ::selection': {
|
||||
backgroundColor: 'rgb(96 165 250 / 0.22)'
|
||||
},
|
||||
'.cm-panels': {
|
||||
backgroundColor: '#111827',
|
||||
color: '#e5eefc',
|
||||
borderBottom: '1px solid #2f405c'
|
||||
},
|
||||
'.cm-searchMatch': {
|
||||
backgroundColor: 'rgb(250 204 21 / 0.18)',
|
||||
outline: '1px solid rgb(250 204 21 / 0.32)'
|
||||
},
|
||||
'.cm-searchMatch.cm-searchMatch-selected': {
|
||||
backgroundColor: 'rgb(250 204 21 / 0.28)'
|
||||
},
|
||||
'.cm-tooltip': {
|
||||
border: '1px solid #314158',
|
||||
backgroundColor: '#111827'
|
||||
},
|
||||
'.cm-tooltip-autocomplete ul li[aria-selected]': {
|
||||
backgroundColor: 'rgb(96 165 250 / 0.18)',
|
||||
color: '#f8fafc'
|
||||
},
|
||||
'.cm-diagnosticRange-error': {
|
||||
backgroundImage: ERROR_SQUIGGLE_BACKGROUND,
|
||||
backgroundPosition: '0 100%',
|
||||
backgroundRepeat: 'repeat-x',
|
||||
backgroundSize: '8px 3px',
|
||||
paddingBottom: '2px'
|
||||
},
|
||||
'.cm-lintRange-error': {
|
||||
backgroundImage: ERROR_SQUIGGLE_BACKGROUND,
|
||||
backgroundPosition: '0 100%',
|
||||
backgroundRepeat: 'repeat-x',
|
||||
backgroundSize: '8px 3px',
|
||||
paddingBottom: '2px'
|
||||
},
|
||||
'.cm-lintPoint-error:after': {
|
||||
borderBottomColor: '#fb7185'
|
||||
},
|
||||
'.cm-diagnostic': {
|
||||
fontFamily: 'Inter, system-ui, sans-serif'
|
||||
},
|
||||
'.cm-diagnostic-error': {
|
||||
borderLeftColor: '#fb7185'
|
||||
}
|
||||
},
|
||||
'&.cm-focused': {
|
||||
outline: 'none'
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
fontFamily: "'IBM Plex Mono', 'JetBrains Mono', 'Fira Code', monospace",
|
||||
lineHeight: '1.55'
|
||||
},
|
||||
'.cm-content': {
|
||||
minHeight: '100%',
|
||||
padding: '1rem 0',
|
||||
caretColor: '#f8fafc'
|
||||
},
|
||||
'.cm-line': {
|
||||
padding: '0 1rem 0 0.5rem'
|
||||
},
|
||||
'.cm-cursor, .cm-dropCursor': {
|
||||
borderLeftColor: '#f8fafc'
|
||||
},
|
||||
'.cm-gutters': {
|
||||
minHeight: '100%',
|
||||
borderRight: '1px solid #2f405c',
|
||||
backgroundColor: '#172033',
|
||||
color: '#7b8aa5'
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'rgb(148 163 184 / 0.08)'
|
||||
},
|
||||
'.cm-activeLineGutter': {
|
||||
backgroundColor: 'rgb(148 163 184 / 0.12)',
|
||||
color: '#d7e2f2'
|
||||
},
|
||||
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground, ::selection': {
|
||||
backgroundColor: 'rgb(96 165 250 / 0.22)'
|
||||
},
|
||||
'.cm-panels': {
|
||||
backgroundColor: '#111827',
|
||||
color: '#e5eefc',
|
||||
borderBottom: '1px solid #2f405c'
|
||||
},
|
||||
'.cm-searchMatch': {
|
||||
backgroundColor: 'rgb(250 204 21 / 0.18)',
|
||||
outline: '1px solid rgb(250 204 21 / 0.32)'
|
||||
},
|
||||
'.cm-searchMatch.cm-searchMatch-selected': {
|
||||
backgroundColor: 'rgb(250 204 21 / 0.28)'
|
||||
},
|
||||
'.cm-tooltip': {
|
||||
border: '1px solid #314158',
|
||||
backgroundColor: '#111827'
|
||||
},
|
||||
'.cm-tooltip-autocomplete ul li[aria-selected]': {
|
||||
backgroundColor: 'rgb(96 165 250 / 0.18)',
|
||||
color: '#f8fafc'
|
||||
{ dark: true }
|
||||
);
|
||||
|
||||
function getLanguageExtensions(language: ThemeCodeEditorLanguage): Extension[] {
|
||||
if (language === 'css') {
|
||||
return [css(), linter(cssSyntaxLinter, { delay: 250 })];
|
||||
}
|
||||
}, { dark: true });
|
||||
|
||||
return [json(), linter(jsonParseLinter(), { delay: 250 })];
|
||||
}
|
||||
|
||||
function cssSyntaxLinter(view: EditorView): Diagnostic[] {
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
const documentLength = view.state.doc.length;
|
||||
|
||||
syntaxTree(view.state).iterate({
|
||||
enter: (node) => {
|
||||
if (!node.type.isError) {
|
||||
return;
|
||||
}
|
||||
|
||||
diagnostics.push({
|
||||
from: node.from,
|
||||
to: Math.max(node.to, Math.min(node.from + 1, documentLength)),
|
||||
severity: 'error',
|
||||
source: 'CSS',
|
||||
message: 'CSS syntax error.'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-theme-json-code-editor',
|
||||
@@ -91,9 +165,10 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
|
||||
readonly editorHostRef = viewChild<ElementRef<HTMLDivElement>>('editorHostRef');
|
||||
readonly value = input.required<string>();
|
||||
readonly fullscreen = input(false);
|
||||
readonly language = input<ThemeCodeEditorLanguage>('json');
|
||||
readonly valueChange = output<string>();
|
||||
|
||||
readonly editorMinHeight = computed(() => this.fullscreen() ? 'max(34rem, calc(100vh - 15rem))' : '28rem');
|
||||
readonly editorMinHeight = computed(() => (this.fullscreen() ? 'max(34rem, calc(100vh - 15rem))' : '28rem'));
|
||||
|
||||
private readonly zone = inject(NgZone);
|
||||
|
||||
@@ -173,6 +248,8 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
|
||||
|
||||
private createEditor(host: HTMLDivElement): void {
|
||||
this.zone.runOutsideAngular(() => {
|
||||
const language = this.language();
|
||||
|
||||
this.editorView = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: this.value(),
|
||||
@@ -180,7 +257,8 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
|
||||
basicSetup,
|
||||
keymap.of([indentWithTab]),
|
||||
indentUnit.of(' '),
|
||||
json(),
|
||||
...getLanguageExtensions(language),
|
||||
lintGutter(),
|
||||
oneDark,
|
||||
THEME_JSON_EDITOR_THEME,
|
||||
EditorState.tabSize.of(2),
|
||||
@@ -188,7 +266,25 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
|
||||
spellcheck: 'false',
|
||||
autocapitalize: 'off',
|
||||
autocorrect: 'off',
|
||||
'aria-label': 'Theme JSON editor'
|
||||
'aria-label': language === 'css' ? 'Theme CSS editor' : 'Theme JSON editor'
|
||||
}),
|
||||
EditorView.domEventHandlers({
|
||||
paste: (event, view) => {
|
||||
if (language !== 'json') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pastedText = event.clipboardData?.getData('application/json') || event.clipboardData?.getData('text/plain') || '';
|
||||
const formattedText = formatPastedJsonText(pastedText);
|
||||
|
||||
if (!formattedText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
view.dispatch(view.state.replaceSelection(formattedText));
|
||||
return true;
|
||||
}
|
||||
}),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (!update.docChanged || this.isApplyingExternalValue) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { formatPastedJsonText } from './theme-json-format.logic';
|
||||
|
||||
describe('theme JSON paste formatting', () => {
|
||||
it('formats pasted JSON with two-space indentation', () => {
|
||||
expect(formatPastedJsonText('{"meta":{"name":"Paste","version":"1"},"tokens":{"colors":{"background":"1 2% 3%"}}}')).toBe([
|
||||
'{',
|
||||
' "meta": {',
|
||||
' "name": "Paste",',
|
||||
' "version": "1"',
|
||||
' },',
|
||||
' "tokens": {',
|
||||
' "colors": {',
|
||||
' "background": "1 2% 3%"',
|
||||
' }',
|
||||
' }',
|
||||
'}'
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
it('leaves non-JSON paste content to the editor default', () => {
|
||||
expect(formatPastedJsonText('backgroundColor: red')).toBeNull();
|
||||
expect(formatPastedJsonText('')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
export function formatPastedJsonText(text: string): string | null {
|
||||
const trimmedText = text.trim();
|
||||
|
||||
if (!trimmedText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(trimmedText) as unknown, null, 2);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,13 @@
|
||||
>
|
||||
Format JSON
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="jumpToCss()"
|
||||
class="inline-flex items-center rounded-md border border-border bg-secondary px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
Open CSS
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="copyLlmThemeGuide()"
|
||||
@@ -33,13 +40,35 @@
|
||||
>
|
||||
Copy LLM Guide
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="themeFileInput.click()"
|
||||
class="inline-flex items-center rounded-md border border-border bg-secondary px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
Import File
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="exportThemeFile()"
|
||||
[disabled]="!draftIsValid()"
|
||||
class="inline-flex items-center rounded-md border border-border bg-secondary px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Export File
|
||||
</button>
|
||||
<input
|
||||
#themeFileInput
|
||||
type="file"
|
||||
accept=".json,.css,application/json,text/css"
|
||||
class="hidden"
|
||||
(change)="importThemeFile($event)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
(click)="applyDraft()"
|
||||
[disabled]="!draftIsValid()"
|
||||
class="inline-flex items-center rounded-md bg-primary px-4 py-1.5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Apply Draft
|
||||
{{ activeEditorTab() === 'cssOnly' ? 'Apply CSS Theme' : 'Apply Draft' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -59,24 +88,8 @@
|
||||
|
||||
<div class="theme-settings__hero-grid mt-4">
|
||||
<div class="theme-settings__hero-stat">
|
||||
<div class="theme-settings__workspace-selector theme-settings__workspace-selector--compact">
|
||||
<label
|
||||
for="theme-studio-workspace-select"
|
||||
class="theme-settings__workspace-selector-label"
|
||||
>
|
||||
Workspace
|
||||
</label>
|
||||
<select
|
||||
id="theme-studio-workspace-select"
|
||||
class="theme-settings__workspace-select"
|
||||
[value]="activeWorkspace()"
|
||||
(change)="onWorkspaceSelect($event)"
|
||||
>
|
||||
@for (workspace of workspaceTabs; track workspace.key) {
|
||||
<option [value]="workspace.key">{{ workspace.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<span class="theme-settings__hero-label">Workspace</span>
|
||||
<strong class="theme-settings__hero-value">{{ activeWorkspaceInfo().label }}</strong>
|
||||
</div>
|
||||
<div class="theme-settings__hero-stat">
|
||||
<span class="theme-settings__hero-label">Regions</span>
|
||||
@@ -110,6 +123,43 @@
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<nav
|
||||
class="theme-settings__workspace-tabs mt-4"
|
||||
aria-label="Theme Studio workspace"
|
||||
>
|
||||
@for (workspace of workspaceTabs; track workspace.key) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="setWorkspace(workspace.key)"
|
||||
class="theme-settings__workspace-tab"
|
||||
[class.theme-settings__workspace-tab--active]="activeWorkspace() === workspace.key"
|
||||
[attr.aria-current]="activeWorkspace() === workspace.key ? 'page' : null"
|
||||
>
|
||||
<span class="theme-settings__workspace-tab-label">{{ workspace.label }}</span>
|
||||
<span class="theme-settings__workspace-tab-description">{{ workspace.description }}</span>
|
||||
</button>
|
||||
}
|
||||
</nav>
|
||||
|
||||
<div class="theme-settings__workspace-selector theme-settings__workspace-selector--mobile mt-4">
|
||||
<label
|
||||
for="theme-studio-workspace-select"
|
||||
class="theme-settings__workspace-selector-label"
|
||||
>
|
||||
Workspace
|
||||
</label>
|
||||
<select
|
||||
id="theme-studio-workspace-select"
|
||||
class="theme-settings__workspace-select"
|
||||
[value]="activeWorkspace()"
|
||||
(change)="onWorkspaceSelect($event)"
|
||||
>
|
||||
@for (workspace of workspaceTabs; track workspace.key) {
|
||||
<option [value]="workspace.key">{{ workspace.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="theme-settings__workspace min-h-0 flex-1">
|
||||
@@ -222,7 +272,7 @@
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="theme-studio-card p-3.5">
|
||||
<section class="theme-studio-card theme-settings__explorer-card p-3.5">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-sm font-semibold text-foreground">Explorer</p>
|
||||
<span class="rounded-full bg-secondary px-2.5 py-1 text-[11px] font-medium text-muted-foreground">
|
||||
@@ -240,7 +290,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="theme-settings__entry-list mt-4">
|
||||
<div class="theme-settings__entry-list theme-settings__explorer-list mt-4">
|
||||
@for (entry of filteredEntries(); track entry.key) {
|
||||
<button
|
||||
type="button"
|
||||
@@ -318,23 +368,70 @@
|
||||
@if (activeWorkspace() === 'editor') {
|
||||
<section class="theme-studio-card theme-settings__editor-card p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4 border-b border-border pb-3">
|
||||
<p class="text-sm font-semibold text-foreground">Theme JSON</p>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-foreground">
|
||||
{{ activeEditorTab() === 'cssOnly' ? 'CSS-Only Theme' : 'Theme JSON' }}
|
||||
</p>
|
||||
@if (activeEditorTab() === 'cssOnly') {
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">CSS here is applied over the built-in default JSON theme.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 text-[11px] font-medium text-muted-foreground">
|
||||
<span class="rounded-md bg-secondary px-2.5 py-1">{{ draftLineCount() }} lines</span>
|
||||
<span class="rounded-md bg-secondary px-2.5 py-1">{{ draftCharacterCount() }} chars</span>
|
||||
<span class="rounded-md bg-secondary px-2.5 py-1">{{ draftErrorCount() }} errors</span>
|
||||
<span class="rounded-md bg-secondary px-2.5 py-1"
|
||||
>{{ activeEditorTab() === 'cssOnly' ? cssOnlyText().split('\n').length : draftLineCount() }} lines</span
|
||||
>
|
||||
<span class="rounded-md bg-secondary px-2.5 py-1"
|
||||
>{{ activeEditorTab() === 'cssOnly' ? cssOnlyText().length : draftCharacterCount() }} chars</span
|
||||
>
|
||||
@if (activeEditorTab() === 'json') {
|
||||
<span class="rounded-md bg-secondary px-2.5 py-1">{{ draftErrorCount() }} errors</span>
|
||||
}
|
||||
<span class="rounded-md bg-slate-900 px-2.5 py-1 text-slate-200">IDE editor</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
(click)="setEditorTab('json')"
|
||||
class="rounded-md border border-border bg-secondary px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||
[class.border-primary/40]="activeEditorTab() === 'json'"
|
||||
[class.bg-primary/10]="activeEditorTab() === 'json'"
|
||||
[attr.aria-current]="activeEditorTab() === 'json' ? 'page' : null"
|
||||
>
|
||||
JSON Theme
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="setEditorTab('cssOnly')"
|
||||
class="rounded-md border border-border bg-secondary px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||
[class.border-primary/40]="activeEditorTab() === 'cssOnly'"
|
||||
[class.bg-primary/10]="activeEditorTab() === 'cssOnly'"
|
||||
[attr.aria-current]="activeEditorTab() === 'cssOnly' ? 'page' : null"
|
||||
>
|
||||
CSS Only
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="theme-settings__editor-panel pt-3">
|
||||
<app-theme-json-code-editor
|
||||
#jsonEditorRef
|
||||
[value]="draftText()"
|
||||
[fullscreen]="isFullscreen()"
|
||||
(valueChange)="onDraftEditorValueChange($event)"
|
||||
/>
|
||||
@if (activeEditorTab() === 'json') {
|
||||
<app-theme-json-code-editor
|
||||
#jsonEditorRef
|
||||
[value]="draftText()"
|
||||
[fullscreen]="isFullscreen()"
|
||||
language="json"
|
||||
(valueChange)="onDraftEditorValueChange($event)"
|
||||
/>
|
||||
} @else {
|
||||
<app-theme-json-code-editor
|
||||
#jsonEditorRef
|
||||
[value]="cssOnlyText()"
|
||||
[fullscreen]="isFullscreen()"
|
||||
language="css"
|
||||
(valueChange)="onCssOnlyEditorValueChange($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -370,7 +467,7 @@
|
||||
|
||||
<section class="theme-studio-card p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-sm font-semibold text-foreground">Schema Hints</p>
|
||||
<p class="text-sm font-semibold text-foreground">Editable Attributes</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@@ -468,7 +565,7 @@
|
||||
|
||||
<div class="mt-5">
|
||||
<app-theme-grid-editor
|
||||
[container]="selectedContainer() === 'appShell' ? layoutContainers[0] : layoutContainers[1]"
|
||||
[container]="selectedLayoutContainer()"
|
||||
[items]="selectedContainerItems()"
|
||||
[selectedKey]="selectedElementKey()"
|
||||
[disabled]="!draftIsValid()"
|
||||
|
||||
@@ -18,6 +18,54 @@
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.theme-settings__workspace-selector--mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.theme-settings__workspace-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.theme-settings__workspace-tab {
|
||||
min-height: 5rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--secondary) / 0.22);
|
||||
padding: 0.8rem 0.9rem;
|
||||
text-align: left;
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
background-color 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.theme-settings__workspace-tab:hover {
|
||||
background: hsl(var(--secondary) / 0.42);
|
||||
}
|
||||
|
||||
.theme-settings__workspace-tab--active {
|
||||
border-color: hsl(var(--primary) / 0.45);
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
box-shadow: inset 0 0 0 1px hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
.theme-settings__workspace-tab-label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.theme-settings__workspace-tab-description {
|
||||
display: block;
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.45;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.theme-settings__workspace-selector-label {
|
||||
font-size: 0.69rem;
|
||||
font-weight: 700;
|
||||
@@ -52,6 +100,29 @@
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.theme-settings__workspace {
|
||||
display: grid;
|
||||
align-items: stretch;
|
||||
grid-template-columns: minmax(17rem, 22rem) minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.theme-settings__sidebar {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.theme-settings__main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.theme-settings__editor-card {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
@@ -92,6 +163,22 @@
|
||||
background: hsl(var(--primary) / 0.08);
|
||||
}
|
||||
|
||||
.theme-settings__explorer-card {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.theme-settings__explorer-list {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.theme-json-editor-panel__header {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -114,3 +201,22 @@
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.theme-settings__workspace-tabs {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.theme-settings__workspace-selector--mobile {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.theme-settings__workspace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.theme-settings__explorer-card {
|
||||
max-height: min(28rem, calc(100vh - 10rem));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { SettingsModalService } from '../../../../../core/services/settings-modal.service';
|
||||
import { getElectronApi } from '../../../../../core/platform/electron/get-electron-api';
|
||||
import {
|
||||
ThemeContainerKey,
|
||||
ThemeElementStyleProperty,
|
||||
@@ -29,7 +30,8 @@ import { THEME_LLM_GUIDE } from '../../../domain/constants/theme-llm-guide.const
|
||||
import { ThemeGridEditorComponent } from '../theme-grid-editor/theme-grid-editor.component';
|
||||
import { ThemeJsonCodeEditorComponent } from '../theme-json-code-editor/theme-json-code-editor.component';
|
||||
|
||||
type JumpSection = 'elements' | 'layout' | 'animations';
|
||||
type JumpSection = 'elements' | 'layout' | 'animations' | 'css';
|
||||
type ThemeEditorTab = 'json' | 'cssOnly';
|
||||
type ThemeStudioWorkspace = 'editor' | 'inspector' | 'layout';
|
||||
|
||||
@Component({
|
||||
@@ -59,6 +61,7 @@ export class ThemeSettingsComponent {
|
||||
readonly statusMessage = this.theme.statusMessage;
|
||||
readonly isDraftDirty = this.theme.isDraftDirty;
|
||||
readonly isFullscreen = this.modal.themeStudioFullscreen;
|
||||
readonly activeTheme = this.theme.activeTheme;
|
||||
readonly draftTheme = this.theme.draftTheme;
|
||||
readonly THEME_ANIMATION_FIELDS = THEME_ANIMATION_FIELD_HINTS;
|
||||
readonly animationKeys = this.theme.knownAnimationClasses;
|
||||
@@ -83,6 +86,8 @@ export class ThemeSettingsComponent {
|
||||
];
|
||||
readonly mountedKeyCounts = computed(() => this.registry.mountedKeyCounts());
|
||||
readonly activeWorkspace = signal<ThemeStudioWorkspace>('editor');
|
||||
readonly activeEditorTab = signal<ThemeEditorTab>('json');
|
||||
readonly cssOnlyText = signal('');
|
||||
readonly explorerQuery = signal('');
|
||||
|
||||
readonly selectedContainer = signal<ThemeContainerKey>('roomLayout');
|
||||
@@ -103,9 +108,15 @@ export class ThemeSettingsComponent {
|
||||
].filter((value): value is string => value !== null);
|
||||
});
|
||||
readonly selectedContainerItems = computed(() => this.layoutSync.itemsForContainer(this.selectedContainer()));
|
||||
readonly selectedLayoutContainer = computed(() => {
|
||||
return this.layoutContainers.find((container) => container.key === this.selectedContainer()) ?? this.layoutContainers[0];
|
||||
});
|
||||
readonly selectedElementGrid = computed(() => {
|
||||
return this.selectedContainerItems().find((item) => item.key === this.selectedElementKey()) ?? null;
|
||||
});
|
||||
readonly activeWorkspaceInfo = computed(() => {
|
||||
return this.workspaceTabs.find((workspace) => workspace.key === this.activeWorkspace()) ?? this.workspaceTabs[0];
|
||||
});
|
||||
readonly visiblePropertyHints = computed(() => {
|
||||
const selected = this.selectedElement();
|
||||
|
||||
@@ -156,6 +167,8 @@ export class ThemeSettingsComponent {
|
||||
private llmGuideCopyTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.syncCssOnlyTextFromTheme();
|
||||
|
||||
if (this.savedThemesAvailable()) {
|
||||
void this.themeLibrary.refresh();
|
||||
}
|
||||
@@ -167,8 +180,16 @@ export class ThemeSettingsComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeWorkspace.set('inspector');
|
||||
this.selectThemeEntry(pickedKey, 'elements');
|
||||
this.openPickedElementInJson(pickedKey);
|
||||
this.picker.clearSelection();
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (this.activeEditorTab() !== 'json') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cssOnlyText.set(this.draftTheme().css);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
@@ -186,10 +207,35 @@ export class ThemeSettingsComponent {
|
||||
this.theme.updateDraftText(value);
|
||||
}
|
||||
|
||||
onCssOnlyEditorValueChange(value: string): void {
|
||||
this.cssOnlyText.set(value);
|
||||
}
|
||||
|
||||
applyDraft(): void {
|
||||
if (this.activeEditorTab() === 'cssOnly') {
|
||||
this.applyCssOnlyTheme();
|
||||
return;
|
||||
}
|
||||
|
||||
this.theme.applyDraft();
|
||||
}
|
||||
|
||||
applyCssOnlyTheme(): void {
|
||||
this.theme.applyCssOnlyTheme(this.cssOnlyText());
|
||||
}
|
||||
|
||||
setEditorTab(tab: ThemeEditorTab): void {
|
||||
this.activeEditorTab.set(tab);
|
||||
|
||||
if (tab === 'json') {
|
||||
this.focusEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
this.syncCssOnlyTextFromTheme();
|
||||
this.focusEditor();
|
||||
}
|
||||
|
||||
setWorkspace(workspace: ThemeStudioWorkspace): void {
|
||||
this.activeWorkspace.set(workspace);
|
||||
|
||||
@@ -202,6 +248,7 @@ export class ThemeSettingsComponent {
|
||||
}
|
||||
|
||||
if (workspace === 'editor') {
|
||||
this.activeEditorTab.set('json');
|
||||
this.focusEditor();
|
||||
}
|
||||
}
|
||||
@@ -223,12 +270,36 @@ export class ThemeSettingsComponent {
|
||||
this.focusEditor();
|
||||
}
|
||||
|
||||
async exportThemeFile(): Promise<void> {
|
||||
const exportText = this.getExportThemeText();
|
||||
|
||||
if (!exportText) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName = `${this.sanitizeThemeFileName(this.draftTheme().meta.name)}.json`;
|
||||
const saved = await this.saveTextAsFile(fileName, exportText);
|
||||
|
||||
this.theme.announceStatus(saved ? `${fileName} exported.` : 'Theme export cancelled.');
|
||||
}
|
||||
|
||||
importThemeFile(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0] ?? null;
|
||||
|
||||
input.value = '';
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
void this.loadThemeFile(file);
|
||||
}
|
||||
|
||||
async copyLlmThemeGuide(): Promise<void> {
|
||||
const copied = await this.copyTextToClipboard(THEME_LLM_GUIDE);
|
||||
|
||||
this.setLlmGuideCopyMessage(copied
|
||||
? 'LLM guide copied.'
|
||||
: 'Manual copy opened.');
|
||||
this.setLlmGuideCopyMessage(copied ? 'LLM guide copied.' : 'Manual copy opened.');
|
||||
}
|
||||
|
||||
startPicker(): void {
|
||||
@@ -285,9 +356,10 @@ export class ThemeSettingsComponent {
|
||||
restoreDefaultTheme(): void {
|
||||
this.theme.resetToDefault('button');
|
||||
this.activeWorkspace.set('editor');
|
||||
this.activeEditorTab.set('json');
|
||||
this.selectedContainer.set('roomLayout');
|
||||
this.selectedElementKey.set('chatRoomMainPanel');
|
||||
this.focusJsonAnchor('elements', 'chatRoomMainPanel');
|
||||
this.focusEditor();
|
||||
}
|
||||
|
||||
selectThemeEntry(key: string, section: JumpSection = 'elements'): void {
|
||||
@@ -299,7 +371,7 @@ export class ThemeSettingsComponent {
|
||||
|
||||
if (section === 'layout') {
|
||||
this.activeWorkspace.set('layout');
|
||||
} else if (section === 'animations') {
|
||||
} else if (section === 'animations' || section === 'css') {
|
||||
this.activeWorkspace.set('editor');
|
||||
} else {
|
||||
this.activeWorkspace.set('inspector');
|
||||
@@ -331,6 +403,9 @@ export class ThemeSettingsComponent {
|
||||
const value = getSuggestedFieldDefault(property, this.animationKeys());
|
||||
|
||||
this.theme.setElementStyle(this.selectedElementKey(), property, value);
|
||||
this.activeWorkspace.set('editor');
|
||||
this.activeEditorTab.set('json');
|
||||
this.focusJsonProperty('elements', this.selectedElementKey(), property);
|
||||
}
|
||||
|
||||
addStarterAnimation(): void {
|
||||
@@ -355,37 +430,200 @@ export class ThemeSettingsComponent {
|
||||
|
||||
jumpToLayout(): void {
|
||||
this.activeWorkspace.set('editor');
|
||||
this.activeEditorTab.set('json');
|
||||
this.focusJsonAnchor('layout', this.selectedElementKey());
|
||||
}
|
||||
|
||||
jumpToStyles(): void {
|
||||
this.activeWorkspace.set('editor');
|
||||
this.activeEditorTab.set('json');
|
||||
this.focusJsonAnchor('elements', this.selectedElementKey());
|
||||
}
|
||||
|
||||
jumpToAnimation(animationKey: string): void {
|
||||
this.activeWorkspace.set('editor');
|
||||
this.activeEditorTab.set('json');
|
||||
this.focusJsonAnchor('animations', animationKey);
|
||||
}
|
||||
|
||||
jumpToCss(): void {
|
||||
this.activeWorkspace.set('editor');
|
||||
this.activeEditorTab.set('cssOnly');
|
||||
this.syncCssOnlyTextFromTheme();
|
||||
this.focusEditor();
|
||||
}
|
||||
|
||||
isMounted(entry: ThemeRegistryEntry): boolean {
|
||||
return (this.mountedKeyCounts()[entry.key] ?? 0) > 0;
|
||||
}
|
||||
|
||||
private focusEditor(): void {
|
||||
this.withEditorReady((editor) => {
|
||||
editor.focus();
|
||||
});
|
||||
}
|
||||
|
||||
private withEditorReady(action: (editor: ThemeJsonCodeEditorComponent) => void, attempts = 8): void {
|
||||
queueMicrotask(() => {
|
||||
this.editorRef()?.focus();
|
||||
const editor = this.editorRef();
|
||||
|
||||
if (editor) {
|
||||
action(editor);
|
||||
return;
|
||||
}
|
||||
|
||||
if (attempts <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this.withEditorReady(action, attempts - 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private syncCssOnlyTextFromTheme(): void {
|
||||
this.cssOnlyText.set(this.activeTheme().css || this.draftTheme().css);
|
||||
}
|
||||
|
||||
private getExportThemeText(): string | null {
|
||||
if (this.activeEditorTab() === 'cssOnly') {
|
||||
return this.theme.buildDraftTextWithCss(this.cssOnlyText());
|
||||
}
|
||||
|
||||
if (!this.draftIsValid()) {
|
||||
this.theme.announceStatus('Fix JSON errors before exporting the theme.');
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.draftText();
|
||||
}
|
||||
|
||||
private async loadThemeFile(file: File): Promise<void> {
|
||||
try {
|
||||
const text = await file.text();
|
||||
|
||||
if (file.name.toLowerCase().endsWith('.css')) {
|
||||
this.activeWorkspace.set('editor');
|
||||
this.activeEditorTab.set('cssOnly');
|
||||
this.cssOnlyText.set(text);
|
||||
this.theme.announceStatus(`${file.name} loaded into the CSS editor.`);
|
||||
this.focusEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
const loaded = this.theme.loadThemeText(text, 'draft', `${file.name} imported into the draft editor.`, 'imported theme file');
|
||||
|
||||
if (!loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeWorkspace.set('editor');
|
||||
this.activeEditorTab.set('json');
|
||||
this.syncCssOnlyTextFromTheme();
|
||||
this.focusEditor();
|
||||
} catch {
|
||||
this.theme.announceStatus(`Unable to import ${file.name}.`);
|
||||
}
|
||||
}
|
||||
|
||||
private async saveTextAsFile(fileName: string, text: string): Promise<boolean> {
|
||||
const electronApi = getElectronApi();
|
||||
|
||||
if (electronApi) {
|
||||
const result = await electronApi.saveFileAs(fileName, this.encodeBase64(text));
|
||||
|
||||
return result.saved;
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
|
||||
const link = document.createElement('a');
|
||||
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private encodeBase64(value: string): string {
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
|
||||
let binary = '';
|
||||
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
private sanitizeThemeFileName(value: string): string {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
return normalized.length > 0 ? normalized : 'theme';
|
||||
}
|
||||
|
||||
private openPickedElementInJson(key: string): void {
|
||||
const definition = this.registry.getDefinition(key);
|
||||
|
||||
if (!definition) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedElementKey.set(key);
|
||||
|
||||
if (definition.container) {
|
||||
this.selectedContainer.set(definition.container);
|
||||
}
|
||||
|
||||
if (this.draftIsValid()) {
|
||||
this.theme.ensureElementEntry(key);
|
||||
}
|
||||
|
||||
this.activeWorkspace.set('editor');
|
||||
this.activeEditorTab.set('json');
|
||||
this.focusJsonElementEntry(key);
|
||||
}
|
||||
|
||||
private focusJsonElementEntry(key: string): void {
|
||||
this.withEditorReady((editor) => {
|
||||
let text = this.draftText();
|
||||
let anchorIndex = this.findAnchorIndex(text, 'elements', key);
|
||||
|
||||
if (anchorIndex === -1 && this.draftIsValid()) {
|
||||
this.theme.ensureElementEntry(key);
|
||||
text = this.draftText();
|
||||
anchorIndex = this.findAnchorIndex(text, 'elements', key);
|
||||
}
|
||||
|
||||
if (anchorIndex === -1) {
|
||||
editor.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const colonIndex = text.indexOf(':', anchorIndex);
|
||||
const objectStart = colonIndex === -1 ? -1 : text.indexOf('{', colonIndex);
|
||||
|
||||
if (objectStart === -1) {
|
||||
editor.focusRange(anchorIndex, Math.min(anchorIndex + key.length + 2, text.length));
|
||||
return;
|
||||
}
|
||||
|
||||
editor.focusRange(objectStart + 1, objectStart + 1);
|
||||
});
|
||||
}
|
||||
|
||||
private focusJsonAnchor(section: JumpSection, key: string): void {
|
||||
queueMicrotask(() => {
|
||||
const editor = this.editorRef();
|
||||
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.withEditorReady((editor) => {
|
||||
let text = this.draftText();
|
||||
let anchorIndex = this.findAnchorIndex(text, section, key);
|
||||
|
||||
@@ -413,6 +651,94 @@ export class ThemeSettingsComponent {
|
||||
});
|
||||
}
|
||||
|
||||
private focusJsonProperty(section: JumpSection, key: string, property: string): void {
|
||||
this.withEditorReady((editor) => {
|
||||
const text = this.draftText();
|
||||
const keyIndex = this.findAnchorIndex(text, section, key);
|
||||
|
||||
if (keyIndex === -1) {
|
||||
editor.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const propertyIndex = text.indexOf(`"${property}"`, keyIndex);
|
||||
|
||||
if (propertyIndex === -1) {
|
||||
editor.focusRange(keyIndex, Math.min(keyIndex + key.length + 2, text.length));
|
||||
return;
|
||||
}
|
||||
|
||||
const valueRange = this.findJsonPropertyValueRange(text, propertyIndex);
|
||||
|
||||
editor.focusRange(valueRange.start, valueRange.end);
|
||||
});
|
||||
}
|
||||
|
||||
private findJsonPropertyValueRange(text: string, propertyIndex: number): { start: number; end: number } {
|
||||
const colonIndex = text.indexOf(':', propertyIndex);
|
||||
|
||||
if (colonIndex === -1) {
|
||||
return {
|
||||
start: propertyIndex,
|
||||
end: propertyIndex
|
||||
};
|
||||
}
|
||||
|
||||
let valueStart = colonIndex + 1;
|
||||
|
||||
while (valueStart < text.length && /\s/.test(text[valueStart])) {
|
||||
valueStart += 1;
|
||||
}
|
||||
|
||||
if (text[valueStart] === '"') {
|
||||
const stringContentStart = valueStart + 1;
|
||||
|
||||
let stringContentEnd = stringContentStart;
|
||||
|
||||
while (stringContentEnd < text.length) {
|
||||
if (text[stringContentEnd] === '"' && text[stringContentEnd - 1] !== '\\') {
|
||||
break;
|
||||
}
|
||||
|
||||
stringContentEnd += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
start: stringContentStart,
|
||||
end: stringContentEnd
|
||||
};
|
||||
}
|
||||
|
||||
let valueEnd = valueStart;
|
||||
|
||||
while (valueEnd < text.length && ![
|
||||
',',
|
||||
'\n',
|
||||
'}'
|
||||
].includes(text[valueEnd])) {
|
||||
valueEnd += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
start: valueStart,
|
||||
end: Math.max(valueStart, valueEnd)
|
||||
};
|
||||
}
|
||||
|
||||
private focusJsonSection(section: JumpSection): void {
|
||||
this.withEditorReady((editor) => {
|
||||
const text = this.draftText();
|
||||
const anchorIndex = text.indexOf(`"${section}"`);
|
||||
|
||||
if (anchorIndex === -1) {
|
||||
editor.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
editor.focusRange(anchorIndex, Math.min(anchorIndex + section.length + 2, text.length));
|
||||
});
|
||||
}
|
||||
|
||||
private async copyTextToClipboard(value: string): Promise<boolean> {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ExternalLinkService } from '../../../core/platform';
|
||||
import { ElementPickerService } from '../application/services/element-picker.service';
|
||||
import { ThemeRegistryService } from '../application/services/theme-registry.service';
|
||||
import { ThemeService } from '../application/services/theme.service';
|
||||
import { applyThemeStyleDeclaration } from './theme-style-application.logic';
|
||||
|
||||
function looksLikeImageReference(value: string): boolean {
|
||||
return value.startsWith('url(')
|
||||
@@ -96,8 +97,7 @@ export class ThemeNodeDirective implements OnDestroy {
|
||||
this.clearAppliedStyles();
|
||||
|
||||
for (const [styleKey, styleValue] of Object.entries(styles)) {
|
||||
this.host.nativeElement.style.setProperty(styleKey, styleValue);
|
||||
this.appliedStyleKeys.add(styleKey);
|
||||
this.appliedStyleKeys.add(applyThemeStyleDeclaration(this.host.nativeElement, styleKey, styleValue));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
applyThemeStyleDeclaration,
|
||||
toCssStylePropertyName
|
||||
} from './theme-style-application.logic';
|
||||
|
||||
describe('theme style application', () => {
|
||||
it('applies camelCase theme properties as real CSS declarations', () => {
|
||||
const host = createHost();
|
||||
|
||||
expect(applyThemeStyleDeclaration(host, 'backgroundImage', 'url("/theme.png")')).toBe('background-image');
|
||||
expect(applyThemeStyleDeclaration(host, 'borderRadius', '12px')).toBe('border-radius');
|
||||
expect(applyThemeStyleDeclaration(host, 'boxShadow', '0 4px 20px rgba(0, 0, 0, 0.25)')).toBe('box-shadow');
|
||||
|
||||
expect(host.style.backgroundImage).toContain('/theme.png');
|
||||
expect(host.style.borderRadius).toBe('12px');
|
||||
expect(host.style.boxShadow).toBe('0 4px 20px rgba(0, 0, 0, 0.25)');
|
||||
});
|
||||
|
||||
it('keeps CSS custom properties intact', () => {
|
||||
const host = createHost();
|
||||
|
||||
expect(toCssStylePropertyName('--theme-effect-glass-blur')).toBe('--theme-effect-glass-blur');
|
||||
applyThemeStyleDeclaration(host, '--theme-effect-glass-blur', 'blur(18px)');
|
||||
|
||||
expect(host.style.getPropertyValue('--theme-effect-glass-blur')).toBe('blur(18px)');
|
||||
});
|
||||
});
|
||||
|
||||
function createHost(): HTMLElement {
|
||||
const values = new Map<string, string>();
|
||||
const style = {
|
||||
backgroundImage: '',
|
||||
borderRadius: '',
|
||||
boxShadow: '',
|
||||
setProperty(propertyName: string, value: string) {
|
||||
values.set(propertyName, value);
|
||||
|
||||
if (propertyName === 'background-image') {
|
||||
this.backgroundImage = value;
|
||||
}
|
||||
|
||||
if (propertyName === 'border-radius') {
|
||||
this.borderRadius = value;
|
||||
}
|
||||
|
||||
if (propertyName === 'box-shadow') {
|
||||
this.boxShadow = value;
|
||||
}
|
||||
},
|
||||
getPropertyValue(propertyName: string) {
|
||||
return values.get(propertyName) ?? '';
|
||||
}
|
||||
};
|
||||
|
||||
return { style } as unknown as HTMLElement;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export function toCssStylePropertyName(propertyName: string): string {
|
||||
if (propertyName.startsWith('--')) {
|
||||
return propertyName;
|
||||
}
|
||||
|
||||
return propertyName.replace(/([A-Z])/g, '-$1').toLowerCase();
|
||||
}
|
||||
|
||||
export function applyThemeStyleDeclaration(host: HTMLElement, propertyName: string, value: string): string {
|
||||
const cssPropertyName = toCssStylePropertyName(propertyName);
|
||||
|
||||
host.style.setProperty(cssPropertyName, value);
|
||||
return cssPropertyName;
|
||||
}
|
||||
Reference in New Issue
Block a user