style: Update default theme

This commit is contained in:
2026-05-25 16:51:44 +02:00
parent 155fe20862
commit 1259645706
23 changed files with 1206 additions and 630 deletions

View File

@@ -1,7 +1,13 @@
import { DOCUMENT } from '@angular/common';
import { EnvironmentInjector, createEnvironmentInjector } from '@angular/core';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { DEFAULT_THEME_JSON, createDefaultThemeDocument } from '../../domain/logic/theme-defaults.logic';
import {
BUILT_IN_THEME_PRESETS,
DEFAULT_THEME_JSON,
createDefaultThemeDocument
} from '../../domain/logic/theme-defaults.logic';
import { validateThemeDocument } from '../../domain/logic/theme-validation.logic';
import { ThemeService } from './theme.service';
@@ -30,55 +36,58 @@ describe('ThemeService theme application', () => {
vi.unstubAllGlobals();
});
it('uses the compact Toju dark theme as the built-in default JSON', () => {
expect(JSON.parse(DEFAULT_THEME_JSON) as unknown).toEqual({
it('uses the website dark theme as the built-in default JSON', () => {
const defaultTheme = JSON.parse(DEFAULT_THEME_JSON) as Record<string, unknown>;
expect(defaultTheme['css']).toEqual(expect.not.stringContaining('radial-gradient'));
expect(defaultTheme).toEqual(expect.objectContaining({
meta: {
name: 'Toju Default Dark',
version: '2.0.0',
description: 'Built-in dark glass theme for the full Toju app shell.'
name: 'Toju Website Dark',
version: '1.0.0',
description: 'Website-inspired dark app theme using the charcoal, green, and amber palette from the public Toju site.'
},
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%',
background: '210 18% 7%',
foreground: '42 33% 94%',
card: '210 17% 10%',
cardForeground: '42 33% 94%',
popover: '210 17% 9%',
popoverForeground: '42 33% 94%',
primary: '154 49% 55%',
primaryForeground: '210 18% 7%',
secondary: '210 14% 15%',
secondaryForeground: '42 33% 94%',
muted: '210 14% 15%',
mutedForeground: '42 13% 67%',
accent: '38 64% 61%',
accentForeground: '210 18% 7%',
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%'
border: '210 13% 22%',
input: '210 13% 22%',
ring: '154 49% 55%',
railBackground: '210 19% 6%',
workspaceBackground: '210 18% 8%',
panelBackground: '210 17% 10%',
panelBackgroundAlt: '210 14% 13%',
titleBarBackground: '210 19% 6%',
surfaceHighlight: '154 49% 55%',
surfaceHighlightAlt: '38 64% 61%'
},
spacing: {},
radii: {
radius: '0.875rem',
surface: '1.35rem',
radius: '0.6rem',
surface: '0.85rem',
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%)'
panelShadow: '0 28px 64px rgba(0, 0, 0, 0.34)',
softShadow: '0 16px 36px rgba(0, 0, 0, 0.22)',
glassBlur: 'blur(16px) saturate(125%)'
}
},
layout: {
layout: expect.objectContaining({
serversRail: {
container: 'appShell',
grid: { x: 0, y: 0, w: 1, h: 1 }
@@ -99,10 +108,76 @@ describe('ThemeService theme application', () => {
container: 'roomLayout',
grid: { x: 16, y: 0, w: 4, h: 12 }
}
}
}),
elements: expect.objectContaining({
titleBar: expect.objectContaining({ border: '0' })
}),
animations: {}
}));
});
it('exposes both built-in theme presets and applies the legacy default preset', () => {
expect(BUILT_IN_THEME_PRESETS.map((preset) => preset.theme.meta.name)).toEqual(['Toju Website Dark', 'Toju Default Dark']);
expect(service.activeThemeName()).toBe('Toju Website Dark');
const applied = service.applyBuiltInPreset('Toju Default Dark');
expect(applied).toBe(true);
expect(service.activeThemeName()).toBe('Toju Default Dark');
expect(service.getHostStyles('appRoot')).toMatchObject({
'--background': '224 28% 7%',
'--primary': '193 95% 68%'
});
});
it('resets to the website dark preset as the new default', () => {
expect(service.applyBuiltInPreset('Toju Default Dark')).toBe(true);
service.resetToDefault();
expect(service.activeThemeName()).toBe('Toju Website Dark');
expect(service.getHostStyles('appRoot')).toMatchObject({
'--background': '210 18% 7%',
'--primary': '154 49% 55%',
'--accent': '38 64% 61%'
});
expect(service.activeThemeText()).not.toContain('radial-gradient');
});
it('keeps the importable website dark artifact aligned with the default preset', () => {
const artifact = JSON.parse(readFileSync(resolve('../project-files/themes/toju-website-dark.json'), 'utf8')) as unknown;
const defaultTheme = JSON.parse(DEFAULT_THEME_JSON) as unknown;
expect(artifact).toEqual(defaultTheme);
});
it('keeps all built-in presets valid', () => {
for (const preset of BUILT_IN_THEME_PRESETS) {
const result = validateThemeDocument(preset.theme);
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
}
});
it('applies a CSS-only theme over the new default JSON theme', () => {
const applied = service.applyCssOnlyTheme('.css-only-theme { background: hsl(var(--background)); }');
expect(applied).toBe(true);
expect(service.activeThemeName()).toBe('Toju Website Dark');
expect(service.getLayoutItemStyles('dmChatPanel')).toMatchObject({
gridColumn: '5 / span 16',
gridRow: '1 / span 12'
});
expect(service.getHostStyles('appRoot')).toMatchObject({
'--background': '210 18% 7%'
});
expect(styleElements.some((styleElement) => styleElement.textContent === '.css-only-theme { background: hsl(var(--background)); }')).toBe(true);
});
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');
@@ -185,23 +260,6 @@ describe('ThemeService theme application', () => {
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();
@@ -261,7 +319,7 @@ describe('ThemeService theme application', () => {
}
});
expect(service.activeThemeName()).toBe('Toju Default Dark');
expect(service.activeThemeName()).toBe('Toju Website Dark');
});
it('validates the dedicated DM workspace layout container', () => {
@@ -378,6 +436,32 @@ describe('ThemeService theme application', () => {
expect(service.getHostStyles('titleBar')).toEqual({});
expect(service.activeThemeText()).not.toContain('"elements"');
});
it('loads the website dark saved-theme artifact', () => {
const themeText = readFileSync(resolve('../project-files/themes/toju-website-dark.json'), 'utf8');
const loaded = service.loadThemeText(themeText, 'apply', 'Theme applied.', 'website dark saved theme');
expect(loaded).toBe(true);
expect(service.activeThemeName()).toBe('Toju Website Dark');
expect(service.getHostStyles('appRoot')).toMatchObject({
'--background': '210 18% 7%',
'--primary': '154 49% 55%',
'--accent': '38 64% 61%'
});
expect(service.getHostStyles('titleBar')).toMatchObject({
border: '0',
backgroundColor: 'hsl(var(--title-bar-background) / 0.88)',
backdropFilter: 'var(--theme-effect-glass-blur)'
});
expect(service.getHostStyles('serversRail')).toMatchObject({ border: '0' });
expect(service.getHostStyles('chatRoomChannelsPanel')).toMatchObject({ border: '0' });
expect(service.getHostStyles('chatRoomMembersPanel')).toMatchObject({ border: '0' });
expect(service.getHostStyles('chatComposerBar')).toMatchObject({ border: '0' });
expect(service.activeThemeText()).toContain('Website-inspired app shell surfaces');
});
});
interface TestStyleElement {

View File

@@ -15,6 +15,7 @@ import {
ThemeElementStyles
} from '../../domain/models/theme.model';
import {
BUILT_IN_THEME_PRESETS,
DEFAULT_THEME_JSON,
createDefaultThemeDocument,
createDefaultThemeLayout,
@@ -137,6 +138,7 @@ export class ThemeService {
readonly activeThemeName: Signal<string>;
readonly knownAnimationClasses: Signal<string[]>;
readonly isDraftDirty: Signal<boolean>;
readonly builtInPresets = BUILT_IN_THEME_PRESETS;
private readonly documentRef = inject(DOCUMENT);
@@ -325,6 +327,26 @@ export class ThemeService {
this.setStatusMessage(reason === 'shortcut' ? 'Theme reset to the default preset by shortcut.' : 'Theme reset to the default preset.');
}
applyBuiltInPreset(name: string): boolean {
const preset = this.builtInPresets.find((builtInPreset) => builtInPreset.theme.meta.name === name || builtInPreset.key === name);
if (!preset) {
this.setStatusMessage('Built-in theme preset not found.');
return false;
}
const theme = structuredClone(preset.theme);
const result = validateThemeDocument(theme);
if (!result.valid || !result.value) {
this.setStatusMessage(result.errors[0] ?? 'Built-in theme preset could not be applied.');
return false;
}
this.commitTheme(result.value, stringifyTheme(result.value), `${result.value.meta.name} preset applied.`);
return true;
}
handleGlobalShortcut(event: KeyboardEvent): boolean {
const usesModifier = event.ctrlKey || event.metaKey;