feat: Theme engine
big changes
This commit is contained in:
@@ -39,6 +39,13 @@ import {
|
||||
getWindowIconPath,
|
||||
updateCloseToTraySetting
|
||||
} from '../window/create-window';
|
||||
import {
|
||||
deleteSavedTheme,
|
||||
getSavedThemesPath,
|
||||
listSavedThemes,
|
||||
readSavedTheme,
|
||||
writeSavedTheme
|
||||
} from '../theme-library';
|
||||
|
||||
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||
const FILE_CLIPBOARD_FORMATS = [
|
||||
@@ -325,6 +332,15 @@ export function setupSystemHandlers(): void {
|
||||
});
|
||||
|
||||
ipcMain.handle('get-app-data-path', () => app.getPath('userData'));
|
||||
ipcMain.handle('get-saved-themes-path', async () => await getSavedThemesPath());
|
||||
ipcMain.handle('list-saved-themes', async () => await listSavedThemes());
|
||||
ipcMain.handle('read-saved-theme', async (_event, fileName: string) => await readSavedTheme(fileName));
|
||||
ipcMain.handle('write-saved-theme', async (_event, fileName: string, text: string) => {
|
||||
return await writeSavedTheme(fileName, text);
|
||||
});
|
||||
ipcMain.handle('delete-saved-theme', async (_event, fileName: string) => {
|
||||
return await deleteSavedTheme(fileName);
|
||||
});
|
||||
|
||||
ipcMain.handle('get-desktop-settings', () => getDesktopSettingsSnapshot());
|
||||
|
||||
|
||||
@@ -102,6 +102,12 @@ export interface WindowStateSnapshot {
|
||||
isMinimized: boolean;
|
||||
}
|
||||
|
||||
export interface SavedThemeFileDescriptor {
|
||||
fileName: string;
|
||||
modifiedAt: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
function readLinuxDisplayServer(): string {
|
||||
if (process.platform !== 'linux') {
|
||||
return 'N/A';
|
||||
@@ -134,6 +140,11 @@ export interface ElectronAPI {
|
||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||
getAppDataPath: () => Promise<string>;
|
||||
getSavedThemesPath: () => Promise<string>;
|
||||
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
||||
readSavedTheme: (fileName: string) => Promise<string>;
|
||||
writeSavedTheme: (fileName: string, text: string) => Promise<boolean>;
|
||||
deleteSavedTheme: (fileName: string) => Promise<boolean>;
|
||||
consumePendingDeepLink: () => Promise<string | null>;
|
||||
getDesktopSettings: () => Promise<{
|
||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||
@@ -230,6 +241,11 @@ const electronAPI: ElectronAPI = {
|
||||
};
|
||||
},
|
||||
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
||||
getSavedThemesPath: () => ipcRenderer.invoke('get-saved-themes-path'),
|
||||
listSavedThemes: () => ipcRenderer.invoke('list-saved-themes'),
|
||||
readSavedTheme: (fileName) => ipcRenderer.invoke('read-saved-theme', fileName),
|
||||
writeSavedTheme: (fileName, text) => ipcRenderer.invoke('write-saved-theme', fileName, text),
|
||||
deleteSavedTheme: (fileName) => ipcRenderer.invoke('delete-saved-theme', fileName),
|
||||
consumePendingDeepLink: () => ipcRenderer.invoke('consume-pending-deep-link'),
|
||||
getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'),
|
||||
showDesktopNotification: (payload) => ipcRenderer.invoke('show-desktop-notification', payload),
|
||||
|
||||
92
electron/theme-library.ts
Normal file
92
electron/theme-library.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { app } from 'electron';
|
||||
import * as fsp from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface SavedThemeFileDescriptor {
|
||||
fileName: string;
|
||||
modifiedAt: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const SAVED_THEME_FILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\.json$/;
|
||||
|
||||
function resolveSavedThemesPath(): string {
|
||||
return path.join(app.getPath('userData'), 'themes');
|
||||
}
|
||||
|
||||
async function ensureSavedThemesPath(): Promise<string> {
|
||||
const themesPath = resolveSavedThemesPath();
|
||||
|
||||
await fsp.mkdir(themesPath, { recursive: true });
|
||||
|
||||
return themesPath;
|
||||
}
|
||||
|
||||
function assertSavedThemeFileName(fileName: string): string {
|
||||
const normalized = typeof fileName === 'string'
|
||||
? fileName.trim()
|
||||
: '';
|
||||
|
||||
if (!SAVED_THEME_FILE_NAME_PATTERN.test(normalized) || normalized.includes('..')) {
|
||||
throw new Error('Invalid saved theme file name.');
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function resolveSavedThemeFilePath(fileName: string): Promise<string> {
|
||||
const themesPath = await ensureSavedThemesPath();
|
||||
|
||||
return path.join(themesPath, assertSavedThemeFileName(fileName));
|
||||
}
|
||||
|
||||
export async function getSavedThemesPath(): Promise<string> {
|
||||
return await ensureSavedThemesPath();
|
||||
}
|
||||
|
||||
export async function listSavedThemes(): Promise<SavedThemeFileDescriptor[]> {
|
||||
const themesPath = await ensureSavedThemesPath();
|
||||
const entries = await fsp.readdir(themesPath, { withFileTypes: true });
|
||||
const files = entries.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.json'));
|
||||
|
||||
const descriptors = await Promise.all(files.map(async (entry) => {
|
||||
const filePath = path.join(themesPath, entry.name);
|
||||
const stats = await fsp.stat(filePath);
|
||||
|
||||
return {
|
||||
fileName: entry.name,
|
||||
modifiedAt: Math.round(stats.mtimeMs),
|
||||
path: filePath
|
||||
} satisfies SavedThemeFileDescriptor;
|
||||
}));
|
||||
|
||||
return descriptors.sort((left, right) => right.modifiedAt - left.modifiedAt || left.fileName.localeCompare(right.fileName));
|
||||
}
|
||||
|
||||
export async function readSavedTheme(fileName: string): Promise<string> {
|
||||
const filePath = await resolveSavedThemeFilePath(fileName);
|
||||
|
||||
return await fsp.readFile(filePath, 'utf8');
|
||||
}
|
||||
|
||||
export async function writeSavedTheme(fileName: string, text: string): Promise<boolean> {
|
||||
const filePath = await resolveSavedThemeFilePath(fileName);
|
||||
|
||||
await fsp.writeFile(filePath, text, 'utf8');
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function deleteSavedTheme(fileName: string): Promise<boolean> {
|
||||
const filePath = await resolveSavedThemeFilePath(fileName);
|
||||
|
||||
try {
|
||||
await fsp.unlink(filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as { code?: string }).code === 'ENOENT') {
|
||||
return true;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
184
package-lock.json
generated
184
package-lock.json
generated
@@ -14,6 +14,12 @@
|
||||
"@angular/forms": "^21.0.0",
|
||||
"@angular/platform-browser": "^21.0.0",
|
||||
"@angular/router": "^21.0.0",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.41.0",
|
||||
"@ng-icons/core": "^33.0.0",
|
||||
"@ng-icons/lucide": "^33.0.0",
|
||||
"@ngrx/effects": "^21.0.1",
|
||||
@@ -27,6 +33,7 @@
|
||||
"auto-launch": "^5.0.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"cytoscape": "^3.33.1",
|
||||
"electron-updater": "^6.6.2",
|
||||
"mermaid": "^11.12.3",
|
||||
@@ -2697,6 +2704,109 @@
|
||||
"integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.20.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz",
|
||||
"integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
|
||||
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-json": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
|
||||
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@lezer/json": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.12.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
|
||||
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"style-mod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lint": {
|
||||
"version": "6.9.5",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz",
|
||||
"integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.35.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/search": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz",
|
||||
"integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.37.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
|
||||
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/theme-one-dark": {
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
|
||||
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@lezer/highlight": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.41.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.0.tgz",
|
||||
"integrity": "sha512-6H/qadXsVuDY219Yljhohglve8xf4B8xJkVOEWfA5uiYKiTFppjqsvsfR5iPA0RbvRBoOyTZpbLIxe9+0UR8xA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@develar/schema-utils": {
|
||||
"version": "2.6.5",
|
||||
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
|
||||
@@ -5672,6 +5782,41 @@
|
||||
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz",
|
||||
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/json": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
|
||||
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/lr": {
|
||||
"version": "1.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz",
|
||||
"integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@listr2/prompt-adapter-inquirer": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.5.tgz",
|
||||
@@ -5865,6 +6010,12 @@
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@mermaid-js/parser": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.0.tgz",
|
||||
@@ -14138,6 +14289,21 @@
|
||||
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/codemirror": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
|
||||
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/commands": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/search": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -14766,6 +14932,12 @@
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cron-parser": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
|
||||
@@ -27782,6 +27954,12 @@
|
||||
"webpack": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
||||
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stylehacks": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-6.1.1.tgz",
|
||||
@@ -30374,6 +30552,12 @@
|
||||
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wait-on": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.2.0.tgz",
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
"@angular/forms": "^21.0.0",
|
||||
"@angular/platform-browser": "^21.0.0",
|
||||
"@angular/router": "^21.0.0",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.41.0",
|
||||
"@ng-icons/core": "^33.0.0",
|
||||
"@ng-icons/lucide": "^33.0.0",
|
||||
"@ngrx/effects": "^21.0.1",
|
||||
@@ -73,6 +79,7 @@
|
||||
"auto-launch": "^5.0.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"cytoscape": "^3.33.1",
|
||||
"electron-updater": "^6.6.2",
|
||||
"mermaid": "^11.12.3",
|
||||
|
||||
Binary file not shown.
@@ -97,7 +97,7 @@
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "1MB",
|
||||
"maximumError": "2.1MB"
|
||||
"maximumError": "2.15MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
|
||||
@@ -1,60 +1,139 @@
|
||||
<div class="workspace-bright-theme relative flex h-screen overflow-hidden bg-background text-foreground">
|
||||
<!-- Global left servers rail always visible -->
|
||||
<aside class="w-16 flex-shrink-0 bg-transparent">
|
||||
<app-servers-rail class="h-full" />
|
||||
</aside>
|
||||
<main class="relative min-w-0 flex-1 overflow-hidden bg-background">
|
||||
<!-- Custom draggable title bar -->
|
||||
<app-title-bar />
|
||||
<div
|
||||
appThemeNode="appRoot"
|
||||
class="workspace-bright-theme relative h-screen overflow-hidden bg-background text-foreground"
|
||||
>
|
||||
<div
|
||||
class="grid h-full min-h-0 min-w-0 overflow-hidden"
|
||||
[ngStyle]="appShellLayoutStyles()"
|
||||
>
|
||||
<aside
|
||||
appThemeNode="serversRail"
|
||||
class="min-h-0 overflow-hidden bg-transparent"
|
||||
[class.hidden]="isThemeStudioFullscreen()"
|
||||
[ngStyle]="serversRailLayoutStyles()"
|
||||
>
|
||||
<app-servers-rail class="block h-full" />
|
||||
</aside>
|
||||
|
||||
@if (desktopUpdateState().restartRequired) {
|
||||
<div class="absolute inset-x-0 top-10 z-20 px-4 pt-4 pointer-events-none">
|
||||
<div class="pointer-events-auto mx-auto max-w-4xl rounded-md border border-border bg-card p-4 shadow-sm">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-foreground">Update ready to install</p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
MetoYou {{ desktopUpdateState().targetVersion || 'update' }} has been downloaded. Restart the app to finish applying it.
|
||||
</p>
|
||||
</div>
|
||||
<main
|
||||
appThemeNode="appWorkspace"
|
||||
class="relative flex min-h-0 min-w-0 flex-col overflow-hidden bg-background"
|
||||
[ngStyle]="appWorkspaceShellStyles()"
|
||||
>
|
||||
<app-title-bar class="block shrink-0" />
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
(click)="openUpdatesSettings()"
|
||||
class="inline-flex items-center rounded-md border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
Update settings
|
||||
</button>
|
||||
<div class="relative min-h-0 flex-1 overflow-hidden">
|
||||
@if (isThemeStudioFullscreen()) {
|
||||
<div class="theme-studio-fullscreen-shell absolute inset-0 overflow-y-auto overflow-x-hidden bg-background">
|
||||
@if (themeStudioFullscreenComponent()) {
|
||||
<ng-container *ngComponentOutlet="themeStudioFullscreenComponent()"></ng-container>
|
||||
} @else {
|
||||
<div class="flex h-full items-center justify-center px-6 text-sm text-muted-foreground">Loading Theme Studio…</div>
|
||||
}
|
||||
</div>
|
||||
} @else { @if (desktopUpdateState().restartRequired) {
|
||||
<div class="pointer-events-none absolute inset-x-0 top-0 z-20 px-4 pt-4">
|
||||
<div class="pointer-events-auto mx-auto max-w-4xl rounded-md border border-border bg-card p-4 shadow-sm">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-foreground">Update ready to install</p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
MetoYou {{ desktopUpdateState().targetVersion || 'update' }} has been downloaded. Restart the app to finish applying it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="restartToApplyUpdate()"
|
||||
class="inline-flex items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Restart now
|
||||
</button>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
(click)="openUpdatesSettings()"
|
||||
class="inline-flex items-center rounded-md border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
Update settings
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="restartToApplyUpdate()"
|
||||
class="inline-flex items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Restart now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="absolute inset-0 overflow-auto">
|
||||
<router-outlet />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Content area fills below the title bar without global scroll -->
|
||||
<div class="absolute inset-x-0 top-10 bottom-0 overflow-auto">
|
||||
<router-outlet />
|
||||
</div>
|
||||
</main>
|
||||
@if (isThemeStudioFullscreen()) {
|
||||
<div
|
||||
#themeStudioControlsRef
|
||||
class="pointer-events-none absolute z-[80]"
|
||||
[ngStyle]="themeStudioControlsPositionStyles()"
|
||||
>
|
||||
<div class="pointer-events-auto flex items-center gap-2 rounded-full border border-border/80 bg-card/95 px-2 py-2 shadow-2xl backdrop-blur">
|
||||
<div
|
||||
class="rounded-full border border-border bg-background px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground select-none cursor-grab"
|
||||
[class.cursor-grabbing]="isDraggingThemeStudioControls()"
|
||||
(pointerdown)="startThemeStudioControlsDrag($event, themeStudioControlsRef)"
|
||||
>
|
||||
Theme Studio
|
||||
</div>
|
||||
|
||||
<!-- Floating voice controls - shown when connected to voice and navigated away from server -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="minimizeThemeStudio()"
|
||||
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary"
|
||||
>
|
||||
Minimize
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="closeThemeStudio()"
|
||||
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @if (isThemeStudioMinimized()) {
|
||||
<div class="pointer-events-none absolute bottom-4 right-4 z-[80]">
|
||||
<div class="pointer-events-auto flex items-center gap-3 rounded-2xl border border-border/80 bg-card/95 px-3 py-3 shadow-2xl backdrop-blur">
|
||||
<div class="min-w-0">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">Theme Studio</p>
|
||||
<p class="mt-1 text-sm font-medium text-foreground">Minimized</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="reopenThemeStudio()"
|
||||
class="rounded-full bg-primary px-3 py-1.5 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Re-open
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="dismissMinimizedThemeStudio()"
|
||||
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @if (!isThemeStudioFullscreen()) {
|
||||
<app-floating-voice-controls />
|
||||
}
|
||||
<app-settings-modal />
|
||||
<app-screen-share-source-picker />
|
||||
<app-debug-console [showLauncher]="false" />
|
||||
<app-theme-picker-overlay />
|
||||
</div>
|
||||
|
||||
<!-- Unified Settings Modal -->
|
||||
<app-settings-modal />
|
||||
|
||||
<!-- Shared Screen Share Source Picker -->
|
||||
<app-screen-share-source-picker />
|
||||
|
||||
<!-- Shared Debug Console -->
|
||||
<app-debug-console [showLauncher]="false" />
|
||||
|
||||
@@ -3,8 +3,12 @@ import {
|
||||
Component,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
HostListener
|
||||
HostListener,
|
||||
signal,
|
||||
Type
|
||||
} from '@angular/core';
|
||||
import {
|
||||
Router,
|
||||
@@ -37,6 +41,11 @@ import {
|
||||
STORAGE_KEY_CURRENT_USER_ID,
|
||||
STORAGE_KEY_LAST_VISITED_ROUTE
|
||||
} from './core/constants';
|
||||
import {
|
||||
ThemeNodeDirective,
|
||||
ThemePickerOverlayComponent,
|
||||
ThemeService
|
||||
} from './domains/theme';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -48,12 +57,17 @@ import {
|
||||
FloatingVoiceControlsComponent,
|
||||
SettingsModalComponent,
|
||||
DebugConsoleComponent,
|
||||
ScreenShareSourcePickerComponent
|
||||
ScreenShareSourcePickerComponent,
|
||||
ThemeNodeDirective,
|
||||
ThemePickerOverlayComponent
|
||||
],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss'
|
||||
})
|
||||
export class App implements OnInit, OnDestroy {
|
||||
private static readonly THEME_STUDIO_CONTROLS_MARGIN = 16;
|
||||
private static readonly TITLE_BAR_HEIGHT = 40;
|
||||
|
||||
store = inject(Store);
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
desktopUpdates = inject(DesktopAppUpdateService);
|
||||
@@ -65,17 +79,120 @@ export class App implements OnInit, OnDestroy {
|
||||
private notifications = inject(NotificationsFacade);
|
||||
private settingsModal = inject(SettingsModalService);
|
||||
private timeSync = inject(TimeSyncService);
|
||||
private theme = inject(ThemeService);
|
||||
private voiceSession = inject(VoiceSessionFacade);
|
||||
private externalLinks = inject(ExternalLinkService);
|
||||
private electronBridge = inject(ElectronBridgeService);
|
||||
private deepLinkCleanup: (() => void) | null = null;
|
||||
private themeStudioControlsDragOffset: { x: number; y: number } | null = null;
|
||||
private themeStudioControlsBounds: { width: number; height: number } | null = null;
|
||||
readonly themeStudioFullscreenComponent = signal<Type<unknown> | null>(null);
|
||||
readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null);
|
||||
readonly isDraggingThemeStudioControls = signal(false);
|
||||
|
||||
readonly appShellLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('appShell'));
|
||||
readonly serversRailLayoutStyles = computed(() => this.theme.getLayoutItemStyles('serversRail'));
|
||||
readonly appWorkspaceLayoutStyles = computed(() => this.theme.getLayoutItemStyles('appWorkspace'));
|
||||
readonly isThemeStudioFullscreen = computed(() => {
|
||||
return this.settingsModal.isOpen()
|
||||
&& this.settingsModal.activePage() === 'theme'
|
||||
&& this.settingsModal.themeStudioFullscreen();
|
||||
});
|
||||
readonly isThemeStudioMinimized = computed(() => {
|
||||
return this.settingsModal.activePage() === 'theme'
|
||||
&& this.settingsModal.themeStudioMinimized();
|
||||
});
|
||||
readonly appWorkspaceShellStyles = computed(() => {
|
||||
const workspaceStyles = this.appWorkspaceLayoutStyles();
|
||||
|
||||
if (!this.isThemeStudioFullscreen()) {
|
||||
return workspaceStyles;
|
||||
}
|
||||
|
||||
return {
|
||||
...workspaceStyles,
|
||||
gridColumn: '1 / -1',
|
||||
gridRow: '1 / -1'
|
||||
};
|
||||
});
|
||||
readonly themeStudioControlsPositionStyles = computed(() => {
|
||||
const position = this.themeStudioControlsPosition();
|
||||
|
||||
if (!position) {
|
||||
return {
|
||||
right: `${App.THEME_STUDIO_CONTROLS_MARGIN}px`,
|
||||
bottom: `${App.THEME_STUDIO_CONTROLS_MARGIN}px`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`
|
||||
};
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (!this.isThemeStudioFullscreen() || this.themeStudioFullscreenComponent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void import('./domains/theme/feature/settings/theme-settings.component')
|
||||
.then((module) => {
|
||||
this.themeStudioFullscreenComponent.set(module.ThemeSettingsComponent);
|
||||
});
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (this.isThemeStudioFullscreen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isDraggingThemeStudioControls.set(false);
|
||||
this.themeStudioControlsDragOffset = null;
|
||||
this.themeStudioControlsBounds = null;
|
||||
});
|
||||
}
|
||||
|
||||
@HostListener('document:click', ['$event'])
|
||||
onGlobalLinkClick(evt: MouseEvent): void {
|
||||
this.externalLinks.handleClick(evt);
|
||||
}
|
||||
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
onGlobalKeyDown(evt: KeyboardEvent): void {
|
||||
this.theme.handleGlobalShortcut(evt);
|
||||
}
|
||||
|
||||
@HostListener('document:pointermove', ['$event'])
|
||||
onDocumentPointerMove(event: PointerEvent): void {
|
||||
if (!this.isDraggingThemeStudioControls() || !this.themeStudioControlsDragOffset || !this.themeStudioControlsBounds) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.themeStudioControlsPosition.set(this.clampThemeStudioControlsPosition(
|
||||
event.clientX - this.themeStudioControlsDragOffset.x,
|
||||
event.clientY - this.themeStudioControlsDragOffset.y,
|
||||
this.themeStudioControlsBounds.width,
|
||||
this.themeStudioControlsBounds.height
|
||||
));
|
||||
}
|
||||
|
||||
@HostListener('document:pointerup')
|
||||
@HostListener('document:pointercancel')
|
||||
onDocumentPointerEnd(): void {
|
||||
if (!this.isDraggingThemeStudioControls()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isDraggingThemeStudioControls.set(false);
|
||||
this.themeStudioControlsDragOffset = null;
|
||||
this.themeStudioControlsBounds = null;
|
||||
}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.theme.initialize();
|
||||
|
||||
void this.desktopUpdates.initialize();
|
||||
|
||||
await this.databaseService.initialize();
|
||||
@@ -143,6 +260,45 @@ export class App implements OnInit, OnDestroy {
|
||||
this.settingsModal.open('updates');
|
||||
}
|
||||
|
||||
startThemeStudioControlsDrag(event: PointerEvent, controlsElement: HTMLElement): void {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = controlsElement.getBoundingClientRect();
|
||||
|
||||
this.themeStudioControlsBounds = {
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
};
|
||||
this.themeStudioControlsDragOffset = {
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top
|
||||
};
|
||||
this.themeStudioControlsPosition.set({
|
||||
x: rect.left,
|
||||
y: rect.top
|
||||
});
|
||||
this.isDraggingThemeStudioControls.set(true);
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
reopenThemeStudio(): void {
|
||||
this.settingsModal.restoreMinimizedThemeStudio();
|
||||
}
|
||||
|
||||
minimizeThemeStudio(): void {
|
||||
this.settingsModal.minimizeThemeStudio();
|
||||
}
|
||||
|
||||
dismissMinimizedThemeStudio(): void {
|
||||
this.settingsModal.dismissMinimizedThemeStudio();
|
||||
}
|
||||
|
||||
closeThemeStudio(): void {
|
||||
this.settingsModal.close();
|
||||
}
|
||||
|
||||
async refreshDesktopUpdateContext(): Promise<void> {
|
||||
await this.desktopUpdates.refreshServerContext();
|
||||
}
|
||||
@@ -151,6 +307,18 @@ export class App implements OnInit, OnDestroy {
|
||||
await this.desktopUpdates.restartToApplyUpdate();
|
||||
}
|
||||
|
||||
private clampThemeStudioControlsPosition(x: number, y: number, width: number, height: number): { x: number; y: number } {
|
||||
const minX = App.THEME_STUDIO_CONTROLS_MARGIN;
|
||||
const minY = App.TITLE_BAR_HEIGHT + App.THEME_STUDIO_CONTROLS_MARGIN;
|
||||
const maxX = Math.max(minX, window.innerWidth - width - App.THEME_STUDIO_CONTROLS_MARGIN);
|
||||
const maxY = Math.max(minY, window.innerHeight - height - App.THEME_STUDIO_CONTROLS_MARGIN);
|
||||
|
||||
return {
|
||||
x: Math.min(Math.max(minX, x), maxX),
|
||||
y: Math.min(Math.max(minY, y), maxY)
|
||||
};
|
||||
}
|
||||
|
||||
private async setupDesktopDeepLinks(): Promise<void> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings';
|
||||
export const STORAGE_KEY_NOTIFICATION_SETTINGS = 'metoyou_notification_settings';
|
||||
export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_settings';
|
||||
export const STORAGE_KEY_DEBUGGING_SETTINGS = 'metoyou_debugging_settings';
|
||||
export const STORAGE_KEY_THEME_ACTIVE = 'metoyou_theme_active';
|
||||
export const STORAGE_KEY_THEME_DRAFT = 'metoyou_theme_draft';
|
||||
export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes';
|
||||
export const ROOM_URL_PATTERN = /\/room\/([^/]+)/;
|
||||
export const STORE_DEVTOOLS_MAX_AGE = 25;
|
||||
|
||||
@@ -118,6 +118,12 @@ export interface WindowStateSnapshot {
|
||||
isMinimized: boolean;
|
||||
}
|
||||
|
||||
export interface SavedThemeFileDescriptor {
|
||||
fileName: string;
|
||||
modifiedAt: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ElectronCommand {
|
||||
type: string;
|
||||
payload: unknown;
|
||||
@@ -143,6 +149,11 @@ export interface ElectronApi {
|
||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||
getAppDataPath: () => Promise<string>;
|
||||
getSavedThemesPath: () => Promise<string>;
|
||||
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
||||
readSavedTheme: (fileName: string) => Promise<string>;
|
||||
writeSavedTheme: (fileName: string, text: string) => Promise<boolean>;
|
||||
deleteSavedTheme: (fileName: string) => Promise<boolean>;
|
||||
consumePendingDeepLink: () => Promise<string | null>;
|
||||
getDesktopSettings: () => Promise<DesktopSettingsSnapshot>;
|
||||
showDesktopNotification: (payload: DesktopNotificationPayload) => Promise<boolean>;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export type SettingsPage =
|
||||
| 'general'
|
||||
| 'theme'
|
||||
| 'network'
|
||||
| 'notifications'
|
||||
| 'voice'
|
||||
@@ -17,18 +18,59 @@ export class SettingsModalService {
|
||||
readonly isOpen = signal(false);
|
||||
readonly activePage = signal<SettingsPage>('general');
|
||||
readonly targetServerId = signal<string | null>(null);
|
||||
readonly themeStudioFullscreen = signal(false);
|
||||
readonly themeStudioMinimized = signal(false);
|
||||
|
||||
open(page: SettingsPage = 'general', serverId?: string): void {
|
||||
this.themeStudioFullscreen.set(false);
|
||||
this.themeStudioMinimized.set(false);
|
||||
this.activePage.set(page);
|
||||
this.targetServerId.set(serverId ?? null);
|
||||
this.isOpen.set(true);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.themeStudioFullscreen.set(false);
|
||||
this.themeStudioMinimized.set(false);
|
||||
this.isOpen.set(false);
|
||||
}
|
||||
|
||||
navigate(page: SettingsPage): void {
|
||||
this.activePage.set(page);
|
||||
|
||||
if (page !== 'theme') {
|
||||
this.themeStudioFullscreen.set(false);
|
||||
this.themeStudioMinimized.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
setThemeStudioFullscreen(isFullscreen: boolean): void {
|
||||
this.themeStudioFullscreen.set(isFullscreen);
|
||||
}
|
||||
|
||||
toggleThemeStudioFullscreen(): void {
|
||||
this.themeStudioFullscreen.update((isFullscreen) => !isFullscreen);
|
||||
}
|
||||
|
||||
openThemeStudio(): void {
|
||||
this.activePage.set('theme');
|
||||
this.themeStudioMinimized.set(false);
|
||||
this.isOpen.set(true);
|
||||
this.themeStudioFullscreen.set(true);
|
||||
}
|
||||
|
||||
minimizeThemeStudio(): void {
|
||||
this.activePage.set('theme');
|
||||
this.themeStudioFullscreen.set(false);
|
||||
this.themeStudioMinimized.set(true);
|
||||
this.isOpen.set(false);
|
||||
}
|
||||
|
||||
restoreMinimizedThemeStudio(): void {
|
||||
this.openThemeStudio();
|
||||
}
|
||||
|
||||
dismissMinimizedThemeStudio(): void {
|
||||
this.themeStudioMinimized.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ infrastructure adapters and UI.
|
||||
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
|
||||
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
|
||||
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
|
||||
| **theme** | JSON-driven theming, element registry, layout syncing, picker tooling, and Electron saved-theme library management | `ThemeService` |
|
||||
| **voice-connection** | Voice activity detection, bitrate profiles, in-channel camera transport | `VoiceConnectionFacade` |
|
||||
| **voice-session** | Join/leave orchestration, voice settings persistence | `VoiceSessionFacade` |
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
|
||||
import {
|
||||
SettingsModalService,
|
||||
type SettingsPage
|
||||
} from '../../../core/services/settings-modal.service';
|
||||
import { ThemeRegistryService } from './theme-registry.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ElementPickerService {
|
||||
private readonly documentRef = inject(DOCUMENT);
|
||||
private readonly modal = inject(SettingsModalService);
|
||||
private readonly registry = inject(ThemeRegistryService);
|
||||
|
||||
private removeListeners: Array<() => void> = [];
|
||||
private resumePage: SettingsPage | null = null;
|
||||
private shouldRestoreModalOnCancel = true;
|
||||
|
||||
readonly isPicking = signal(false);
|
||||
readonly hoveredKey = signal<string | null>(null);
|
||||
readonly selectedKey = signal<string | null>(null);
|
||||
|
||||
start(page: SettingsPage = 'theme', restoreModalOnCancel = true): void {
|
||||
if (this.isPicking()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.resumePage = page;
|
||||
this.shouldRestoreModalOnCancel = restoreModalOnCancel;
|
||||
this.modal.close();
|
||||
this.attachListeners();
|
||||
this.hoveredKey.set(null);
|
||||
this.isPicking.set(true);
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (!this.isPicking()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.detachListeners();
|
||||
this.hoveredKey.set(null);
|
||||
this.isPicking.set(false);
|
||||
|
||||
if (this.shouldRestoreModalOnCancel && this.resumePage) {
|
||||
this.modal.open(this.resumePage);
|
||||
}
|
||||
}
|
||||
|
||||
clearSelection(): void {
|
||||
this.selectedKey.set(null);
|
||||
}
|
||||
|
||||
private completePick(key: string): void {
|
||||
this.selectedKey.set(key);
|
||||
this.detachListeners();
|
||||
this.hoveredKey.set(null);
|
||||
this.isPicking.set(false);
|
||||
|
||||
if (this.resumePage) {
|
||||
this.modal.open(this.resumePage);
|
||||
}
|
||||
}
|
||||
|
||||
private attachListeners(): void {
|
||||
const onPointerMove = (event: Event) => {
|
||||
const key = this.resolveThemeKeyFromTarget(event.target);
|
||||
|
||||
this.hoveredKey.set(key);
|
||||
};
|
||||
|
||||
const onClick = (event: Event) => {
|
||||
const key = this.resolveThemeKeyFromTarget(event.target);
|
||||
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if ('stopImmediatePropagation' in event) {
|
||||
(event as Event & { stopImmediatePropagation(): void }).stopImmediatePropagation();
|
||||
}
|
||||
|
||||
this.completePick(key);
|
||||
};
|
||||
|
||||
const onKeyDown = (event: Event) => {
|
||||
const keyboardEvent = event as KeyboardEvent;
|
||||
|
||||
if (keyboardEvent.key === 'Escape') {
|
||||
keyboardEvent.preventDefault();
|
||||
keyboardEvent.stopPropagation();
|
||||
this.cancel();
|
||||
}
|
||||
};
|
||||
|
||||
this.documentRef.addEventListener('pointermove', onPointerMove, true);
|
||||
this.documentRef.addEventListener('click', onClick, true);
|
||||
this.documentRef.addEventListener('keydown', onKeyDown, true);
|
||||
|
||||
this.removeListeners = [
|
||||
() => this.documentRef.removeEventListener('pointermove', onPointerMove, true),
|
||||
() => this.documentRef.removeEventListener('click', onClick, true),
|
||||
() => this.documentRef.removeEventListener('keydown', onKeyDown, true)
|
||||
];
|
||||
}
|
||||
|
||||
private detachListeners(): void {
|
||||
for (const removeListener of this.removeListeners) {
|
||||
removeListener();
|
||||
}
|
||||
|
||||
this.removeListeners = [];
|
||||
}
|
||||
|
||||
private resolveThemeKeyFromTarget(target: EventTarget | null): string | null {
|
||||
if (!(target instanceof Element)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const themedElement = target.closest<HTMLElement>('[data-theme-key]');
|
||||
const key = themedElement?.dataset['themeKey'] ?? null;
|
||||
const definition = this.registry.getDefinition(key);
|
||||
|
||||
return definition?.pickerVisible
|
||||
? key
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Injectable, computed, inject } from '@angular/core';
|
||||
|
||||
import {
|
||||
ThemeContainerKey,
|
||||
ThemeGridEditorItem,
|
||||
ThemeGridRect
|
||||
} from '../domain/theme.models';
|
||||
import { createDefaultThemeDocument } from '../domain/theme.defaults';
|
||||
import { ThemeRegistryService } from './theme-registry.service';
|
||||
import { ThemeService } from './theme.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LayoutSyncService {
|
||||
private readonly registry = inject(ThemeRegistryService);
|
||||
private readonly theme = inject(ThemeService);
|
||||
|
||||
readonly draftLayout = computed(() => this.theme.draftTheme().layout);
|
||||
|
||||
containers() {
|
||||
return this.registry.layoutContainers();
|
||||
}
|
||||
|
||||
itemsForContainer(containerKey: ThemeContainerKey): ThemeGridEditorItem[] {
|
||||
const draftTheme = this.theme.draftTheme();
|
||||
const defaults = createDefaultThemeDocument();
|
||||
|
||||
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
|
||||
}));
|
||||
}
|
||||
|
||||
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.`);
|
||||
}
|
||||
|
||||
resetContainer(containerKey: ThemeContainerKey): void {
|
||||
const defaults = createDefaultThemeDocument();
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
}, true, `${containerKey} restored to its default layout.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import type { SavedThemeSummary } from '../domain/theme.models';
|
||||
import { ThemeLibraryStorageService } from '../infrastructure/theme-library.storage';
|
||||
import { ThemeService } from './theme.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ThemeLibraryService {
|
||||
private readonly storage = inject(ThemeLibraryStorageService);
|
||||
private readonly theme = inject(ThemeService);
|
||||
|
||||
readonly isAvailable = signal(this.storage.isAvailable);
|
||||
readonly entries = signal<SavedThemeSummary[]>([]);
|
||||
readonly selectedFileName = signal<string | null>(null);
|
||||
readonly isBusy = signal(false);
|
||||
readonly savedThemesPath = signal<string | null>(null);
|
||||
readonly selectedEntry = computed(() => {
|
||||
const selectedFileName = this.selectedFileName();
|
||||
|
||||
return selectedFileName
|
||||
? this.entries().find((entry) => entry.fileName === selectedFileName) ?? null
|
||||
: null;
|
||||
});
|
||||
|
||||
async refresh(preferredSelection?: string | null): Promise<void> {
|
||||
if (!this.isAvailable() || this.isBusy()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isBusy.set(true);
|
||||
|
||||
try {
|
||||
await this.refreshEntries(preferredSelection);
|
||||
} catch {
|
||||
this.theme.announceStatus('Unable to refresh the saved themes library.');
|
||||
} finally {
|
||||
this.isBusy.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
select(fileName: string | null): void {
|
||||
this.selectedFileName.set(fileName);
|
||||
}
|
||||
|
||||
async useSelectedTheme(): Promise<boolean> {
|
||||
const selectedEntry = this.selectedEntry();
|
||||
|
||||
if (!selectedEntry || this.isBusy()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const themeText = await this.storage.readThemeText(selectedEntry.fileName);
|
||||
|
||||
if (!themeText) {
|
||||
this.theme.announceStatus('Unable to read the selected saved theme.');
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.theme.loadThemeText(themeText, 'apply', `${selectedEntry.themeName} applied from saved themes.`, 'saved theme');
|
||||
}
|
||||
|
||||
async openSelectedThemeInDraft(): Promise<boolean> {
|
||||
const selectedEntry = this.selectedEntry();
|
||||
|
||||
if (!selectedEntry || this.isBusy()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const themeText = await this.storage.readThemeText(selectedEntry.fileName);
|
||||
|
||||
if (!themeText) {
|
||||
this.theme.announceStatus('Unable to read the selected saved theme.');
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.theme.loadThemeText(themeText, 'draft', `${selectedEntry.themeName} loaded into the draft editor.`, 'saved theme');
|
||||
}
|
||||
|
||||
async saveDraftAsNewTheme(): Promise<string | null> {
|
||||
if (this.isBusy()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.theme.draftIsValid()) {
|
||||
this.theme.announceStatus('Fix JSON errors before saving a theme.');
|
||||
return null;
|
||||
}
|
||||
|
||||
this.isBusy.set(true);
|
||||
|
||||
try {
|
||||
const fileName = await this.storage.saveNewTheme(this.theme.draftTheme().meta.name, this.theme.draftText());
|
||||
|
||||
if (!fileName) {
|
||||
this.theme.announceStatus('Unable to save the current draft as a new theme.');
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.refreshEntries(fileName);
|
||||
this.theme.announceStatus(`${this.theme.draftTheme().meta.name} saved to the Electron themes folder.`);
|
||||
return fileName;
|
||||
} finally {
|
||||
this.isBusy.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async saveDraftToSelectedTheme(): Promise<boolean> {
|
||||
const selectedEntry = this.selectedEntry();
|
||||
|
||||
if (!selectedEntry || this.isBusy()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.theme.draftIsValid()) {
|
||||
this.theme.announceStatus('Fix JSON errors before updating a saved theme.');
|
||||
return false;
|
||||
}
|
||||
|
||||
this.isBusy.set(true);
|
||||
|
||||
try {
|
||||
const saved = await this.storage.overwriteTheme(selectedEntry.fileName, this.theme.draftText());
|
||||
|
||||
if (!saved) {
|
||||
this.theme.announceStatus('Unable to update the selected saved theme.');
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.refreshEntries(selectedEntry.fileName);
|
||||
this.theme.announceStatus(`${selectedEntry.themeName} updated in the Electron themes folder.`);
|
||||
return true;
|
||||
} finally {
|
||||
this.isBusy.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async removeSelectedTheme(): Promise<boolean> {
|
||||
const selectedEntry = this.selectedEntry();
|
||||
|
||||
if (!selectedEntry || this.isBusy()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.isBusy.set(true);
|
||||
|
||||
try {
|
||||
const deleted = await this.storage.deleteTheme(selectedEntry.fileName);
|
||||
|
||||
if (!deleted) {
|
||||
this.theme.announceStatus('Unable to remove the selected saved theme.');
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.refreshEntries(null);
|
||||
this.theme.announceStatus(`${selectedEntry.themeName} removed from saved themes.`);
|
||||
return true;
|
||||
} finally {
|
||||
this.isBusy.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshEntries(preferredSelection?: string | null): Promise<void> {
|
||||
const entries = await this.storage.listThemes();
|
||||
const savedThemesPath = await this.storage.getSavedThemesPath();
|
||||
const nextSelection = preferredSelection ?? this.selectedFileName();
|
||||
|
||||
this.entries.set(entries);
|
||||
this.savedThemesPath.set(savedThemesPath);
|
||||
|
||||
if (nextSelection && entries.some((entry) => entry.fileName === nextSelection)) {
|
||||
this.selectedFileName.set(nextSelection);
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedFileName.set(entries[0]?.fileName ?? null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
import {
|
||||
ThemeLayoutContainerDefinition,
|
||||
ThemeRegistryEntry
|
||||
} from '../domain/theme.models';
|
||||
import {
|
||||
THEME_LAYOUT_CONTAINERS,
|
||||
THEME_REGISTRY,
|
||||
findThemeLayoutContainer,
|
||||
findThemeRegistryEntry
|
||||
} from '../domain/theme.registry';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ThemeRegistryService {
|
||||
private readonly mountedCounts = signal<Record<string, number>>({});
|
||||
private readonly mountedHosts = new Map<string, Set<HTMLElement>>();
|
||||
|
||||
entries(): readonly ThemeRegistryEntry[] {
|
||||
return THEME_REGISTRY;
|
||||
}
|
||||
|
||||
layoutContainers(): readonly ThemeLayoutContainerDefinition[] {
|
||||
return THEME_LAYOUT_CONTAINERS;
|
||||
}
|
||||
|
||||
mountedKeyCounts() {
|
||||
return this.mountedCounts();
|
||||
}
|
||||
|
||||
getDefinition(key: string | null | undefined): ThemeRegistryEntry | null {
|
||||
return key
|
||||
? findThemeRegistryEntry(key)
|
||||
: null;
|
||||
}
|
||||
|
||||
getContainer(key: string | null | undefined): ThemeLayoutContainerDefinition | null {
|
||||
return key
|
||||
? findThemeLayoutContainer(key)
|
||||
: null;
|
||||
}
|
||||
|
||||
registerHost(key: string, host: HTMLElement): void {
|
||||
const existingHosts = this.mountedHosts.get(key) ?? new Set<HTMLElement>();
|
||||
|
||||
existingHosts.add(host);
|
||||
this.mountedHosts.set(key, existingHosts);
|
||||
this.syncMountedCounts();
|
||||
}
|
||||
|
||||
unregisterHost(key: string, host: HTMLElement): void {
|
||||
const existingHosts = this.mountedHosts.get(key);
|
||||
|
||||
if (!existingHosts) {
|
||||
return;
|
||||
}
|
||||
|
||||
existingHosts.delete(host);
|
||||
|
||||
if (existingHosts.size === 0) {
|
||||
this.mountedHosts.delete(key);
|
||||
}
|
||||
|
||||
this.syncMountedCounts();
|
||||
}
|
||||
|
||||
firstMountedHost(key: string): HTMLElement | null {
|
||||
const hosts = this.mountedHosts.get(key);
|
||||
|
||||
return hosts
|
||||
? Array.from(hosts)[0] ?? null
|
||||
: null;
|
||||
}
|
||||
|
||||
isMounted(key: string): boolean {
|
||||
return (this.mountedCounts()[key] ?? 0) > 0;
|
||||
}
|
||||
|
||||
private syncMountedCounts(): void {
|
||||
const nextCounts: Record<string, number> = {};
|
||||
|
||||
for (const [key, hosts] of this.mountedHosts.entries()) {
|
||||
nextCounts[key] = hosts.size;
|
||||
}
|
||||
|
||||
this.mountedCounts.set(nextCounts);
|
||||
}
|
||||
}
|
||||
515
toju-app/src/app/domains/theme/application/theme.service.ts
Normal file
515
toju-app/src/app/domains/theme/application/theme.service.ts
Normal file
@@ -0,0 +1,515 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
|
||||
import {
|
||||
ThemeAnimationDefinition,
|
||||
ThemeContainerKey,
|
||||
ThemeDocument,
|
||||
ThemeElementStyleProperty,
|
||||
ThemeElementStyles
|
||||
} from '../domain/theme.models';
|
||||
import {
|
||||
DEFAULT_THEME_JSON,
|
||||
createDefaultThemeDocument,
|
||||
isLegacyDefaultThemeDocument
|
||||
} from '../domain/theme.defaults';
|
||||
import {
|
||||
createAnimationStarterDefinition
|
||||
} from '../domain/theme.schema';
|
||||
import {
|
||||
findThemeLayoutContainer
|
||||
} from '../domain/theme.registry';
|
||||
import { validateThemeDocument } from '../domain/theme.validation';
|
||||
import {
|
||||
loadThemeStorageSnapshot,
|
||||
saveActiveThemeText,
|
||||
saveDraftThemeText
|
||||
} from '../infrastructure/theme.storage';
|
||||
|
||||
function toKebabCase(value: string): string {
|
||||
return value
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
||||
.replace(/[_\s]+/g, '-')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function stringifyTheme(document: ThemeDocument): string {
|
||||
return JSON.stringify(document, null, 2);
|
||||
}
|
||||
|
||||
function resolveBuiltInDefaultMigration(document: ThemeDocument): ThemeDocument {
|
||||
return isLegacyDefaultThemeDocument(document)
|
||||
? createDefaultThemeDocument()
|
||||
: document;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ThemeService {
|
||||
private readonly documentRef = inject(DOCUMENT);
|
||||
|
||||
private readonly activeThemeInternal = signal<ThemeDocument>(createDefaultThemeDocument());
|
||||
private readonly activeThemeTextInternal = signal(DEFAULT_THEME_JSON);
|
||||
private readonly draftThemeInternal = signal<ThemeDocument>(createDefaultThemeDocument());
|
||||
private readonly draftTextInternal = signal(DEFAULT_THEME_JSON);
|
||||
private readonly draftIsValidInternal = signal(true);
|
||||
private readonly draftErrorsInternal = signal<string[]>([]);
|
||||
private readonly statusMessageInternal = signal<string | null>(null);
|
||||
|
||||
private initialized = false;
|
||||
private statusTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
private animationStyleElement: HTMLStyleElement | null = null;
|
||||
|
||||
readonly activeTheme = this.activeThemeInternal.asReadonly();
|
||||
readonly activeThemeText = this.activeThemeTextInternal.asReadonly();
|
||||
readonly draftTheme = this.draftThemeInternal.asReadonly();
|
||||
readonly draftText = this.draftTextInternal.asReadonly();
|
||||
readonly draftIsValid = this.draftIsValidInternal.asReadonly();
|
||||
readonly draftErrors = this.draftErrorsInternal.asReadonly();
|
||||
readonly statusMessage = this.statusMessageInternal.asReadonly();
|
||||
readonly activeThemeName = computed(() => this.activeThemeInternal().meta.name);
|
||||
readonly knownAnimationClasses = computed(() => Object.keys(this.draftThemeInternal().animations));
|
||||
readonly isDraftDirty = computed(() => {
|
||||
return this.draftTextInternal().trim() !== this.activeThemeTextInternal().trim();
|
||||
});
|
||||
|
||||
initialize(): void {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
|
||||
const storageSnapshot = loadThemeStorageSnapshot();
|
||||
const activeText = storageSnapshot.activeText ?? DEFAULT_THEME_JSON;
|
||||
const activeResult = this.parseAndValidateTheme(activeText, 'saved active theme');
|
||||
|
||||
if (activeResult.valid && activeResult.value) {
|
||||
const resolvedTheme = resolveBuiltInDefaultMigration(activeResult.value);
|
||||
const formatted = stringifyTheme(resolvedTheme);
|
||||
|
||||
this.activeThemeInternal.set(resolvedTheme);
|
||||
this.activeThemeTextInternal.set(formatted);
|
||||
saveActiveThemeText(formatted);
|
||||
} else {
|
||||
const defaultTheme = createDefaultThemeDocument();
|
||||
const defaultText = stringifyTheme(defaultTheme);
|
||||
|
||||
this.activeThemeInternal.set(defaultTheme);
|
||||
this.activeThemeTextInternal.set(defaultText);
|
||||
saveActiveThemeText(defaultText);
|
||||
}
|
||||
|
||||
const draftText = storageSnapshot.draftText ?? this.activeThemeTextInternal();
|
||||
const draftResult = this.parseAndValidateTheme(draftText, 'saved draft theme');
|
||||
|
||||
if (draftResult.valid && draftResult.value) {
|
||||
const resolvedDraftTheme = resolveBuiltInDefaultMigration(draftResult.value);
|
||||
const formattedDraft = stringifyTheme(resolvedDraftTheme);
|
||||
|
||||
this.draftThemeInternal.set(resolvedDraftTheme);
|
||||
this.draftTextInternal.set(formattedDraft);
|
||||
this.draftIsValidInternal.set(true);
|
||||
this.draftErrorsInternal.set([]);
|
||||
saveDraftThemeText(formattedDraft);
|
||||
} else {
|
||||
this.draftThemeInternal.set(this.activeThemeInternal());
|
||||
this.draftTextInternal.set(this.activeThemeTextInternal());
|
||||
this.draftIsValidInternal.set(false);
|
||||
this.draftErrorsInternal.set(draftResult.errors);
|
||||
}
|
||||
|
||||
this.syncAnimationStylesheet();
|
||||
}
|
||||
|
||||
updateDraftText(text: string): void {
|
||||
this.draftTextInternal.set(text);
|
||||
saveDraftThemeText(text);
|
||||
|
||||
const result = this.parseAndValidateTheme(text, 'theme draft');
|
||||
|
||||
if (result.valid && result.value) {
|
||||
this.draftThemeInternal.set(result.value);
|
||||
this.draftIsValidInternal.set(true);
|
||||
this.draftErrorsInternal.set([]);
|
||||
return;
|
||||
}
|
||||
|
||||
this.draftIsValidInternal.set(false);
|
||||
this.draftErrorsInternal.set(result.errors);
|
||||
}
|
||||
|
||||
formatDraft(): void {
|
||||
if (!this.draftIsValidInternal()) {
|
||||
this.setStatusMessage('Fix JSON errors before formatting the theme draft.');
|
||||
return;
|
||||
}
|
||||
|
||||
const formatted = stringifyTheme(this.draftThemeInternal());
|
||||
|
||||
this.draftTextInternal.set(formatted);
|
||||
saveDraftThemeText(formatted);
|
||||
this.setStatusMessage('Theme draft formatted.');
|
||||
}
|
||||
|
||||
applyDraft(): boolean {
|
||||
if (!this.draftIsValidInternal()) {
|
||||
this.setStatusMessage('The current draft has validation errors. The previous working theme is still active.');
|
||||
return false;
|
||||
}
|
||||
|
||||
const formatted = stringifyTheme(this.draftThemeInternal());
|
||||
|
||||
this.commitTheme(this.draftThemeInternal(), formatted, 'Theme applied.');
|
||||
return true;
|
||||
}
|
||||
|
||||
loadThemeText(
|
||||
text: string,
|
||||
mode: 'draft' | 'apply',
|
||||
successMessage: string,
|
||||
sourceLabel = 'theme'
|
||||
): boolean {
|
||||
const result = this.parseAndValidateTheme(text, sourceLabel);
|
||||
|
||||
if (!result.valid || !result.value) {
|
||||
this.setStatusMessage(result.errors[0] ?? `The ${sourceLabel} could not be loaded.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolvedTheme = resolveBuiltInDefaultMigration(result.value);
|
||||
const formatted = stringifyTheme(resolvedTheme);
|
||||
|
||||
if (mode === 'apply') {
|
||||
this.commitTheme(resolvedTheme, formatted, successMessage);
|
||||
return true;
|
||||
}
|
||||
|
||||
this.draftThemeInternal.set(resolvedTheme);
|
||||
this.draftTextInternal.set(formatted);
|
||||
this.draftIsValidInternal.set(true);
|
||||
this.draftErrorsInternal.set([]);
|
||||
saveDraftThemeText(formatted);
|
||||
this.setStatusMessage(successMessage);
|
||||
return true;
|
||||
}
|
||||
|
||||
announceStatus(message: string): void {
|
||||
this.setStatusMessage(message);
|
||||
}
|
||||
|
||||
resetToDefault(reason: 'button' | 'shortcut' = 'button'): void {
|
||||
const defaultTheme = createDefaultThemeDocument();
|
||||
const defaultText = stringifyTheme(defaultTheme);
|
||||
|
||||
this.activeThemeInternal.set(defaultTheme);
|
||||
this.activeThemeTextInternal.set(defaultText);
|
||||
this.draftThemeInternal.set(defaultTheme);
|
||||
this.draftTextInternal.set(defaultText);
|
||||
this.draftIsValidInternal.set(true);
|
||||
this.draftErrorsInternal.set([]);
|
||||
saveActiveThemeText(defaultText);
|
||||
saveDraftThemeText(defaultText);
|
||||
this.syncAnimationStylesheet();
|
||||
this.setStatusMessage(reason === 'shortcut'
|
||||
? 'Theme reset to the default preset by shortcut.'
|
||||
: 'Theme reset to the default preset.');
|
||||
}
|
||||
|
||||
handleGlobalShortcut(event: KeyboardEvent): boolean {
|
||||
const usesModifier = event.ctrlKey || event.metaKey;
|
||||
|
||||
if (!usesModifier || !event.shiftKey || event.code !== 'Digit0') {
|
||||
return false;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
this.resetToDefault('shortcut');
|
||||
return true;
|
||||
}
|
||||
|
||||
ensureElementEntry(key: string): void {
|
||||
this.updateStructuredDraft((draft) => {
|
||||
draft.elements[key] = draft.elements[key] ?? {};
|
||||
}, false, `Prepared ${key} in the theme draft.`);
|
||||
}
|
||||
|
||||
ensureLayoutEntry(key: string): void {
|
||||
this.updateStructuredDraft((draft) => {
|
||||
const defaults = createDefaultThemeDocument();
|
||||
|
||||
draft.layout[key] = draft.layout[key] ?? defaults.layout[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.`);
|
||||
}
|
||||
|
||||
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> {
|
||||
const elementTheme = this.activeThemeInternal().elements[key] ?? {};
|
||||
const styles: Record<string, string> = {};
|
||||
|
||||
if (key === 'appRoot') {
|
||||
Object.assign(styles, this.buildTokenStyles(this.activeThemeInternal()));
|
||||
}
|
||||
|
||||
const backgroundLayers = [elementTheme.gradient, elementTheme.backgroundImage]
|
||||
.filter((layer): layer is string => typeof layer === 'string' && layer.trim().length > 0);
|
||||
|
||||
if (backgroundLayers.length > 0) {
|
||||
styles['backgroundImage'] = backgroundLayers.join(', ');
|
||||
}
|
||||
|
||||
if (elementTheme.width) styles['width'] = elementTheme.width;
|
||||
if (elementTheme.height) styles['height'] = elementTheme.height;
|
||||
if (elementTheme.minWidth) styles['minWidth'] = elementTheme.minWidth;
|
||||
if (elementTheme.minHeight) styles['minHeight'] = elementTheme.minHeight;
|
||||
if (elementTheme.maxWidth) styles['maxWidth'] = elementTheme.maxWidth;
|
||||
if (elementTheme.maxHeight) styles['maxHeight'] = elementTheme.maxHeight;
|
||||
if (elementTheme.position) styles['position'] = elementTheme.position;
|
||||
if (elementTheme.top) styles['top'] = elementTheme.top;
|
||||
if (elementTheme.right) styles['right'] = elementTheme.right;
|
||||
if (elementTheme.bottom) styles['bottom'] = elementTheme.bottom;
|
||||
if (elementTheme.left) styles['left'] = elementTheme.left;
|
||||
if (elementTheme.padding) styles['padding'] = elementTheme.padding;
|
||||
if (elementTheme.margin) styles['margin'] = elementTheme.margin;
|
||||
if (elementTheme.border) styles['border'] = elementTheme.border;
|
||||
if (elementTheme.borderRadius) styles['borderRadius'] = elementTheme.borderRadius;
|
||||
if (elementTheme.backgroundColor) styles['backgroundColor'] = elementTheme.backgroundColor;
|
||||
if (elementTheme.color) styles['color'] = elementTheme.color;
|
||||
if (elementTheme.backgroundSize) styles['backgroundSize'] = elementTheme.backgroundSize;
|
||||
if (elementTheme.backgroundPosition) styles['backgroundPosition'] = elementTheme.backgroundPosition;
|
||||
if (elementTheme.backgroundRepeat) styles['backgroundRepeat'] = elementTheme.backgroundRepeat;
|
||||
if (elementTheme.boxShadow) styles['boxShadow'] = elementTheme.boxShadow;
|
||||
if (elementTheme.backdropFilter) styles['backdropFilter'] = elementTheme.backdropFilter;
|
||||
|
||||
if (typeof elementTheme.opacity === 'number') {
|
||||
styles['opacity'] = `${elementTheme.opacity}`;
|
||||
}
|
||||
|
||||
return styles;
|
||||
}
|
||||
|
||||
getAnimationClass(key: string): string | null {
|
||||
const animationClass = this.activeThemeInternal().elements[key]?.animationClass?.trim();
|
||||
|
||||
return animationClass && animationClass.length > 0
|
||||
? animationClass
|
||||
: null;
|
||||
}
|
||||
|
||||
getLink(key: string): string | null {
|
||||
return this.activeThemeInternal().elements[key]?.link ?? null;
|
||||
}
|
||||
|
||||
getTextOverride(key: string): string | null {
|
||||
return this.activeThemeInternal().elements[key]?.textOverride ?? null;
|
||||
}
|
||||
|
||||
getIcon(key: string): string | null {
|
||||
return this.activeThemeInternal().elements[key]?.icon ?? null;
|
||||
}
|
||||
|
||||
getLayoutContainerStyles(containerKey: ThemeContainerKey): Record<string, string> {
|
||||
const container = findThemeLayoutContainer(containerKey);
|
||||
|
||||
if (!container) {
|
||||
return {
|
||||
display: 'grid'
|
||||
};
|
||||
}
|
||||
|
||||
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))`),
|
||||
minHeight: '0',
|
||||
minWidth: '0'
|
||||
};
|
||||
}
|
||||
|
||||
getLayoutItemStyles(key: string): Record<string, string> {
|
||||
const defaults = createDefaultThemeDocument();
|
||||
const layoutEntry = this.activeThemeInternal().layout[key] ?? defaults.layout[key];
|
||||
|
||||
if (!layoutEntry) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
gridColumn: `${layoutEntry.grid.x + 1} / span ${layoutEntry.grid.w}`,
|
||||
gridRow: `${layoutEntry.grid.y + 1} / span ${layoutEntry.grid.h}`,
|
||||
minWidth: '0',
|
||||
minHeight: '0'
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const nextDraft = structuredClone(this.draftThemeInternal());
|
||||
|
||||
mutator(nextDraft);
|
||||
|
||||
const result = validateThemeDocument(nextDraft);
|
||||
|
||||
if (!result.valid || !result.value) {
|
||||
this.setStatusMessage('The structured change could not be validated.');
|
||||
return;
|
||||
}
|
||||
|
||||
const formatted = stringifyTheme(result.value);
|
||||
|
||||
this.draftThemeInternal.set(result.value);
|
||||
this.draftTextInternal.set(formatted);
|
||||
this.draftIsValidInternal.set(true);
|
||||
this.draftErrorsInternal.set([]);
|
||||
saveDraftThemeText(formatted);
|
||||
|
||||
if (applyImmediately) {
|
||||
this.commitTheme(result.value, formatted, successMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setStatusMessage(successMessage);
|
||||
}
|
||||
|
||||
private commitTheme(theme: ThemeDocument, text: string, successMessage: string): void {
|
||||
this.activeThemeInternal.set(theme);
|
||||
this.activeThemeTextInternal.set(text);
|
||||
this.draftThemeInternal.set(theme);
|
||||
this.draftTextInternal.set(text);
|
||||
this.draftIsValidInternal.set(true);
|
||||
this.draftErrorsInternal.set([]);
|
||||
saveActiveThemeText(text);
|
||||
saveDraftThemeText(text);
|
||||
this.syncAnimationStylesheet();
|
||||
this.setStatusMessage(successMessage);
|
||||
}
|
||||
|
||||
private parseAndValidateTheme(text: string, label: string) {
|
||||
try {
|
||||
return validateThemeDocument(JSON.parse(text) as unknown);
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: [`${label} could not be parsed: ${error instanceof Error ? error.message : 'unknown JSON error'}`],
|
||||
value: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private buildTokenStyles(theme: ThemeDocument): Record<string, string> {
|
||||
const styles: Record<string, string> = {};
|
||||
|
||||
for (const [tokenName, tokenValue] of Object.entries(theme.tokens.colors)) {
|
||||
styles[`--${toKebabCase(tokenName)}`] = tokenValue;
|
||||
}
|
||||
|
||||
for (const [tokenName, tokenValue] of Object.entries(theme.tokens.spacing)) {
|
||||
styles[`--theme-spacing-${toKebabCase(tokenName)}`] = tokenValue;
|
||||
}
|
||||
|
||||
for (const [tokenName, tokenValue] of Object.entries(theme.tokens.radii)) {
|
||||
const cssVariableName = tokenName === 'radius'
|
||||
? '--radius'
|
||||
: `--theme-radius-${toKebabCase(tokenName)}`;
|
||||
|
||||
styles[cssVariableName] = tokenValue;
|
||||
}
|
||||
|
||||
for (const [tokenName, tokenValue] of Object.entries(theme.tokens.effects)) {
|
||||
styles[`--theme-effect-${toKebabCase(tokenName)}`] = tokenValue;
|
||||
}
|
||||
|
||||
return styles;
|
||||
}
|
||||
|
||||
private syncAnimationStylesheet(): void {
|
||||
const theme = this.activeThemeInternal();
|
||||
const css = Object.entries(theme.animations)
|
||||
.map(([className, definition]) => this.buildAnimationRule(className, definition))
|
||||
.filter((rule) => rule.length > 0)
|
||||
.join('\n\n');
|
||||
|
||||
if (!this.animationStyleElement) {
|
||||
this.animationStyleElement = this.documentRef.createElement('style');
|
||||
this.animationStyleElement.setAttribute('data-toju-theme-animations', 'true');
|
||||
this.documentRef.head.appendChild(this.animationStyleElement);
|
||||
}
|
||||
|
||||
this.animationStyleElement.textContent = css;
|
||||
}
|
||||
|
||||
private buildAnimationRule(className: string, definition: ThemeAnimationDefinition): string {
|
||||
const animationClass = `.${className}`;
|
||||
const declarationLines = [
|
||||
`animation-name: ${className};`,
|
||||
`animation-duration: ${definition.duration ?? '240ms'};`,
|
||||
`animation-timing-function: ${definition.easing ?? 'ease'};`,
|
||||
`animation-delay: ${definition.delay ?? '0ms'};`,
|
||||
`animation-iteration-count: ${definition.iterationCount ?? '1'};`,
|
||||
`animation-fill-mode: ${definition.fillMode ?? 'both'};`,
|
||||
`animation-direction: ${definition.direction ?? 'normal'};`
|
||||
];
|
||||
const classRule = `${animationClass} {\n ${declarationLines.join('\n ')}\n}`;
|
||||
|
||||
if (!definition.keyframes || Object.keys(definition.keyframes).length === 0) {
|
||||
return classRule;
|
||||
}
|
||||
|
||||
const keyframeRule = `@keyframes ${className} {\n${Object.entries(definition.keyframes)
|
||||
.map(([step, declarations]) => {
|
||||
const lines = Object.entries(declarations)
|
||||
.map(([property, value]) => ` ${toKebabCase(property)}: ${value};`)
|
||||
.join('\n');
|
||||
|
||||
return ` ${step} {\n${lines}\n }`;
|
||||
})
|
||||
.join('\n')}\n}`;
|
||||
|
||||
return `${keyframeRule}\n\n${classRule}`;
|
||||
}
|
||||
|
||||
private setStatusMessage(message: string): void {
|
||||
this.statusMessageInternal.set(message);
|
||||
|
||||
if (this.statusTimeoutId) {
|
||||
clearTimeout(this.statusTimeoutId);
|
||||
}
|
||||
|
||||
this.statusTimeoutId = setTimeout(() => {
|
||||
this.statusMessageInternal.set(null);
|
||||
this.statusTimeoutId = null;
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
171
toju-app/src/app/domains/theme/domain/theme-llm-guide.ts
Normal file
171
toju-app/src/app/domains/theme/domain/theme-llm-guide.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { DEFAULT_THEME_DOCUMENT } from './theme.defaults';
|
||||
import {
|
||||
THEME_LAYOUT_CONTAINERS,
|
||||
THEME_REGISTRY
|
||||
} from './theme.registry';
|
||||
import {
|
||||
THEME_ANIMATION_FIELDS,
|
||||
THEME_ELEMENT_STYLE_FIELDS,
|
||||
createAnimationStarterDefinition
|
||||
} from './theme.schema';
|
||||
|
||||
function formatExample(example: string | number): string {
|
||||
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);
|
||||
}
|
||||
|
||||
function describeCapabilities(entry: (typeof THEME_REGISTRY)[number]): string {
|
||||
const capabilities = [
|
||||
entry.layoutEditable ? 'layout' : null,
|
||||
entry.supportsTextOverride ? 'textOverride' : null,
|
||||
entry.supportsLink ? 'link' : null,
|
||||
entry.supportsIcon ? 'icon' : null
|
||||
].filter((value): value is string => value !== null);
|
||||
|
||||
return capabilities.length > 0
|
||||
? capabilities.join(', ')
|
||||
: 'visual style overrides only';
|
||||
}
|
||||
|
||||
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 guideTemplateDocument = {
|
||||
meta: {
|
||||
name: 'Theme Name',
|
||||
version: '1.0.0',
|
||||
description: 'Short mood and material direction.'
|
||||
},
|
||||
tokens: {
|
||||
colors: {
|
||||
background: '224 28% 7%',
|
||||
foreground: '210 40% 96%',
|
||||
primary: '193 95% 68%',
|
||||
panelBackground: '224 24% 11%',
|
||||
titleBarBackground: '226 34% 7%'
|
||||
},
|
||||
spacing: {},
|
||||
radii: {
|
||||
radius: '0.875rem',
|
||||
surface: '1.35rem'
|
||||
},
|
||||
effects: {
|
||||
glassBlur: 'blur(18px) saturate(135%)'
|
||||
}
|
||||
},
|
||||
layout: {
|
||||
chatRoomMainPanel: {
|
||||
container: 'roomLayout',
|
||||
grid: {
|
||||
x: 4,
|
||||
y: 0,
|
||||
w: 12,
|
||||
h: 12
|
||||
}
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
titleBar: {
|
||||
backgroundColor: 'hsl(var(--title-bar-background) / 0.82)',
|
||||
color: 'hsl(var(--foreground))',
|
||||
border: '1px solid hsl(var(--border) / 0.72)',
|
||||
backdropFilter: 'var(--theme-effect-glass-blur)'
|
||||
},
|
||||
chatRoomMainPanel: {
|
||||
backgroundColor: 'hsl(var(--panel-background) / 0.82)',
|
||||
borderRadius: 'var(--theme-radius-surface)',
|
||||
boxShadow: 'var(--theme-effect-panel-shadow)'
|
||||
}
|
||||
},
|
||||
animations: {
|
||||
'theme-fade-in': createAnimationStarterDefinition()
|
||||
}
|
||||
};
|
||||
|
||||
export const THEME_LLM_GUIDE = [
|
||||
'TOJU THEME CREATION SHEET FOR LLMS',
|
||||
'',
|
||||
'Goal',
|
||||
'- Produce one valid JSON theme document for Toju Theme Studio.',
|
||||
'- 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.',
|
||||
'- 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.',
|
||||
'- 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.',
|
||||
'- 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.',
|
||||
'',
|
||||
'Theme creation workflow',
|
||||
'- 1. Set meta.name, meta.version, and an optional meta.description.',
|
||||
'- 2. Define the palette and tokens first. Prefer token-driven colors instead of hard-coded values in every element.',
|
||||
'- 3. Move only layout-editable surfaces in layout.',
|
||||
'- 4. Add visual overrides in elements using the supported style fields below.',
|
||||
'- 5. Add animations only when an element actually references them with animationClass.',
|
||||
'',
|
||||
'Token rules',
|
||||
'- 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.',
|
||||
'- 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>.',
|
||||
`- Built-in shell color tokens: ${colorTokenKeys.join(', ')}.`,
|
||||
`- Built-in shell radius tokens: ${radiusTokenKeys.join(', ')}.`,
|
||||
`- Built-in shell effect tokens: ${effectTokenKeys.join(', ')}.`,
|
||||
'',
|
||||
'Top-level schema reference',
|
||||
'- meta: { name: string, version: string, description?: 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 } }>',
|
||||
'- elements: Record<string, ThemeElementStyles>',
|
||||
'- animations: Record<string, { duration?, easing?, delay?, iterationCount?, fillMode?, direction?, keyframes? }>',
|
||||
'',
|
||||
'Layout containers',
|
||||
...THEME_LAYOUT_CONTAINERS.map((container) => {
|
||||
const layoutKeys = getLayoutKeysForContainer(container.key);
|
||||
|
||||
return `- ${container.key}: ${container.columns} columns x ${container.rows} rows. ${container.description} Layout keys: ${layoutKeys.join(', ')}.`;
|
||||
}),
|
||||
`- Only these keys should normally appear in layout: ${layoutEditableKeys.join(', ')}.`,
|
||||
'',
|
||||
'Registered theme element keys',
|
||||
...THEME_REGISTRY.map((entry) => {
|
||||
const container = entry.container ?? 'none';
|
||||
|
||||
return `- ${entry.key}: ${entry.label}. Category=${entry.category}. Container=${container}. ${entry.description} Supported extras: ${describeCapabilities(entry)}.`;
|
||||
}),
|
||||
'',
|
||||
'Supported element style fields',
|
||||
...THEME_ELEMENT_STYLE_FIELDS.map((field) => {
|
||||
return `- ${field.key}: ${field.description} Example: ${formatExample(field.example)}.`;
|
||||
}),
|
||||
'',
|
||||
'Supported animation fields',
|
||||
...THEME_ANIMATION_FIELDS.map((field) => {
|
||||
return `- ${field.key}: ${field.description} Example: ${formatExample(field.example)}.`;
|
||||
}),
|
||||
'',
|
||||
'Minimal valid starting point',
|
||||
JSON.stringify(guideTemplateDocument, null, 2),
|
||||
'',
|
||||
'Output checklist',
|
||||
'- Return one JSON object only.',
|
||||
'- Prefer token references like hsl(var(--foreground)) and var(--theme-effect-glass-blur) inside element overrides.',
|
||||
'- Keep layout edits plausible for the declared container grid size.',
|
||||
'- If a field is unsupported, omit it instead of guessing.',
|
||||
'- If a section does not need changes, leave it empty rather than filling it with noise.'
|
||||
].join('\n');
|
||||
241
toju-app/src/app/domains/theme/domain/theme.defaults.ts
Normal file
241
toju-app/src/app/domains/theme/domain/theme.defaults.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import {
|
||||
ThemeDocument,
|
||||
ThemeElementStyles,
|
||||
ThemeLayoutEntry
|
||||
} from './theme.models';
|
||||
import {
|
||||
THEME_LAYOUT_CONTAINERS,
|
||||
THEME_REGISTRY,
|
||||
getLayoutEditableThemeKeys
|
||||
} from './theme.registry';
|
||||
|
||||
function createDefaultElements(): Record<string, ThemeElementStyles> {
|
||||
return Object.fromEntries(
|
||||
THEME_REGISTRY.map((entry) => [entry.key, {}])
|
||||
) as Record<string, ThemeElementStyles>;
|
||||
}
|
||||
|
||||
function createDefaultLayout(): 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 }
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === 'appWorkspace') {
|
||||
const appShell = THEME_LAYOUT_CONTAINERS.find((container) => container.key === 'appShell');
|
||||
|
||||
layoutEntries[key] = {
|
||||
container: 'appShell',
|
||||
grid: { x: 1,
|
||||
y: 0,
|
||||
w: (appShell?.columns ?? 20) - 1,
|
||||
h: 1 }
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === 'chatRoomChannelsPanel') {
|
||||
layoutEntries[key] = {
|
||||
container: 'roomLayout',
|
||||
grid: { x: 0,
|
||||
y: 0,
|
||||
w: 4,
|
||||
h: 12 }
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === 'chatRoomMainPanel') {
|
||||
layoutEntries[key] = {
|
||||
container: 'roomLayout',
|
||||
grid: { x: 4,
|
||||
y: 0,
|
||||
w: 12,
|
||||
h: 12 }
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === 'chatRoomMembersPanel') {
|
||||
layoutEntries[key] = {
|
||||
container: 'roomLayout',
|
||||
grid: { x: 16,
|
||||
y: 0,
|
||||
w: 4,
|
||||
h: 12 }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return layoutEntries;
|
||||
}
|
||||
|
||||
function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
|
||||
const elements = createDefaultElements();
|
||||
|
||||
elements['appRoot'] = {
|
||||
backgroundColor: 'hsl(var(--background))',
|
||||
color: 'hsl(var(--foreground))',
|
||||
gradient: 'radial-gradient(circle at top, hsl(var(--surface-highlight) / 0.18), transparent 34%), linear-gradient(180deg, rgba(5, 8, 15, 0.98), rgba(9, 12, 20, 1))'
|
||||
};
|
||||
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: 'radial-gradient(circle at top right, hsl(var(--surface-highlight-alt) / 0.14), transparent 30%), linear-gradient(180deg, rgba(9, 12, 21, 0.96), rgba(7, 10, 18, 1))'
|
||||
};
|
||||
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)',
|
||||
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: 'radial-gradient(circle at top, hsl(var(--surface-highlight) / 0.12), transparent 28%), linear-gradient(180deg, rgba(16, 20, 34, 0.82), rgba(8, 11, 19, 0.92))',
|
||||
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['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: 'radial-gradient(circle at top right, hsl(var(--surface-highlight) / 0.14), transparent 32%), linear-gradient(180deg, rgba(18, 23, 37, 0.78), rgba(9, 12, 21, 0.88))',
|
||||
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);
|
||||
|
||||
return keys.length === 1 && radii['radius'] === '0.375rem';
|
||||
}
|
||||
|
||||
function allElementsEmpty(elements: Record<string, ThemeElementStyles>): boolean {
|
||||
return Object.values(elements).every((elementStyles) => Object.keys(elementStyles).length === 0);
|
||||
}
|
||||
|
||||
export function createDefaultThemeDocument(): ThemeDocument {
|
||||
return {
|
||||
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: createDefaultLayout(),
|
||||
elements: createDarkDefaultElements(),
|
||||
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;
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME_DOCUMENT: ThemeDocument = createDefaultThemeDocument();
|
||||
export const DEFAULT_THEME_JSON = JSON.stringify(DEFAULT_THEME_DOCUMENT, null, 2);
|
||||
133
toju-app/src/app/domains/theme/domain/theme.models.ts
Normal file
133
toju-app/src/app/domains/theme/domain/theme.models.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
export type ThemeContainerKey = 'appShell' | 'roomLayout';
|
||||
|
||||
export interface ThemeMeta {
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ThemeTokenGroups {
|
||||
colors: Record<string, string>;
|
||||
spacing: Record<string, string>;
|
||||
radii: Record<string, string>;
|
||||
effects: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ThemeGridRect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface ThemeLayoutEntry {
|
||||
container: ThemeContainerKey;
|
||||
grid: ThemeGridRect;
|
||||
}
|
||||
|
||||
export interface ThemeElementStyles {
|
||||
width?: string;
|
||||
height?: string;
|
||||
minWidth?: string;
|
||||
minHeight?: string;
|
||||
maxWidth?: string;
|
||||
maxHeight?: string;
|
||||
position?: 'static' | 'relative' | 'absolute' | 'sticky';
|
||||
top?: string;
|
||||
right?: string;
|
||||
bottom?: string;
|
||||
left?: string;
|
||||
opacity?: number;
|
||||
padding?: string;
|
||||
margin?: string;
|
||||
border?: string;
|
||||
borderRadius?: string;
|
||||
backgroundColor?: string;
|
||||
color?: string;
|
||||
backgroundImage?: string;
|
||||
backgroundSize?: string;
|
||||
backgroundPosition?: string;
|
||||
backgroundRepeat?: string;
|
||||
gradient?: string;
|
||||
boxShadow?: string;
|
||||
backdropFilter?: string;
|
||||
icon?: string;
|
||||
textOverride?: string;
|
||||
link?: string;
|
||||
animationClass?: string;
|
||||
}
|
||||
|
||||
export type ThemeElementStyleProperty = keyof ThemeElementStyles;
|
||||
|
||||
export interface ThemeAnimationDefinition {
|
||||
duration?: string;
|
||||
easing?: string;
|
||||
delay?: string;
|
||||
iterationCount?: string;
|
||||
fillMode?: 'none' | 'forwards' | 'backwards' | 'both';
|
||||
direction?: 'normal' | 'reverse' | 'alternate' | 'alternate-reverse';
|
||||
keyframes?: Record<string, Record<string, string | number>>;
|
||||
}
|
||||
|
||||
export interface ThemeDocument {
|
||||
meta: ThemeMeta;
|
||||
tokens: ThemeTokenGroups;
|
||||
layout: Record<string, ThemeLayoutEntry>;
|
||||
elements: Record<string, ThemeElementStyles>;
|
||||
animations: Record<string, ThemeAnimationDefinition>;
|
||||
}
|
||||
|
||||
export interface ThemeLayoutContainerDefinition {
|
||||
key: ThemeContainerKey;
|
||||
label: string;
|
||||
description: string;
|
||||
columns: number;
|
||||
rows: number;
|
||||
templateColumns?: string;
|
||||
templateRows?: string;
|
||||
}
|
||||
|
||||
export interface ThemeRegistryEntry {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
category: 'shell' | 'room' | 'overlay' | 'state';
|
||||
container?: ThemeContainerKey;
|
||||
layoutEditable: boolean;
|
||||
pickerVisible: boolean;
|
||||
supportsTextOverride: boolean;
|
||||
supportsLink: boolean;
|
||||
supportsIcon: boolean;
|
||||
}
|
||||
|
||||
export interface SavedThemeSummary {
|
||||
description: string | null;
|
||||
error: string | null;
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
isValid: boolean;
|
||||
modifiedAt: number;
|
||||
themeName: string;
|
||||
version: string | null;
|
||||
}
|
||||
|
||||
export interface ThemeValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
value: ThemeDocument | null;
|
||||
}
|
||||
|
||||
export interface ThemeGridEditorItem {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
grid: ThemeGridRect;
|
||||
}
|
||||
|
||||
export interface ThemeSchemaField<T extends string = string> {
|
||||
key: T;
|
||||
description: string;
|
||||
type: 'string' | 'number' | 'object';
|
||||
example: string | number;
|
||||
examples: readonly (string | number)[];
|
||||
}
|
||||
161
toju-app/src/app/domains/theme/domain/theme.registry.ts
Normal file
161
toju-app/src/app/domains/theme/domain/theme.registry.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
ThemeLayoutContainerDefinition,
|
||||
ThemeRegistryEntry
|
||||
} from './theme.models';
|
||||
|
||||
export const THEME_LAYOUT_CONTAINERS: readonly ThemeLayoutContainerDefinition[] = [
|
||||
{
|
||||
key: 'appShell',
|
||||
label: 'App Shell',
|
||||
description: 'Controls how the global rail and workspace split the application frame.',
|
||||
columns: 20,
|
||||
rows: 1,
|
||||
templateColumns: '4rem repeat(19, minmax(0, 1fr))'
|
||||
},
|
||||
{
|
||||
key: 'roomLayout',
|
||||
label: 'Room Workspace',
|
||||
description: 'Controls the channel list, main chat panel, and member list inside a room.',
|
||||
columns: 20,
|
||||
rows: 12,
|
||||
templateColumns: 'repeat(4, 4.25rem) repeat(12, minmax(0, 1fr)) repeat(4, 4.25rem)'
|
||||
}
|
||||
];
|
||||
|
||||
export const THEME_REGISTRY: readonly ThemeRegistryEntry[] = [
|
||||
{
|
||||
key: 'appRoot',
|
||||
label: 'App Root',
|
||||
description: 'Global workspace wrapper that owns theme tokens and high-level app background styling.',
|
||||
category: 'shell',
|
||||
layoutEditable: false,
|
||||
pickerVisible: false,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'serversRail',
|
||||
label: 'Servers Rail',
|
||||
description: 'The persistent left navigation rail that holds saved servers and the create button.',
|
||||
category: 'shell',
|
||||
container: 'appShell',
|
||||
layoutEditable: true,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'appWorkspace',
|
||||
label: 'App Workspace',
|
||||
description: 'The main workspace area to the right of the server rail.',
|
||||
category: 'shell',
|
||||
container: 'appShell',
|
||||
layoutEditable: true,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'titleBar',
|
||||
label: 'Title Bar',
|
||||
description: 'The top application bar that shows the active room context and desktop controls.',
|
||||
category: 'shell',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: true,
|
||||
supportsLink: true,
|
||||
supportsIcon: true
|
||||
},
|
||||
{
|
||||
key: 'chatRoomChannelsPanel',
|
||||
label: 'Channels Panel',
|
||||
description: 'The room-side panel showing text and voice channels.',
|
||||
category: 'room',
|
||||
container: 'roomLayout',
|
||||
layoutEditable: true,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'chatRoomMainPanel',
|
||||
label: 'Chat Panel',
|
||||
description: 'The main room panel that hosts chat messages and the voice workspace overlay.',
|
||||
category: 'room',
|
||||
container: 'roomLayout',
|
||||
layoutEditable: true,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'chatRoomMembersPanel',
|
||||
label: 'Members Panel',
|
||||
description: 'The right-hand room panel showing online and offline members.',
|
||||
category: 'room',
|
||||
container: 'roomLayout',
|
||||
layoutEditable: true,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'chatRoomEmptyState',
|
||||
label: 'Room Empty State',
|
||||
description: 'The empty-state panel displayed when no room or no text channels are available.',
|
||||
category: 'state',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: true,
|
||||
supportsLink: true,
|
||||
supportsIcon: true
|
||||
},
|
||||
{
|
||||
key: 'voiceWorkspace',
|
||||
label: 'Voice Workspace',
|
||||
description: 'The stream-focused overlay and mini-window used when voice workspace is open.',
|
||||
category: 'overlay',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'floatingVoiceControls',
|
||||
label: 'Floating Voice Controls',
|
||||
description: 'The compact floating voice controls shown when the active voice server is off-screen.',
|
||||
category: 'overlay',
|
||||
layoutEditable: false,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
}
|
||||
];
|
||||
|
||||
export function findThemeRegistryEntry(key: string): ThemeRegistryEntry | null {
|
||||
return THEME_REGISTRY.find((entry) => entry.key === key) ?? null;
|
||||
}
|
||||
|
||||
export function findThemeLayoutContainer(key: string): ThemeLayoutContainerDefinition | null {
|
||||
return THEME_LAYOUT_CONTAINERS.find((container) => container.key === key) ?? null;
|
||||
}
|
||||
|
||||
export function getLayoutEditableThemeKeys(): string[] {
|
||||
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);
|
||||
}
|
||||
432
toju-app/src/app/domains/theme/domain/theme.schema.ts
Normal file
432
toju-app/src/app/domains/theme/domain/theme.schema.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
import {
|
||||
ThemeDocument,
|
||||
ThemeElementStyleProperty,
|
||||
ThemeSchemaField
|
||||
} from './theme.models';
|
||||
|
||||
export const THEME_TOP_LEVEL_FIELDS: readonly ThemeSchemaField[] = [
|
||||
{
|
||||
key: 'meta',
|
||||
description: 'Theme metadata used for naming, versioning, and describing the preset.',
|
||||
type: 'object',
|
||||
example: '{ "name": "Aurora" }',
|
||||
examples: ['{ "name": "Aurora" }']
|
||||
},
|
||||
{
|
||||
key: 'tokens',
|
||||
description: 'Global CSS-variable token overrides for colors, spacing, radii, and effects.',
|
||||
type: 'object',
|
||||
example: '{ "colors": { "background": "220 18% 10%" } }',
|
||||
examples: ['{ "colors": { "background": "220 18% 10%" } }']
|
||||
},
|
||||
{
|
||||
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 } } }']
|
||||
},
|
||||
{
|
||||
key: 'elements',
|
||||
description: 'Per-element visual overrides such as color, spacing, links, icons, and text.',
|
||||
type: 'object',
|
||||
example: '{ "titleBar": { "backgroundColor": "rgba(7,11,20,0.84)" } }',
|
||||
examples: ['{ "titleBar": { "backgroundColor": "rgba(7,11,20,0.84)" } }']
|
||||
},
|
||||
{
|
||||
key: 'animations',
|
||||
description: 'Animation class definitions keyed by class name. Entries can include timing and keyframes.',
|
||||
type: 'object',
|
||||
example: '{ "theme-fade-in": { "duration": "240ms" } }',
|
||||
examples: ['{ "theme-fade-in": { "duration": "240ms" } }']
|
||||
}
|
||||
];
|
||||
|
||||
export const THEME_TOKEN_GROUP_FIELDS: readonly ThemeSchemaField[] = [
|
||||
{
|
||||
key: 'colors',
|
||||
description: 'Maps token names such as background or primary to CSS variable values.',
|
||||
type: 'object',
|
||||
example: '{ "background": "220 18% 10%" }',
|
||||
examples: ['{ "background": "220 18% 10%" }']
|
||||
},
|
||||
{
|
||||
key: 'spacing',
|
||||
description: 'Custom theme spacing variables exposed as --theme-spacing-* CSS variables.',
|
||||
type: 'object',
|
||||
example: '{ "panelGap": "12px" }',
|
||||
examples: ['{ "panelGap": "12px" }']
|
||||
},
|
||||
{
|
||||
key: 'radii',
|
||||
description: 'Theme radius variables exposed as --radius or --theme-radius-* CSS variables.',
|
||||
type: 'object',
|
||||
example: '{ "radius": "18px" }',
|
||||
examples: ['{ "radius": "18px" }']
|
||||
},
|
||||
{
|
||||
key: 'effects',
|
||||
description: 'Custom effect variables exposed as --theme-effect-* CSS variables.',
|
||||
type: 'object',
|
||||
example: '{ "glassBlur": "18px" }',
|
||||
examples: ['{ "glassBlur": "18px" }']
|
||||
}
|
||||
];
|
||||
|
||||
export const THEME_LAYOUT_FIELDS: readonly ThemeSchemaField[] = [
|
||||
{
|
||||
key: 'container',
|
||||
description: 'The registered layout container that owns this grid item.',
|
||||
type: 'string',
|
||||
example: 'roomLayout',
|
||||
examples: ['appShell', 'roomLayout']
|
||||
},
|
||||
{
|
||||
key: 'grid',
|
||||
description: 'Grid coordinates for the item within its container.',
|
||||
type: 'object',
|
||||
example: '{ "x": 4, "y": 0, "w": 12, "h": 12 }',
|
||||
examples: ['{ "x": 4, "y": 0, "w": 12, "h": 12 }']
|
||||
}
|
||||
];
|
||||
|
||||
export const THEME_GRID_FIELDS: readonly ThemeSchemaField[] = [
|
||||
{
|
||||
key: 'x',
|
||||
description: 'Horizontal grid start column, zero-based.',
|
||||
type: 'number',
|
||||
example: 4,
|
||||
examples: [0, 1, 4]
|
||||
},
|
||||
{
|
||||
key: 'y',
|
||||
description: 'Vertical grid start row, zero-based.',
|
||||
type: 'number',
|
||||
example: 0,
|
||||
examples: [0, 1, 6]
|
||||
},
|
||||
{
|
||||
key: 'w',
|
||||
description: 'Grid width in columns.',
|
||||
type: 'number',
|
||||
example: 12,
|
||||
examples: [1, 4, 12]
|
||||
},
|
||||
{
|
||||
key: 'h',
|
||||
description: 'Grid height in rows.',
|
||||
type: 'number',
|
||||
example: 12,
|
||||
examples: [1, 4, 12]
|
||||
}
|
||||
];
|
||||
|
||||
export const THEME_ANIMATION_FIELDS: readonly ThemeSchemaField[] = [
|
||||
{
|
||||
key: 'duration',
|
||||
description: 'Animation duration.',
|
||||
type: 'string',
|
||||
example: '240ms',
|
||||
examples: ['200ms', '240ms', '600ms']
|
||||
},
|
||||
{
|
||||
key: 'easing',
|
||||
description: 'Animation easing function.',
|
||||
type: 'string',
|
||||
example: 'ease-out',
|
||||
examples: ['ease', 'ease-out', 'cubic-bezier(0.16, 1, 0.3, 1)']
|
||||
},
|
||||
{
|
||||
key: 'delay',
|
||||
description: 'Optional animation delay.',
|
||||
type: 'string',
|
||||
example: '0ms',
|
||||
examples: ['0ms', '120ms']
|
||||
},
|
||||
{
|
||||
key: 'iterationCount',
|
||||
description: 'How many times the animation should run.',
|
||||
type: 'string',
|
||||
example: '1',
|
||||
examples: ['1', 'infinite']
|
||||
},
|
||||
{
|
||||
key: 'fillMode',
|
||||
description: 'Animation fill behavior after running.',
|
||||
type: 'string',
|
||||
example: 'both',
|
||||
examples: ['none', 'forwards', 'backwards', 'both']
|
||||
},
|
||||
{
|
||||
key: 'direction',
|
||||
description: 'Animation direction.',
|
||||
type: 'string',
|
||||
example: 'normal',
|
||||
examples: ['normal', 'reverse', 'alternate']
|
||||
},
|
||||
{
|
||||
key: 'keyframes',
|
||||
description: 'Optional keyframe map keyed by from, to, or percentages.',
|
||||
type: 'object',
|
||||
example: '{ "0%": { "opacity": "0" }, "100%": { "opacity": "1" } }',
|
||||
examples: ['{ "0%": { "opacity": "0" }, "100%": { "opacity": "1" } }']
|
||||
}
|
||||
];
|
||||
|
||||
export const THEME_ELEMENT_STYLE_FIELDS: readonly ThemeSchemaField<ThemeElementStyleProperty>[] = [
|
||||
{
|
||||
key: 'width',
|
||||
description: 'CSS width applied to the selected element host.',
|
||||
type: 'string',
|
||||
example: '280px',
|
||||
examples: ['280px', '20rem', 'min(24rem, 30vw)']
|
||||
},
|
||||
{
|
||||
key: 'height',
|
||||
description: 'CSS height applied to the selected element host.',
|
||||
type: 'string',
|
||||
example: '100%',
|
||||
examples: ['100%', '22rem', 'calc(100vh - 4rem)']
|
||||
},
|
||||
{
|
||||
key: 'minWidth',
|
||||
description: 'CSS minimum width for the element host.',
|
||||
type: 'string',
|
||||
example: '16rem',
|
||||
examples: ['16rem', '240px']
|
||||
},
|
||||
{
|
||||
key: 'minHeight',
|
||||
description: 'CSS minimum height for the element host.',
|
||||
type: 'string',
|
||||
example: '14rem',
|
||||
examples: ['14rem', '200px']
|
||||
},
|
||||
{
|
||||
key: 'maxWidth',
|
||||
description: 'CSS maximum width for the element host.',
|
||||
type: 'string',
|
||||
example: '34rem',
|
||||
examples: ['34rem', '80vw']
|
||||
},
|
||||
{
|
||||
key: 'maxHeight',
|
||||
description: 'CSS maximum height for the element host.',
|
||||
type: 'string',
|
||||
example: '90vh',
|
||||
examples: ['90vh', '48rem']
|
||||
},
|
||||
{
|
||||
key: 'position',
|
||||
description: 'CSS positioning mode for the host element.',
|
||||
type: 'string',
|
||||
example: 'relative',
|
||||
examples: ['static', 'relative', 'absolute', 'sticky']
|
||||
},
|
||||
{
|
||||
key: 'top',
|
||||
description: 'CSS top inset used with positioned elements.',
|
||||
type: 'string',
|
||||
example: '12px',
|
||||
examples: ['0', '12px', '2rem']
|
||||
},
|
||||
{
|
||||
key: 'right',
|
||||
description: 'CSS right inset used with positioned elements.',
|
||||
type: 'string',
|
||||
example: '12px',
|
||||
examples: ['0', '12px', '2rem']
|
||||
},
|
||||
{
|
||||
key: 'bottom',
|
||||
description: 'CSS bottom inset used with positioned elements.',
|
||||
type: 'string',
|
||||
example: '12px',
|
||||
examples: ['0', '12px', '2rem']
|
||||
},
|
||||
{
|
||||
key: 'left',
|
||||
description: 'CSS left inset used with positioned elements.',
|
||||
type: 'string',
|
||||
example: '12px',
|
||||
examples: ['0', '12px', '2rem']
|
||||
},
|
||||
{
|
||||
key: 'opacity',
|
||||
description: 'Element opacity between 0 and 1.',
|
||||
type: 'number',
|
||||
example: 0.96,
|
||||
examples: [0.72, 0.88, 1]
|
||||
},
|
||||
{
|
||||
key: 'padding',
|
||||
description: 'CSS padding shorthand for internal spacing.',
|
||||
type: 'string',
|
||||
example: '12px',
|
||||
examples: ['12px', '12px 16px', '1rem 1.25rem']
|
||||
},
|
||||
{
|
||||
key: 'margin',
|
||||
description: 'CSS margin shorthand for external spacing.',
|
||||
type: 'string',
|
||||
example: '0',
|
||||
examples: ['0', '12px', '0 0 12px']
|
||||
},
|
||||
{
|
||||
key: 'border',
|
||||
description: 'CSS border shorthand.',
|
||||
type: 'string',
|
||||
example: '1px solid rgba(255,255,255,0.08)',
|
||||
examples: ['1px solid rgba(255,255,255,0.08)', '0']
|
||||
},
|
||||
{
|
||||
key: 'borderRadius',
|
||||
description: 'CSS border radius shorthand.',
|
||||
type: 'string',
|
||||
example: '16px',
|
||||
examples: ['12px', '16px', '999px']
|
||||
},
|
||||
{
|
||||
key: 'backgroundColor',
|
||||
description: 'CSS background-color value.',
|
||||
type: 'string',
|
||||
example: 'rgba(12, 18, 28, 0.88)',
|
||||
examples: ['rgba(12, 18, 28, 0.88)', 'hsl(var(--card))']
|
||||
},
|
||||
{
|
||||
key: 'color',
|
||||
description: 'CSS text color value.',
|
||||
type: 'string',
|
||||
example: 'hsl(var(--foreground))',
|
||||
examples: ['hsl(var(--foreground))', '#e8edf4']
|
||||
},
|
||||
{
|
||||
key: 'backgroundImage',
|
||||
description: 'CSS background image or image URL.',
|
||||
type: 'string',
|
||||
example: "url('/assets/themes/paper-noise.png')",
|
||||
examples: ["url('/assets/themes/paper-noise.png')", "url('https://example.com/bg.jpg')"]
|
||||
},
|
||||
{
|
||||
key: 'backgroundSize',
|
||||
description: 'CSS background-size value.',
|
||||
type: 'string',
|
||||
example: 'cover',
|
||||
examples: ['cover', 'contain', 'auto 100%']
|
||||
},
|
||||
{
|
||||
key: 'backgroundPosition',
|
||||
description: 'CSS background-position value.',
|
||||
type: 'string',
|
||||
example: 'center',
|
||||
examples: ['center', 'top left', '50% 20%']
|
||||
},
|
||||
{
|
||||
key: 'backgroundRepeat',
|
||||
description: 'CSS background-repeat value.',
|
||||
type: 'string',
|
||||
example: 'no-repeat',
|
||||
examples: ['no-repeat', 'repeat', 'repeat-x']
|
||||
},
|
||||
{
|
||||
key: 'gradient',
|
||||
description: 'CSS gradient layered above any background image.',
|
||||
type: 'string',
|
||||
example: 'linear-gradient(180deg, rgba(0,0,0,0.12), rgba(0,0,0,0.72))',
|
||||
examples: ['linear-gradient(180deg, rgba(0,0,0,0.12), rgba(0,0,0,0.72))', 'radial-gradient(circle at top, rgba(255,255,255,0.12), transparent 60%)']
|
||||
},
|
||||
{
|
||||
key: 'boxShadow',
|
||||
description: 'CSS box-shadow value.',
|
||||
type: 'string',
|
||||
example: '0 18px 45px rgba(0,0,0,0.24)',
|
||||
examples: ['0 18px 45px rgba(0,0,0,0.24)', 'none']
|
||||
},
|
||||
{
|
||||
key: 'backdropFilter',
|
||||
description: 'CSS backdrop-filter value.',
|
||||
type: 'string',
|
||||
example: 'blur(18px)',
|
||||
examples: ['blur(18px)', 'saturate(140%) blur(12px)']
|
||||
},
|
||||
{
|
||||
key: 'icon',
|
||||
description: 'Optional theme icon string or image URL used by supported elements.',
|
||||
type: 'string',
|
||||
example: "url('/assets/themes/orb.svg')",
|
||||
examples: ["url('/assets/themes/orb.svg')", 'TX']
|
||||
},
|
||||
{
|
||||
key: 'textOverride',
|
||||
description: 'Replacement text for supported elements.',
|
||||
type: 'string',
|
||||
example: 'Studio Mode',
|
||||
examples: ['Studio Mode', 'Open to your crew']
|
||||
},
|
||||
{
|
||||
key: 'link',
|
||||
description: 'Safe http or https URL opened externally when supported elements are clicked.',
|
||||
type: 'string',
|
||||
example: 'https://example.com',
|
||||
examples: ['https://example.com', 'https://toju.app/themes']
|
||||
},
|
||||
{
|
||||
key: 'animationClass',
|
||||
description: 'Animation class name defined in the animations section or in app CSS.',
|
||||
type: 'string',
|
||||
example: 'theme-fade-in',
|
||||
examples: ['theme-fade-in', 'theme-glide-in']
|
||||
}
|
||||
];
|
||||
|
||||
export const THEME_SCHEMA = {
|
||||
topLevel: THEME_TOP_LEVEL_FIELDS,
|
||||
tokens: THEME_TOKEN_GROUP_FIELDS,
|
||||
layout: THEME_LAYOUT_FIELDS,
|
||||
grid: THEME_GRID_FIELDS,
|
||||
animation: THEME_ANIMATION_FIELDS,
|
||||
elementStyle: THEME_ELEMENT_STYLE_FIELDS
|
||||
} as const;
|
||||
|
||||
export function findThemeElementStyleField(field: string): ThemeSchemaField<ThemeElementStyleProperty> | null {
|
||||
return THEME_ELEMENT_STYLE_FIELDS.find((entry) => entry.key === field) ?? null;
|
||||
}
|
||||
|
||||
export function getSuggestedValueOptions(
|
||||
field: ThemeElementStyleProperty | string,
|
||||
animationKeys: readonly string[] = []
|
||||
): readonly (string | number)[] {
|
||||
if (field === 'animationClass' && animationKeys.length > 0) {
|
||||
return animationKeys;
|
||||
}
|
||||
|
||||
return findThemeElementStyleField(field)?.examples ?? [];
|
||||
}
|
||||
|
||||
export function getSuggestedFieldDefault(
|
||||
field: ThemeElementStyleProperty,
|
||||
animationKeys: readonly string[] = []
|
||||
): string | number {
|
||||
return getSuggestedValueOptions(field, animationKeys)[0] ?? '';
|
||||
}
|
||||
|
||||
export function createAnimationStarterDefinition(): ThemeDocument['animations'][string] {
|
||||
return {
|
||||
duration: '240ms',
|
||||
easing: 'ease-out',
|
||||
delay: '0ms',
|
||||
iterationCount: '1',
|
||||
fillMode: 'both',
|
||||
direction: 'normal',
|
||||
keyframes: {
|
||||
'0%': {
|
||||
opacity: '0',
|
||||
transform: 'translateY(10px)'
|
||||
},
|
||||
'100%': {
|
||||
opacity: '1',
|
||||
transform: 'translateY(0)'
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
452
toju-app/src/app/domains/theme/domain/theme.validation.ts
Normal file
452
toju-app/src/app/domains/theme/domain/theme.validation.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
import {
|
||||
ThemeAnimationDefinition,
|
||||
ThemeDocument,
|
||||
ThemeElementStyleProperty,
|
||||
ThemeElementStyles,
|
||||
ThemeValidationResult
|
||||
} from './theme.models';
|
||||
import { createDefaultThemeDocument } from './theme.defaults';
|
||||
import {
|
||||
THEME_LAYOUT_CONTAINERS,
|
||||
THEME_REGISTRY,
|
||||
getLayoutEditableThemeKeys
|
||||
} from './theme.registry';
|
||||
|
||||
const TOP_LEVEL_KEYS = ['meta', 'tokens', 'layout', 'elements', 'animations'] as const;
|
||||
const META_KEYS = ['name', 'version', 'description'] as const;
|
||||
const TOKEN_GROUP_KEYS = ['colors', 'spacing', 'radii', 'effects'] as const;
|
||||
const LAYOUT_ENTRY_KEYS = ['container', 'grid'] as const;
|
||||
const GRID_KEYS = ['x', 'y', 'w', 'h'] as const;
|
||||
const ANIMATION_KEYS = ['duration', 'easing', 'delay', 'iterationCount', 'fillMode', 'direction', 'keyframes'] as const;
|
||||
const POSITION_VALUES = ['static', 'relative', 'absolute', 'sticky'] as const;
|
||||
const FILL_MODE_VALUES = ['none', 'forwards', 'backwards', 'both'] as const;
|
||||
const DIRECTION_VALUES = ['normal', 'reverse', 'alternate', 'alternate-reverse'] as const;
|
||||
const SAFE_LINK_PROTOCOLS = ['http:', 'https:'] as const;
|
||||
const SAFE_CLASS_PATTERN = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
||||
const KEYFRAME_STEP_PATTERN = /^(from|to|(?:\d|[1-9]\d|100)%)$/;
|
||||
const ELEMENT_STYLE_KEYS: readonly ThemeElementStyleProperty[] = [
|
||||
'width',
|
||||
'height',
|
||||
'minWidth',
|
||||
'minHeight',
|
||||
'maxWidth',
|
||||
'maxHeight',
|
||||
'position',
|
||||
'top',
|
||||
'right',
|
||||
'bottom',
|
||||
'left',
|
||||
'opacity',
|
||||
'padding',
|
||||
'margin',
|
||||
'border',
|
||||
'borderRadius',
|
||||
'backgroundColor',
|
||||
'color',
|
||||
'backgroundImage',
|
||||
'backgroundSize',
|
||||
'backgroundPosition',
|
||||
'backgroundRepeat',
|
||||
'gradient',
|
||||
'boxShadow',
|
||||
'backdropFilter',
|
||||
'icon',
|
||||
'textOverride',
|
||||
'link',
|
||||
'animationClass'
|
||||
];
|
||||
|
||||
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 {
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!allowedKeys.includes(key)) {
|
||||
errors.push(`${path}.${key} is not part of the supported theme schema.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateString(value: unknown, path: string, errors: string[], allowEmpty = false): value is string {
|
||||
if (typeof value !== 'string') {
|
||||
errors.push(`${path} must be a string.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!allowEmpty && value.trim().length === 0) {
|
||||
errors.push(`${path} cannot be empty.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if ((allowZero && value < minimum) || (!allowZero && value <= minimum)) {
|
||||
errors.push(`${path} must be ${allowZero ? 'greater than or equal to' : 'greater than'} ${minimum}.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (value < minimum || value > maximum) {
|
||||
errors.push(`${path} must be between ${minimum} and ${maximum}.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateStringRecord(value: unknown, path: string, errors: string[]): value is Record<string, string> {
|
||||
if (!isPlainObject(value)) {
|
||||
errors.push(`${path} must be an object containing string values.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [key, recordValue] of Object.entries(value)) {
|
||||
validateString(recordValue, `${path}.${key}`, errors);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateElementStyles(value: unknown, path: string, errors: string[]): value is ThemeElementStyles {
|
||||
if (!isPlainObject(value)) {
|
||||
errors.push(`${path} must be an object.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
validateUnknownKeys(value, ELEMENT_STYLE_KEYS, path, errors);
|
||||
|
||||
for (const [key, fieldValue] of Object.entries(value)) {
|
||||
if (key === 'opacity') {
|
||||
validateNumberRange(fieldValue, `${path}.${key}`, errors, 0, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === 'position') {
|
||||
validateEnum(fieldValue, POSITION_VALUES, `${path}.${key}`, errors);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === 'link') {
|
||||
if (validateString(fieldValue, `${path}.${key}`, errors)) {
|
||||
try {
|
||||
const parsedUrl = new URL(fieldValue);
|
||||
|
||||
if (!SAFE_LINK_PROTOCOLS.includes(parsedUrl.protocol as (typeof SAFE_LINK_PROTOCOLS)[number])) {
|
||||
errors.push(`${path}.${key} must use http or https.`);
|
||||
}
|
||||
} catch {
|
||||
errors.push(`${path}.${key} must be a valid absolute URL.`);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === 'animationClass') {
|
||||
if (validateString(fieldValue, `${path}.${key}`, errors) && !SAFE_CLASS_PATTERN.test(fieldValue)) {
|
||||
errors.push(`${path}.${key} must be a safe CSS class token.`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
validateString(fieldValue, `${path}.${key}`, errors, key === 'backgroundImage' || key === 'gradient');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateAnimationDefinition(value: unknown, path: string, errors: string[]): value is ThemeAnimationDefinition {
|
||||
if (!isPlainObject(value)) {
|
||||
errors.push(`${path} must be an object.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
validateUnknownKeys(value, ANIMATION_KEYS, path, errors);
|
||||
|
||||
if (value['duration'] !== undefined) {
|
||||
validateString(value['duration'], `${path}.duration`, errors);
|
||||
}
|
||||
|
||||
if (value['easing'] !== undefined) {
|
||||
validateString(value['easing'], `${path}.easing`, errors);
|
||||
}
|
||||
|
||||
if (value['delay'] !== undefined) {
|
||||
validateString(value['delay'], `${path}.delay`, errors);
|
||||
}
|
||||
|
||||
if (value['iterationCount'] !== undefined) {
|
||||
validateString(value['iterationCount'], `${path}.iterationCount`, errors);
|
||||
}
|
||||
|
||||
if (value['fillMode'] !== undefined) {
|
||||
validateEnum(value['fillMode'], FILL_MODE_VALUES, `${path}.fillMode`, errors);
|
||||
}
|
||||
|
||||
if (value['direction'] !== undefined) {
|
||||
validateEnum(value['direction'], DIRECTION_VALUES, `${path}.direction`, errors);
|
||||
}
|
||||
|
||||
if (value['keyframes'] !== undefined) {
|
||||
if (!isPlainObject(value['keyframes'])) {
|
||||
errors.push(`${path}.keyframes must be an object.`);
|
||||
} else {
|
||||
for (const [step, declarations] of Object.entries(value['keyframes'])) {
|
||||
if (!KEYFRAME_STEP_PATTERN.test(step)) {
|
||||
errors.push(`${path}.keyframes.${step} is not a supported keyframe step.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isPlainObject(declarations)) {
|
||||
errors.push(`${path}.keyframes.${step} must be an object of CSS declarations.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [cssProperty, cssValue] of Object.entries(declarations)) {
|
||||
if (typeof cssValue !== 'string' && typeof cssValue !== 'number') {
|
||||
errors.push(`${path}.keyframes.${step}.${cssProperty} must be a string or number.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function normaliseThemeDocument(input: Partial<ThemeDocument>): ThemeDocument {
|
||||
const document = createDefaultThemeDocument();
|
||||
|
||||
document.meta = {
|
||||
...document.meta,
|
||||
...input.meta
|
||||
};
|
||||
|
||||
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 ?? {})
|
||||
}
|
||||
};
|
||||
|
||||
document.layout = {
|
||||
...document.layout,
|
||||
...(input.layout ?? {})
|
||||
};
|
||||
|
||||
document.elements = {
|
||||
...document.elements,
|
||||
...(input.elements ?? {})
|
||||
};
|
||||
|
||||
document.animations = {
|
||||
...document.animations,
|
||||
...(input.animations ?? {})
|
||||
};
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
export function validateThemeDocument(input: unknown): ThemeValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!isPlainObject(input)) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: ['Theme document must be a JSON object.'],
|
||||
value: null
|
||||
};
|
||||
}
|
||||
|
||||
validateUnknownKeys(input, TOP_LEVEL_KEYS, 'theme', errors);
|
||||
|
||||
const meta = input['meta'];
|
||||
|
||||
if (!isPlainObject(meta)) {
|
||||
errors.push('theme.meta must be an object.');
|
||||
} else {
|
||||
validateUnknownKeys(meta, META_KEYS, 'theme.meta', errors);
|
||||
validateString(meta['name'], 'theme.meta.name', errors);
|
||||
validateString(meta['version'], 'theme.meta.version', errors);
|
||||
|
||||
if (meta['description'] !== undefined) {
|
||||
validateString(meta['description'], 'theme.meta.description', errors, true);
|
||||
}
|
||||
}
|
||||
|
||||
const tokens = input['tokens'];
|
||||
|
||||
if (tokens !== undefined) {
|
||||
if (!isPlainObject(tokens)) {
|
||||
errors.push('theme.tokens must be an object.');
|
||||
} else {
|
||||
validateUnknownKeys(tokens, TOKEN_GROUP_KEYS, 'theme.tokens', errors);
|
||||
|
||||
if (tokens['colors'] !== undefined) {
|
||||
validateStringRecord(tokens['colors'], 'theme.tokens.colors', errors);
|
||||
}
|
||||
|
||||
if (tokens['spacing'] !== undefined) {
|
||||
validateStringRecord(tokens['spacing'], 'theme.tokens.spacing', errors);
|
||||
}
|
||||
|
||||
if (tokens['radii'] !== undefined) {
|
||||
validateStringRecord(tokens['radii'], 'theme.tokens.radii', errors);
|
||||
}
|
||||
|
||||
if (tokens['effects'] !== undefined) {
|
||||
validateStringRecord(tokens['effects'], 'theme.tokens.effects', errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const layout = input['layout'];
|
||||
|
||||
if (layout !== undefined) {
|
||||
if (!isPlainObject(layout)) {
|
||||
errors.push('theme.layout must be an object.');
|
||||
} else {
|
||||
const allowedLayoutKeys = getLayoutEditableThemeKeys();
|
||||
|
||||
validateUnknownKeys(layout, allowedLayoutKeys, 'theme.layout', errors);
|
||||
|
||||
for (const [key, value] of Object.entries(layout)) {
|
||||
const basePath = `theme.layout.${key}`;
|
||||
|
||||
if (!isPlainObject(value)) {
|
||||
errors.push(`${basePath} must be an object.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
validateUnknownKeys(value, LAYOUT_ENTRY_KEYS, basePath, errors);
|
||||
|
||||
if (value['container'] !== undefined) {
|
||||
validateEnum(
|
||||
value['container'],
|
||||
THEME_LAYOUT_CONTAINERS.map((container) => container.key) as unknown as readonly string[],
|
||||
`${basePath}.container`,
|
||||
errors
|
||||
);
|
||||
}
|
||||
|
||||
const grid = value['grid'];
|
||||
|
||||
if (!isPlainObject(grid)) {
|
||||
errors.push(`${basePath}.grid must be an object.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
validateUnknownKeys(grid, GRID_KEYS, `${basePath}.grid`, errors);
|
||||
validateInteger(grid['x'], `${basePath}.grid.x`, errors, 0, true);
|
||||
validateInteger(grid['y'], `${basePath}.grid.y`, errors, 0, true);
|
||||
validateInteger(grid['w'], `${basePath}.grid.w`, errors, 0, false);
|
||||
validateInteger(grid['h'], `${basePath}.grid.h`, errors, 0, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const elements = input['elements'];
|
||||
|
||||
if (elements !== undefined) {
|
||||
if (!isPlainObject(elements)) {
|
||||
errors.push('theme.elements must be an object.');
|
||||
} else {
|
||||
const allowedElementKeys = THEME_REGISTRY.map((entry) => entry.key);
|
||||
|
||||
validateUnknownKeys(elements, allowedElementKeys, 'theme.elements', errors);
|
||||
|
||||
for (const [key, value] of Object.entries(elements)) {
|
||||
validateElementStyles(value, `theme.elements.${key}`, errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const animations = input['animations'];
|
||||
|
||||
if (animations !== undefined) {
|
||||
if (!isPlainObject(animations)) {
|
||||
errors.push('theme.animations must be an object.');
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(animations)) {
|
||||
if (!SAFE_CLASS_PATTERN.test(key)) {
|
||||
errors.push(`theme.animations.${key} must use a safe CSS class token.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
validateAnimationDefinition(value, `theme.animations.${key}`, errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return {
|
||||
valid: false,
|
||||
errors,
|
||||
value: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
errors: [],
|
||||
value: normaliseThemeDocument(input as Partial<ThemeDocument>)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<div class="theme-grid-editor rounded-2xl border border-border bg-background/50 p-3">
|
||||
<div class="mb-3 flex items-center justify-between gap-3 px-1">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-foreground">{{ container().label }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ container().description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-full bg-secondary px-2.5 py-1 text-[11px] font-medium text-muted-foreground">
|
||||
{{ container().columns }} cols x {{ container().rows }} rows
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
#canvasRef
|
||||
class="theme-grid-editor__frame relative overflow-hidden rounded-xl border border-border/80"
|
||||
[ngStyle]="frameStyle()"
|
||||
>
|
||||
<div class="theme-grid-editor__grid"></div>
|
||||
|
||||
@for (item of items(); track item.key) {
|
||||
<div
|
||||
class="theme-grid-editor__item absolute"
|
||||
[class.theme-grid-editor__item--selected]="selectedKey() === item.key"
|
||||
[class.theme-grid-editor__item--disabled]="disabled()"
|
||||
[ngStyle]="itemStyle(item)"
|
||||
(click)="selectItem(item.key)"
|
||||
(keydown.enter)="selectItem(item.key)"
|
||||
(keydown.space)="selectItem(item.key)"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
[attr.aria-label]="'Select ' + item.label"
|
||||
>
|
||||
<div
|
||||
class="theme-grid-editor__item-body"
|
||||
(pointerdown)="startMove($event, item)"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold text-foreground">{{ item.label }}</p>
|
||||
<p class="mt-1 line-clamp-2 text-[11px] leading-4 text-muted-foreground">{{ item.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-full bg-background/80 px-2 py-0.5 text-[10px] font-medium text-muted-foreground shadow-sm">
|
||||
{{ item.grid.x }},{{ item.grid.y }} · {{ item.grid.w }}x{{ item.grid.h }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="theme-grid-editor__handle"
|
||||
(pointerdown)="startResize($event, item)"
|
||||
aria-label="Resize {{ item.label }}"
|
||||
></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (disabled()) {
|
||||
<div
|
||||
class="theme-grid-editor__disabled absolute inset-0 flex items-center justify-center rounded-xl bg-background/75 px-6 text-center text-sm text-muted-foreground backdrop-blur-sm"
|
||||
>
|
||||
Fix JSON validation errors to re-enable the grid editor.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,79 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.theme-grid-editor__frame {
|
||||
aspect-ratio: 16 / 9;
|
||||
background:
|
||||
radial-gradient(circle at top, hsl(var(--primary) / 0.08), transparent 45%),
|
||||
linear-gradient(180deg, hsl(var(--background) / 0.96), hsl(var(--card) / 0.98));
|
||||
}
|
||||
|
||||
.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));
|
||||
}
|
||||
|
||||
.theme-grid-editor__item {
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
.theme-grid-editor__item-body {
|
||||
height: 100%;
|
||||
border: 1px solid hsl(var(--border) / 0.8);
|
||||
border-radius: 1rem;
|
||||
background:
|
||||
linear-gradient(180deg, hsl(var(--card) / 0.96), hsl(var(--background) / 0.96)),
|
||||
radial-gradient(circle at top right, hsl(var(--primary) / 0.1), transparent 45%);
|
||||
box-shadow: 0 12px 30px rgb(0 0 0 / 10%);
|
||||
cursor: grab;
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.theme-grid-editor__item:active .theme-grid-editor__item-body {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.theme-grid-editor__item--selected .theme-grid-editor__item-body {
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow:
|
||||
0 0 0 1px hsl(var(--primary)),
|
||||
0 14px 34px hsl(var(--primary) / 0.18);
|
||||
}
|
||||
|
||||
.theme-grid-editor__item--disabled .theme-grid-editor__item-body {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.theme-grid-editor__item:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.theme-grid-editor__item:focus-visible .theme-grid-editor__item-body {
|
||||
box-shadow:
|
||||
0 0 0 2px hsl(var(--primary)),
|
||||
0 14px 34px hsl(var(--primary) / 0.18);
|
||||
}
|
||||
|
||||
.theme-grid-editor__handle {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
bottom: 0.75rem;
|
||||
height: 0.95rem;
|
||||
width: 0.95rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--primary));
|
||||
box-shadow: 0 0 0 3px hsl(var(--background));
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.theme-grid-editor__disabled {
|
||||
border: 1px dashed hsl(var(--border));
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
viewChild
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import {
|
||||
ThemeGridEditorItem,
|
||||
ThemeGridRect,
|
||||
ThemeLayoutContainerDefinition
|
||||
} from '../../domain/theme.models';
|
||||
|
||||
type DragMode = 'move' | 'resize';
|
||||
|
||||
interface DragState {
|
||||
key: string;
|
||||
mode: DragMode;
|
||||
startClientX: number;
|
||||
startClientY: number;
|
||||
startGrid: ThemeGridRect;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-theme-grid-editor',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './theme-grid-editor.component.html',
|
||||
styleUrl: './theme-grid-editor.component.scss'
|
||||
})
|
||||
export class ThemeGridEditorComponent {
|
||||
readonly container = input.required<ThemeLayoutContainerDefinition>();
|
||||
readonly items = input.required<ThemeGridEditorItem[]>();
|
||||
readonly selectedKey = input<string | null>(null);
|
||||
readonly disabled = input(false);
|
||||
|
||||
readonly itemChanged = output<{ key: string; grid: ThemeGridRect }>();
|
||||
readonly itemSelected = output<string>();
|
||||
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private dragState: DragState | null = null;
|
||||
|
||||
readonly canvasRef = viewChild.required<ElementRef<HTMLElement>>('canvasRef');
|
||||
readonly frameStyle = computed(() => ({
|
||||
'--theme-grid-columns': `${this.container().columns}`,
|
||||
'--theme-grid-rows': `${this.container().rows}`
|
||||
}));
|
||||
|
||||
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}%`
|
||||
};
|
||||
}
|
||||
|
||||
selectItem(key: string): void {
|
||||
this.itemSelected.emit(key);
|
||||
}
|
||||
|
||||
startMove(event: PointerEvent, item: ThemeGridEditorItem): void {
|
||||
this.startDrag(event, item, 'move');
|
||||
}
|
||||
|
||||
startResize(event: PointerEvent, item: ThemeGridEditorItem): void {
|
||||
this.startDrag(event, item, 'resize');
|
||||
}
|
||||
|
||||
@HostListener('document:pointermove', ['$event'])
|
||||
onPointerMove(event: PointerEvent): void {
|
||||
if (!this.dragState || this.disabled()) {
|
||||
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 nextGrid = { ...this.dragState.startGrid };
|
||||
|
||||
if (this.dragState.mode === 'move') {
|
||||
nextGrid.x = this.clamp(deltaColumns + this.dragState.startGrid.x, 0, this.container().columns - nextGrid.w);
|
||||
nextGrid.y = this.clamp(deltaRows + this.dragState.startGrid.y, 0, this.container().rows - nextGrid.h);
|
||||
} else {
|
||||
nextGrid.w = this.clamp(deltaColumns + this.dragState.startGrid.w, 1, this.container().columns - nextGrid.x);
|
||||
nextGrid.h = this.clamp(deltaRows + this.dragState.startGrid.h, 1, this.container().rows - nextGrid.y);
|
||||
}
|
||||
|
||||
this.itemChanged.emit({
|
||||
key: this.dragState.key,
|
||||
grid: nextGrid
|
||||
});
|
||||
}
|
||||
|
||||
@HostListener('document:pointerup')
|
||||
@HostListener('document:pointercancel')
|
||||
onPointerUp(): void {
|
||||
this.dragState = null;
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscape(): void {
|
||||
this.dragState = null;
|
||||
}
|
||||
|
||||
private startDrag(event: PointerEvent, item: ThemeGridEditorItem, mode: DragMode): void {
|
||||
if (this.disabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.itemSelected.emit(item.key);
|
||||
this.dragState = {
|
||||
key: item.key,
|
||||
mode,
|
||||
startClientX: event.clientX,
|
||||
startClientY: event.clientY,
|
||||
startGrid: { ...item.grid }
|
||||
};
|
||||
}
|
||||
|
||||
private clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.min(Math.max(value, minimum), maximum);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
NgZone,
|
||||
OnDestroy,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
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 { 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'
|
||||
},
|
||||
'&.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 });
|
||||
|
||||
@Component({
|
||||
selector: 'app-theme-json-code-editor',
|
||||
standalone: true,
|
||||
template: `
|
||||
<div
|
||||
class="theme-json-code-editor"
|
||||
[style.minHeight]="editorMinHeight()"
|
||||
>
|
||||
<div
|
||||
#editorHostRef
|
||||
class="theme-json-code-editor__host"
|
||||
></div>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.theme-json-code-editor {
|
||||
overflow: hidden;
|
||||
border: 1px solid #2f405c;
|
||||
border-radius: 1rem;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgb(96 165 250 / 0.16), transparent 34%),
|
||||
linear-gradient(180deg, #172033, #0e1625);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgb(125 211 252 / 0.08),
|
||||
0 0 0 1px rgb(15 23 42 / 0.16);
|
||||
}
|
||||
|
||||
.theme-json-code-editor:focus-within {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgb(125 211 252 / 0.5),
|
||||
0 0 0 3px rgb(14 165 233 / 0.18);
|
||||
}
|
||||
|
||||
.theme-json-code-editor__host {
|
||||
min-height: inherit;
|
||||
}
|
||||
`
|
||||
})
|
||||
export class ThemeJsonCodeEditorComponent implements OnDestroy {
|
||||
private readonly zone = inject(NgZone);
|
||||
|
||||
readonly editorHostRef = viewChild<ElementRef<HTMLDivElement>>('editorHostRef');
|
||||
readonly value = input.required<string>();
|
||||
readonly fullscreen = input(false);
|
||||
readonly valueChange = output<string>();
|
||||
|
||||
readonly editorMinHeight = computed(() => this.fullscreen() ? 'max(34rem, calc(100vh - 15rem))' : '28rem');
|
||||
|
||||
private editorView: EditorView | null = null;
|
||||
private isApplyingExternalValue = false;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const host = this.editorHostRef();
|
||||
|
||||
if (!host || this.editorView) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.createEditor(host.nativeElement);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const nextValue = this.value();
|
||||
|
||||
if (!this.editorView) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentValue = this.editorView.state.doc.toString();
|
||||
|
||||
if (currentValue === nextValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isApplyingExternalValue = true;
|
||||
this.editorView.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: currentValue.length,
|
||||
insert: nextValue
|
||||
}
|
||||
});
|
||||
this.isApplyingExternalValue = false;
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
this.fullscreen();
|
||||
|
||||
queueMicrotask(() => {
|
||||
this.editorView?.requestMeasure();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.editorView?.destroy();
|
||||
this.editorView = null;
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this.editorView?.focus();
|
||||
}
|
||||
|
||||
focusRange(from: number, to = from): void {
|
||||
if (!this.editorView) {
|
||||
return;
|
||||
}
|
||||
|
||||
const documentLength = this.editorView.state.doc.length;
|
||||
const selectionStart = Math.max(0, Math.min(from, documentLength));
|
||||
const selectionEnd = Math.max(selectionStart, Math.min(to, documentLength));
|
||||
|
||||
this.editorView.dispatch({
|
||||
selection: EditorSelection.range(selectionStart, selectionEnd),
|
||||
effects: EditorView.scrollIntoView(selectionStart, { y: 'center' })
|
||||
});
|
||||
this.editorView.focus();
|
||||
}
|
||||
|
||||
private createEditor(host: HTMLDivElement): void {
|
||||
this.zone.runOutsideAngular(() => {
|
||||
this.editorView = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: this.value(),
|
||||
extensions: [
|
||||
basicSetup,
|
||||
keymap.of([indentWithTab]),
|
||||
indentUnit.of(' '),
|
||||
json(),
|
||||
oneDark,
|
||||
THEME_JSON_EDITOR_THEME,
|
||||
EditorState.tabSize.of(2),
|
||||
EditorView.contentAttributes.of({
|
||||
spellcheck: 'false',
|
||||
autocapitalize: 'off',
|
||||
autocorrect: 'off',
|
||||
'aria-label': 'Theme JSON editor'
|
||||
}),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (!update.docChanged || this.isApplyingExternalValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextValue = update.state.doc.toString();
|
||||
|
||||
this.zone.run(() => {
|
||||
this.valueChange.emit(nextValue);
|
||||
});
|
||||
})
|
||||
]
|
||||
}),
|
||||
parent: host
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
<div
|
||||
class="theme-settings flex min-h-0 w-full flex-col space-y-4"
|
||||
[class.min-h-full]="isFullscreen()"
|
||||
[class.p-4]="isFullscreen()"
|
||||
[class.theme-settings--fullscreen]="isFullscreen()"
|
||||
>
|
||||
<section class="theme-studio-card p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-primary">Theme Studio</p>
|
||||
<h4 class="mt-1 text-xl font-semibold text-foreground">{{ draftTheme().meta.name }}</h4>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
(click)="startPicker()"
|
||||
class="inline-flex items-center rounded-full border border-border bg-background px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary"
|
||||
>
|
||||
Pick UI Element
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="formatDraft()"
|
||||
class="inline-flex items-center rounded-full border border-border bg-background px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary"
|
||||
>
|
||||
Format JSON
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="copyLlmThemeGuide()"
|
||||
class="inline-flex items-center rounded-full border border-border bg-background px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary"
|
||||
>
|
||||
Copy LLM Guide
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="applyDraft()"
|
||||
[disabled]="!draftIsValid()"
|
||||
class="inline-flex items-center rounded-full 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
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="restoreDefaultTheme()"
|
||||
class="inline-flex items-center rounded-full border border-destructive/25 bg-destructive/10 px-3 py-1.5 text-sm font-medium text-destructive transition-colors hover:bg-destructive/15"
|
||||
>
|
||||
Restore Default
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (llmGuideCopyMessage()) {
|
||||
<div class="mt-3 inline-flex rounded-full border border-emerald-500/20 bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-700">
|
||||
{{ llmGuideCopyMessage() }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<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 class="theme-settings__workspace-selector-label">Workspace</label>
|
||||
<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>
|
||||
</div>
|
||||
<div class="theme-settings__hero-stat">
|
||||
<span class="theme-settings__hero-label">Regions</span>
|
||||
<strong class="theme-settings__hero-value">{{ mountedEntryCount() }}</strong>
|
||||
</div>
|
||||
<div class="theme-settings__hero-stat">
|
||||
<span class="theme-settings__hero-label">Draft</span>
|
||||
<strong
|
||||
class="theme-settings__hero-value"
|
||||
[class.text-amber-700]="isDraftDirty()"
|
||||
[class.text-emerald-700]="!isDraftDirty()"
|
||||
>
|
||||
{{ isDraftDirty() ? 'Unsaved changes' : 'In sync' }}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (statusMessage()) {
|
||||
<div class="mt-3 rounded-2xl border border-primary/20 bg-primary/10 px-4 py-3 text-sm text-primary">
|
||||
{{ statusMessage() }}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!draftIsValid()) {
|
||||
<div class="mt-3 rounded-2xl border border-destructive/30 bg-destructive/8 p-4">
|
||||
<p class="text-sm font-semibold text-destructive">The draft is invalid. The last working theme is still active.</p>
|
||||
<ul class="mt-2 space-y-1 text-sm text-destructive/90">
|
||||
@for (error of draftErrors(); track error) {
|
||||
<li>{{ error }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<div class="theme-settings__workspace min-h-0 flex-1">
|
||||
<aside class="theme-settings__sidebar">
|
||||
@if (savedThemesAvailable()) {
|
||||
<section class="theme-studio-card p-3.5">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-sm font-semibold text-foreground">Saved Themes</p>
|
||||
<span class="rounded-full bg-secondary px-2.5 py-1 text-[11px] font-medium text-muted-foreground">
|
||||
{{ savedThemesBusy() ? 'Syncing' : savedThemes().length + ' saved' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
(click)="saveDraftAsNewTheme()"
|
||||
[disabled]="!draftIsValid() || savedThemesBusy()"
|
||||
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Save New
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="saveDraftToSelectedTheme()"
|
||||
[disabled]="!draftIsValid() || !selectedSavedTheme() || savedThemesBusy()"
|
||||
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Save Selected
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="useSelectedSavedTheme()"
|
||||
[disabled]="!selectedSavedTheme()?.isValid || (savedThemesBusy() && savedThemes().length === 0)"
|
||||
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Use
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="editSelectedSavedTheme()"
|
||||
[disabled]="!selectedSavedTheme()?.isValid || (savedThemesBusy() && savedThemes().length === 0)"
|
||||
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="removeSelectedSavedTheme()"
|
||||
[disabled]="!selectedSavedTheme() || (savedThemesBusy() && savedThemes().length === 0)"
|
||||
class="rounded-full border border-destructive/25 bg-destructive/10 px-3 py-1.5 text-xs font-medium text-destructive transition-colors hover:bg-destructive/15 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="refreshSavedThemes()"
|
||||
[disabled]="savedThemesBusy() && savedThemes().length === 0"
|
||||
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (savedThemes().length > 0) {
|
||||
<div class="theme-settings__saved-theme-list mt-4">
|
||||
@for (savedTheme of savedThemes(); track savedTheme.fileName) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="selectSavedTheme(savedTheme.fileName)"
|
||||
class="theme-settings__saved-theme-button"
|
||||
[class.theme-settings__saved-theme-button--active]="selectedSavedTheme()?.fileName === savedTheme.fileName"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-foreground">{{ savedTheme.themeName }}</p>
|
||||
<p class="mt-1 font-mono text-[11px] text-muted-foreground">{{ savedTheme.fileName }}</p>
|
||||
</div>
|
||||
|
||||
@if (savedTheme.isValid) {
|
||||
<span class="rounded-full bg-emerald-500/12 px-2 py-0.5 text-[10px] font-medium text-emerald-700">Ready</span>
|
||||
} @else {
|
||||
<span class="rounded-full bg-destructive/10 px-2 py-0.5 text-[10px] font-medium text-destructive">Invalid</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (savedTheme.version) {
|
||||
<p class="mt-2 text-[11px] font-medium text-muted-foreground">v{{ savedTheme.version }}</p>
|
||||
}
|
||||
|
||||
@if (savedTheme.description) {
|
||||
<p class="mt-2 text-xs leading-5 text-muted-foreground">{{ savedTheme.description }}</p>
|
||||
}
|
||||
|
||||
@if (savedTheme.error) {
|
||||
<p class="mt-2 text-xs leading-5 text-destructive">{{ savedTheme.error }}</p>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="mt-4 rounded-2xl border border-dashed border-border bg-background/60 px-4 py-5 text-sm text-muted-foreground">
|
||||
Save the current draft to create your first reusable Electron theme.
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (savedThemesPath()) {
|
||||
<p class="mt-3 font-mono text-[11px] leading-5 text-muted-foreground">{{ savedThemesPath() }}</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="theme-studio-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">
|
||||
{{ filteredEntries().length }} shown
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<input
|
||||
type="text"
|
||||
[value]="explorerQuery()"
|
||||
(input)="onExplorerQueryInput($event)"
|
||||
placeholder="Search theme keys"
|
||||
class="theme-settings__search-input mt-2 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="theme-settings__entry-list mt-4">
|
||||
@for (entry of filteredEntries(); track entry.key) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="selectThemeEntry(entry.key)"
|
||||
class="theme-settings__entry-button"
|
||||
[class.theme-settings__entry-button--active]="selectedElementKey() === entry.key"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm font-semibold text-foreground">{{ entry.label }}</span>
|
||||
@if (isMounted(entry)) {
|
||||
<span class="rounded-full bg-emerald-500/12 px-2 py-0.5 text-[10px] font-medium text-emerald-700">Mounted</span>
|
||||
}
|
||||
</div>
|
||||
<span class="mt-1 block font-mono text-[11px] text-muted-foreground">{{ entry.key }}</span>
|
||||
<span class="mt-2 block text-xs leading-5 text-muted-foreground">{{ entry.description }}</span>
|
||||
</button>
|
||||
} @empty {
|
||||
<div class="rounded-2xl border border-dashed border-border bg-background/60 px-4 py-5 text-sm text-muted-foreground">
|
||||
No registered theme keys match this filter.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<main class="theme-settings__main">
|
||||
<section class="theme-studio-card p-3.5">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
@if (selectedElement()) {
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-base font-semibold text-foreground">{{ selectedElement()!.label }}</span>
|
||||
<span class="rounded-full bg-secondary px-2 py-0.5 font-mono text-[11px] text-muted-foreground">{{ selectedElement()!.key }}</span>
|
||||
@if (selectedElement()!.container) {
|
||||
<span class="rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground">{{
|
||||
selectedElement()!.container
|
||||
}}</span>
|
||||
}
|
||||
</div>
|
||||
<p class="mt-2 text-sm leading-6 text-muted-foreground">{{ selectedElement()!.description }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (selectedElement()) {
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
(click)="jumpToStyles()"
|
||||
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary"
|
||||
>
|
||||
Open styles in JSON
|
||||
</button>
|
||||
@if (selectedElement()!.layoutEditable) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="jumpToLayout()"
|
||||
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary"
|
||||
>
|
||||
Open layout in JSON
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (selectedElementCapabilities().length > 0) {
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
@for (capability of selectedElementCapabilities(); track capability) {
|
||||
<span class="rounded-full bg-secondary px-2.5 py-1 text-[11px] font-medium text-muted-foreground">{{ capability }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@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="flex flex-wrap gap-2 text-[11px] font-medium text-muted-foreground">
|
||||
<span class="rounded-full bg-secondary px-2.5 py-1">{{ draftLineCount() }} lines</span>
|
||||
<span class="rounded-full bg-secondary px-2.5 py-1">{{ draftCharacterCount() }} chars</span>
|
||||
<span class="rounded-full bg-secondary px-2.5 py-1">{{ draftErrorCount() }} errors</span>
|
||||
<span class="rounded-full bg-slate-900 px-2.5 py-1 text-slate-200">IDE editor</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="theme-settings__editor-panel pt-3">
|
||||
<app-theme-json-code-editor
|
||||
#jsonEditorRef
|
||||
[value]="draftText()"
|
||||
[fullscreen]="isFullscreen()"
|
||||
(valueChange)="onDraftEditorValueChange($event)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (activeWorkspace() === 'inspector') {
|
||||
<div class="space-y-6">
|
||||
<section class="theme-studio-card p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-sm font-semibold text-foreground">Selection</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="startPicker()"
|
||||
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary"
|
||||
>
|
||||
Pick live element
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (selectedElement()) {
|
||||
<div class="mt-4 rounded-2xl border border-border/80 bg-background/65 p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-base font-semibold text-foreground">{{ selectedElement()!.label }}</p>
|
||||
<span class="rounded-full bg-secondary px-2 py-0.5 font-mono text-[11px] text-muted-foreground">{{ selectedElement()!.key }}</span>
|
||||
@if (isMounted(selectedElement()!)) {
|
||||
<span class="rounded-full bg-emerald-500/12 px-2 py-0.5 text-[11px] font-medium text-emerald-700">Mounted now</span>
|
||||
}
|
||||
</div>
|
||||
<p class="mt-2 text-sm leading-6 text-muted-foreground">{{ selectedElement()!.description }}</p>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<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>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="addStarterAnimation()"
|
||||
[disabled]="!draftIsValid()"
|
||||
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Add fade animation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 xl:grid-cols-2">
|
||||
@for (field of visiblePropertyHints(); track field.key) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="applySuggestedProperty(field.key)"
|
||||
[disabled]="!draftIsValid()"
|
||||
class="rounded-2xl border border-border/80 bg-background/65 p-3 text-left transition-colors hover:bg-secondary/45 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-mono text-sm font-semibold text-foreground">{{ field.key }}</p>
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ field.description }}</p>
|
||||
</div>
|
||||
<span class="rounded-full bg-secondary px-2 py-0.5 text-[10px] font-medium text-muted-foreground">{{ field.type }}</span>
|
||||
</div>
|
||||
<div class="mt-3 inline-flex rounded-full bg-primary/10 px-2.5 py-1 font-mono text-[11px] text-primary">
|
||||
{{ field.example }}
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="theme-studio-card p-5">
|
||||
<p class="text-sm font-semibold text-foreground">Animation Keys</p>
|
||||
|
||||
@if (animationKeys().length > 0) {
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
@for (animationKey of animationKeys(); track animationKey) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="jumpToAnimation(animationKey)"
|
||||
class="rounded-full border border-border bg-background px-3 py-1.5 font-mono text-[11px] text-foreground transition-colors hover:bg-secondary"
|
||||
>
|
||||
{{ animationKey }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="mt-4 rounded-2xl border border-dashed border-border bg-background/60 px-4 py-5 text-sm text-muted-foreground">
|
||||
No custom animation keys yet.
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mt-4 rounded-2xl border border-border/80 bg-background/65 p-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@for (field of THEME_ANIMATION_FIELDS; track field.key) {
|
||||
<span class="rounded-full bg-secondary px-2.5 py-1 font-mono text-[11px] text-foreground/80">{{ field.key }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (activeWorkspace() === 'layout') {
|
||||
<section class="theme-studio-card p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<p class="text-sm font-semibold text-foreground">Layout Grid</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@for (container of layoutContainers; track container.key) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="selectContainer(container.key)"
|
||||
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary"
|
||||
[class.bg-primary/10]="selectedContainer() === container.key"
|
||||
[class.border-primary/40]="selectedContainer() === container.key"
|
||||
>
|
||||
{{ container.label }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="resetSelectedContainer()"
|
||||
[disabled]="!draftIsValid()"
|
||||
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Reset Container
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<app-theme-grid-editor
|
||||
[container]="selectedContainer() === 'appShell' ? layoutContainers[0] : layoutContainers[1]"
|
||||
[items]="selectedContainerItems()"
|
||||
[selectedKey]="selectedElementKey()"
|
||||
[disabled]="!draftIsValid()"
|
||||
(itemChanged)="handleGridChange($event)"
|
||||
(itemSelected)="handleGridSelection($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@if (selectedElementGrid()) {
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-4">
|
||||
<div class="rounded-2xl border border-border/80 bg-background/65 p-3">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">x</p>
|
||||
<p class="mt-2 font-mono text-lg font-semibold text-foreground">{{ selectedElementGrid()!.grid.x }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-border/80 bg-background/65 p-3">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">y</p>
|
||||
<p class="mt-2 font-mono text-lg font-semibold text-foreground">{{ selectedElementGrid()!.grid.y }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-border/80 bg-background/65 p-3">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">w</p>
|
||||
<p class="mt-2 font-mono text-lg font-semibold text-foreground">{{ selectedElementGrid()!.grid.w }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-border/80 bg-background/65 p-3">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">h</p>
|
||||
<p class="mt-2 font-mono text-lg font-semibold text-foreground">{{ selectedElementGrid()!.grid.h }}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,117 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.theme-settings {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.theme-settings__workspace-selector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.theme-settings__workspace-selector--compact {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.theme-settings__workspace-selector-label {
|
||||
font-size: 0.69rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.theme-settings__workspace-select {
|
||||
width: 100%;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.85rem;
|
||||
background: hsl(var(--background) / 0.82);
|
||||
padding: 0.65rem 0.8rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease,
|
||||
background-color 160ms ease;
|
||||
}
|
||||
|
||||
.theme-settings__workspace-select:focus {
|
||||
border-color: hsl(var(--primary) / 0.4);
|
||||
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.12);
|
||||
}
|
||||
|
||||
.theme-settings__workspace-selector--compact .theme-settings__workspace-select {
|
||||
padding: 0.55rem 0.7rem;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.theme-settings__editor-card {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.theme-settings__editor-panel {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.theme-settings__saved-theme-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.theme-settings__saved-theme-button {
|
||||
width: 100%;
|
||||
border: 1px solid hsl(var(--border) / 0.8);
|
||||
border-radius: 1rem;
|
||||
background: hsl(var(--background) / 0.65);
|
||||
padding: 0.85rem;
|
||||
text-align: left;
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
background-color 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
|
||||
.theme-settings__saved-theme-button:hover {
|
||||
background: hsl(var(--secondary) / 0.45);
|
||||
}
|
||||
|
||||
.theme-settings__saved-theme-button--active {
|
||||
border-color: hsl(var(--primary) / 0.38);
|
||||
background: hsl(var(--primary) / 0.08);
|
||||
}
|
||||
|
||||
.theme-json-editor-panel__header {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.theme-json-editor-panel__title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.theme-json-editor-panel__caption {
|
||||
margin-top: 0.2rem;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.45;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.theme-settings--fullscreen .theme-settings__editor-panel app-theme-json-code-editor {
|
||||
display: block;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
viewChild
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||
import {
|
||||
ThemeContainerKey,
|
||||
ThemeElementStyleProperty,
|
||||
ThemeRegistryEntry
|
||||
} from '../../domain/theme.models';
|
||||
import {
|
||||
THEME_ANIMATION_FIELDS as THEME_ANIMATION_FIELD_HINTS,
|
||||
THEME_ELEMENT_STYLE_FIELDS,
|
||||
createAnimationStarterDefinition,
|
||||
getSuggestedFieldDefault
|
||||
} from '../../domain/theme.schema';
|
||||
import { ElementPickerService } from '../../application/element-picker.service';
|
||||
import { LayoutSyncService } from '../../application/layout-sync.service';
|
||||
import { ThemeLibraryService } from '../../application/theme-library.service';
|
||||
import { ThemeRegistryService } from '../../application/theme-registry.service';
|
||||
import { ThemeService } from '../../application/theme.service';
|
||||
import { THEME_LLM_GUIDE } from '../../domain/theme-llm-guide';
|
||||
import { ThemeGridEditorComponent } from './theme-grid-editor.component';
|
||||
import { ThemeJsonCodeEditorComponent } from './theme-json-code-editor.component';
|
||||
|
||||
type JumpSection = 'elements' | 'layout' | 'animations';
|
||||
type ThemeStudioWorkspace = 'editor' | 'inspector' | 'layout';
|
||||
|
||||
@Component({
|
||||
selector: 'app-theme-settings',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ThemeGridEditorComponent,
|
||||
ThemeJsonCodeEditorComponent
|
||||
],
|
||||
templateUrl: './theme-settings.component.html',
|
||||
styleUrl: './theme-settings.component.scss'
|
||||
})
|
||||
export class ThemeSettingsComponent {
|
||||
private readonly modal = inject(SettingsModalService);
|
||||
private readonly theme = inject(ThemeService);
|
||||
private readonly themeLibrary = inject(ThemeLibraryService);
|
||||
private readonly registry = inject(ThemeRegistryService);
|
||||
private readonly picker = inject(ElementPickerService);
|
||||
private readonly layoutSync = inject(LayoutSyncService);
|
||||
|
||||
readonly editorRef = viewChild<ThemeJsonCodeEditorComponent>('jsonEditorRef');
|
||||
|
||||
readonly draftText = this.theme.draftText;
|
||||
readonly draftErrors = this.theme.draftErrors;
|
||||
readonly draftIsValid = this.theme.draftIsValid;
|
||||
readonly statusMessage = this.theme.statusMessage;
|
||||
readonly isDraftDirty = this.theme.isDraftDirty;
|
||||
readonly isFullscreen = this.modal.themeStudioFullscreen;
|
||||
readonly draftTheme = this.theme.draftTheme;
|
||||
readonly THEME_ANIMATION_FIELDS = THEME_ANIMATION_FIELD_HINTS;
|
||||
readonly animationKeys = this.theme.knownAnimationClasses;
|
||||
readonly layoutContainers = this.layoutSync.containers();
|
||||
readonly themeEntries = this.registry.entries();
|
||||
readonly workspaceTabs: ReadonlyArray<{ key: ThemeStudioWorkspace; label: string; description: string }> = [
|
||||
{
|
||||
key: 'editor',
|
||||
label: 'JSON Editor',
|
||||
description: 'Edit the raw theme document in a fixed-contrast code view.'
|
||||
},
|
||||
{
|
||||
key: 'inspector',
|
||||
label: 'Element Inspector',
|
||||
description: 'Browse themeable regions, supported overrides, and starter values.'
|
||||
},
|
||||
{
|
||||
key: 'layout',
|
||||
label: 'Layout Studio',
|
||||
description: 'Move shells around the grid without hunting through JSON.'
|
||||
}
|
||||
];
|
||||
readonly mountedKeyCounts = computed(() => this.registry.mountedKeyCounts());
|
||||
readonly activeWorkspace = signal<ThemeStudioWorkspace>('editor');
|
||||
readonly explorerQuery = signal('');
|
||||
|
||||
readonly selectedContainer = signal<ThemeContainerKey>('roomLayout');
|
||||
readonly selectedElementKey = signal<string>('chatRoomMainPanel');
|
||||
readonly selectedElement = computed(() => this.registry.getDefinition(this.selectedElementKey()));
|
||||
readonly selectedElementCapabilities = computed(() => {
|
||||
const selected = this.selectedElement();
|
||||
|
||||
if (!selected) {
|
||||
return [] as string[];
|
||||
}
|
||||
|
||||
return [
|
||||
selected.layoutEditable ? 'Layout editable' : null,
|
||||
selected.supportsTextOverride ? 'Text override' : null,
|
||||
selected.supportsLink ? 'Safe external link' : null,
|
||||
selected.supportsIcon ? 'Icon slot' : null
|
||||
].filter((value): value is string => value !== null);
|
||||
});
|
||||
readonly selectedContainerItems = computed(() => this.layoutSync.itemsForContainer(this.selectedContainer()));
|
||||
readonly selectedElementGrid = computed(() => {
|
||||
return this.selectedContainerItems().find((item) => item.key === this.selectedElementKey()) ?? null;
|
||||
});
|
||||
readonly visiblePropertyHints = computed(() => {
|
||||
const selected = this.selectedElement();
|
||||
|
||||
return THEME_ELEMENT_STYLE_FIELDS.filter((field) => {
|
||||
if (field.key === 'textOverride' && !selected?.supportsTextOverride) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (field.key === 'link' && !selected?.supportsLink) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (field.key === 'icon' && !selected?.supportsIcon) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
});
|
||||
readonly mountedEntries = computed(() => {
|
||||
return this.themeEntries.filter((entry) => entry.pickerVisible || entry.layoutEditable);
|
||||
});
|
||||
readonly filteredEntries = computed(() => {
|
||||
const query = this.explorerQuery().trim().toLowerCase();
|
||||
|
||||
if (!query) {
|
||||
return this.mountedEntries();
|
||||
}
|
||||
|
||||
return this.mountedEntries().filter((entry) => {
|
||||
const haystack = `${entry.label} ${entry.key} ${entry.description} ${entry.category}`.toLowerCase();
|
||||
|
||||
return haystack.includes(query);
|
||||
});
|
||||
});
|
||||
readonly draftLineCount = computed(() => this.draftText().split('\n').length);
|
||||
readonly draftCharacterCount = computed(() => this.draftText().length);
|
||||
readonly draftErrorCount = computed(() => this.draftErrors().length);
|
||||
readonly mountedEntryCount = computed(() => this.mountedEntries().length);
|
||||
readonly llmGuideCopyMessage = signal<string | null>(null);
|
||||
readonly savedThemesAvailable = this.themeLibrary.isAvailable;
|
||||
readonly savedThemes = this.themeLibrary.entries;
|
||||
readonly savedThemesBusy = this.themeLibrary.isBusy;
|
||||
readonly savedThemesPath = this.themeLibrary.savedThemesPath;
|
||||
readonly selectedSavedTheme = this.themeLibrary.selectedEntry;
|
||||
|
||||
private llmGuideCopyTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor() {
|
||||
if (this.savedThemesAvailable()) {
|
||||
void this.themeLibrary.refresh();
|
||||
}
|
||||
|
||||
effect(() => {
|
||||
const pickedKey = this.picker.selectedKey();
|
||||
|
||||
if (!pickedKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeWorkspace.set('inspector');
|
||||
this.selectThemeEntry(pickedKey, 'elements');
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (!this.isFullscreen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
this.focusEditor();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onDraftEditorValueChange(value: string): void {
|
||||
this.theme.updateDraftText(value);
|
||||
}
|
||||
|
||||
applyDraft(): void {
|
||||
this.theme.applyDraft();
|
||||
}
|
||||
|
||||
setWorkspace(workspace: ThemeStudioWorkspace): void {
|
||||
this.activeWorkspace.set(workspace);
|
||||
|
||||
if (workspace === 'layout') {
|
||||
const selected = this.selectedElement();
|
||||
|
||||
if (selected?.container) {
|
||||
this.selectedContainer.set(selected.container);
|
||||
}
|
||||
}
|
||||
|
||||
if (workspace === 'editor') {
|
||||
this.focusEditor();
|
||||
}
|
||||
}
|
||||
|
||||
onWorkspaceSelect(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
|
||||
this.setWorkspace(select.value as ThemeStudioWorkspace);
|
||||
}
|
||||
|
||||
onExplorerQueryInput(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.explorerQuery.set(input.value);
|
||||
}
|
||||
|
||||
formatDraft(): void {
|
||||
this.theme.formatDraft();
|
||||
this.focusEditor();
|
||||
}
|
||||
|
||||
async copyLlmThemeGuide(): Promise<void> {
|
||||
const copied = await this.copyTextToClipboard(THEME_LLM_GUIDE);
|
||||
|
||||
this.setLlmGuideCopyMessage(copied
|
||||
? 'LLM guide copied.'
|
||||
: 'Manual copy opened.');
|
||||
}
|
||||
|
||||
startPicker(): void {
|
||||
this.picker.start('theme');
|
||||
}
|
||||
|
||||
selectSavedTheme(fileName: string): void {
|
||||
this.themeLibrary.select(fileName);
|
||||
}
|
||||
|
||||
async refreshSavedThemes(): Promise<void> {
|
||||
await this.themeLibrary.refresh();
|
||||
}
|
||||
|
||||
async saveDraftAsNewTheme(): Promise<void> {
|
||||
await this.themeLibrary.saveDraftAsNewTheme();
|
||||
}
|
||||
|
||||
async saveDraftToSelectedTheme(): Promise<void> {
|
||||
await this.themeLibrary.saveDraftToSelectedTheme();
|
||||
}
|
||||
|
||||
async useSelectedSavedTheme(): Promise<void> {
|
||||
await this.themeLibrary.useSelectedTheme();
|
||||
}
|
||||
|
||||
async editSelectedSavedTheme(): Promise<void> {
|
||||
const opened = await this.themeLibrary.openSelectedThemeInDraft();
|
||||
|
||||
if (!opened) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setWorkspace('editor');
|
||||
this.focusEditor();
|
||||
}
|
||||
|
||||
async removeSelectedSavedTheme(): Promise<void> {
|
||||
const selectedSavedTheme = this.selectedSavedTheme();
|
||||
|
||||
if (!selectedSavedTheme) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(`Delete saved theme "${selectedSavedTheme.themeName}"?`);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.themeLibrary.removeSelectedTheme();
|
||||
}
|
||||
|
||||
restoreDefaultTheme(): void {
|
||||
this.theme.resetToDefault('button');
|
||||
this.activeWorkspace.set('editor');
|
||||
this.selectedContainer.set('roomLayout');
|
||||
this.selectedElementKey.set('chatRoomMainPanel');
|
||||
this.focusJsonAnchor('elements', 'chatRoomMainPanel');
|
||||
}
|
||||
|
||||
selectThemeEntry(key: string, section: JumpSection = 'elements'): void {
|
||||
const definition = this.registry.getDefinition(key);
|
||||
|
||||
if (!definition) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (section === 'layout') {
|
||||
this.activeWorkspace.set('layout');
|
||||
} else if (section === 'animations') {
|
||||
this.activeWorkspace.set('editor');
|
||||
} else {
|
||||
this.activeWorkspace.set('inspector');
|
||||
}
|
||||
|
||||
this.selectedElementKey.set(key);
|
||||
|
||||
if (definition.container) {
|
||||
this.selectedContainer.set(definition.container);
|
||||
}
|
||||
}
|
||||
|
||||
selectContainer(containerKey: ThemeContainerKey): void {
|
||||
this.activeWorkspace.set('layout');
|
||||
this.selectedContainer.set(containerKey);
|
||||
|
||||
const matchingItem = this.layoutSync.itemsForContainer(containerKey)[0];
|
||||
|
||||
if (matchingItem) {
|
||||
this.selectedElementKey.set(matchingItem.key);
|
||||
}
|
||||
}
|
||||
|
||||
applySuggestedProperty(property: ThemeElementStyleProperty): void {
|
||||
if (!this.draftIsValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = getSuggestedFieldDefault(property, this.animationKeys());
|
||||
|
||||
this.theme.setElementStyle(this.selectedElementKey(), property, value);
|
||||
}
|
||||
|
||||
addStarterAnimation(): void {
|
||||
if (!this.draftIsValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.theme.setAnimation('theme-fade-in', createAnimationStarterDefinition());
|
||||
}
|
||||
|
||||
handleGridChange(event: { key: string; grid: { x: number; y: number; w: number; h: number } }): void {
|
||||
this.layoutSync.updateGrid(event.key, event.grid);
|
||||
}
|
||||
|
||||
handleGridSelection(key: string): void {
|
||||
this.selectThemeEntry(key, 'layout');
|
||||
}
|
||||
|
||||
resetSelectedContainer(): void {
|
||||
this.layoutSync.resetContainer(this.selectedContainer());
|
||||
}
|
||||
|
||||
jumpToLayout(): void {
|
||||
this.activeWorkspace.set('editor');
|
||||
this.focusJsonAnchor('layout', this.selectedElementKey());
|
||||
}
|
||||
|
||||
jumpToStyles(): void {
|
||||
this.activeWorkspace.set('editor');
|
||||
this.focusJsonAnchor('elements', this.selectedElementKey());
|
||||
}
|
||||
|
||||
jumpToAnimation(animationKey: string): void {
|
||||
this.activeWorkspace.set('editor');
|
||||
this.focusJsonAnchor('animations', animationKey);
|
||||
}
|
||||
|
||||
isMounted(entry: ThemeRegistryEntry): boolean {
|
||||
return (this.mountedKeyCounts()[entry.key] ?? 0) > 0;
|
||||
}
|
||||
|
||||
private focusEditor(): void {
|
||||
queueMicrotask(() => {
|
||||
this.editorRef()?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
private focusJsonAnchor(section: JumpSection, key: string): void {
|
||||
queueMicrotask(() => {
|
||||
const editor = this.editorRef();
|
||||
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
let text = this.draftText();
|
||||
let anchorIndex = this.findAnchorIndex(text, section, key);
|
||||
|
||||
if (anchorIndex === -1 && this.draftIsValid()) {
|
||||
if (section === 'elements') {
|
||||
this.theme.ensureElementEntry(key);
|
||||
} else if (section === 'layout') {
|
||||
this.theme.ensureLayoutEntry(key);
|
||||
} else if (section === 'animations') {
|
||||
this.theme.setAnimation(key, createAnimationStarterDefinition(), false);
|
||||
}
|
||||
|
||||
text = this.draftText();
|
||||
anchorIndex = this.findAnchorIndex(text, section, key);
|
||||
}
|
||||
|
||||
if (anchorIndex === -1) {
|
||||
editor.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const selectionEnd = Math.min(anchorIndex + key.length + 2, text.length);
|
||||
|
||||
editor.focusRange(anchorIndex, selectionEnd);
|
||||
});
|
||||
}
|
||||
|
||||
private async copyTextToClipboard(value: string): Promise<boolean> {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return true;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
|
||||
textarea.value = value;
|
||||
textarea.setAttribute('readonly', 'true');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
textarea.style.pointerEvents = 'none';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
const copied = document.execCommand('copy');
|
||||
|
||||
if (copied) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
window.prompt('Copy this LLM theme guide', value);
|
||||
return false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
window.prompt('Copy this LLM theme guide', value);
|
||||
return false;
|
||||
}
|
||||
|
||||
private setLlmGuideCopyMessage(message: string): void {
|
||||
this.llmGuideCopyMessage.set(message);
|
||||
|
||||
if (this.llmGuideCopyTimeoutId) {
|
||||
clearTimeout(this.llmGuideCopyTimeoutId);
|
||||
}
|
||||
|
||||
this.llmGuideCopyTimeoutId = setTimeout(() => {
|
||||
this.llmGuideCopyMessage.set(null);
|
||||
this.llmGuideCopyTimeoutId = null;
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
private findAnchorIndex(text: string, section: JumpSection, key: string): number {
|
||||
const sectionAnchor = `"${section}": {`;
|
||||
const sectionIndex = text.indexOf(sectionAnchor);
|
||||
|
||||
if (sectionIndex === -1) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return text.indexOf(`"${key}"`, sectionIndex);
|
||||
}
|
||||
}
|
||||
248
toju-app/src/app/domains/theme/feature/theme-node.directive.ts
Normal file
248
toju-app/src/app/domains/theme/feature/theme-node.directive.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
Directive,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
effect,
|
||||
inject,
|
||||
input
|
||||
} from '@angular/core';
|
||||
|
||||
import { ExternalLinkService } from '../../../core/platform';
|
||||
import { ElementPickerService } from '../application/element-picker.service';
|
||||
import { ThemeRegistryService } from '../application/theme-registry.service';
|
||||
import { ThemeService } from '../application/theme.service';
|
||||
|
||||
function looksLikeImageReference(value: string): boolean {
|
||||
return value.startsWith('url(')
|
||||
|| value.startsWith('http://')
|
||||
|| value.startsWith('https://')
|
||||
|| value.startsWith('/')
|
||||
|| value.startsWith('./');
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: '[appThemeNode]',
|
||||
standalone: true
|
||||
})
|
||||
export class ThemeNodeDirective {
|
||||
readonly themeKey = input.required<string>({ alias: 'appThemeNode' });
|
||||
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly theme = inject(ThemeService);
|
||||
private readonly registry = inject(ThemeRegistryService);
|
||||
private readonly picker = inject(ElementPickerService);
|
||||
private readonly externalLinks = inject(ExternalLinkService);
|
||||
|
||||
private appliedStyleKeys = new Set<string>();
|
||||
private appliedClasses = new Set<string>();
|
||||
private originalTextContent = new WeakMap<HTMLElement, string>();
|
||||
private registeredKey: string | null = null;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const key = this.themeKey();
|
||||
const definition = this.registry.getDefinition(key);
|
||||
const host = this.host.nativeElement;
|
||||
|
||||
host.dataset['themeKey'] = key;
|
||||
host.dataset['themeLabel'] = definition?.label ?? key;
|
||||
|
||||
if (this.registeredKey && this.registeredKey !== key) {
|
||||
this.registry.unregisterHost(this.registeredKey, host);
|
||||
}
|
||||
|
||||
if (this.registeredKey !== key) {
|
||||
this.registry.registerHost(key, host);
|
||||
this.registeredKey = key;
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
this.applyThemeStyles();
|
||||
this.applyAnimationClasses();
|
||||
this.applyTextOverride();
|
||||
this.applyIconOverride();
|
||||
this.applyLinkState();
|
||||
this.applyPickerState();
|
||||
});
|
||||
}
|
||||
|
||||
@HostListener('keydown.enter', ['$event'])
|
||||
onEnterKey(event: Event): void {
|
||||
this.openConfiguredLink(event);
|
||||
}
|
||||
|
||||
@HostListener('click', ['$event'])
|
||||
onClick(event: MouseEvent): void {
|
||||
this.openConfiguredLink(event);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.registeredKey) {
|
||||
this.registry.unregisterHost(this.registeredKey, this.host.nativeElement);
|
||||
this.registeredKey = null;
|
||||
}
|
||||
|
||||
this.clearAppliedStyles();
|
||||
this.clearAppliedClasses();
|
||||
this.restoreTextTarget();
|
||||
this.resetIconTarget();
|
||||
}
|
||||
|
||||
private applyThemeStyles(): void {
|
||||
const styles = this.theme.getHostStyles(this.themeKey());
|
||||
|
||||
this.clearAppliedStyles();
|
||||
|
||||
for (const [styleKey, styleValue] of Object.entries(styles)) {
|
||||
this.host.nativeElement.style.setProperty(styleKey, styleValue);
|
||||
this.appliedStyleKeys.add(styleKey);
|
||||
}
|
||||
}
|
||||
|
||||
private applyAnimationClasses(): void {
|
||||
this.clearAppliedClasses();
|
||||
|
||||
const animationClass = this.theme.getAnimationClass(this.themeKey());
|
||||
|
||||
if (!animationClass) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.host.nativeElement.classList.add(animationClass);
|
||||
this.appliedClasses.add(animationClass);
|
||||
}
|
||||
|
||||
private applyTextOverride(): void {
|
||||
const definition = this.registry.getDefinition(this.themeKey());
|
||||
const textTarget = this.host.nativeElement.querySelector<HTMLElement>('[data-theme-slot="text"]');
|
||||
|
||||
if (!definition?.supportsTextOverride || !textTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.originalTextContent.has(textTarget)) {
|
||||
this.originalTextContent.set(textTarget, textTarget.textContent ?? '');
|
||||
}
|
||||
|
||||
textTarget.textContent = this.theme.getTextOverride(this.themeKey()) ?? this.originalTextContent.get(textTarget) ?? '';
|
||||
}
|
||||
|
||||
private applyIconOverride(): void {
|
||||
const definition = this.registry.getDefinition(this.themeKey());
|
||||
const iconTarget = this.host.nativeElement.querySelector<HTMLElement>('[data-theme-slot="icon"]');
|
||||
const iconValue = this.theme.getIcon(this.themeKey());
|
||||
|
||||
if (!definition?.supportsIcon || !iconTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!iconValue) {
|
||||
this.resetIconTarget();
|
||||
return;
|
||||
}
|
||||
|
||||
iconTarget.dataset['themeVisible'] = 'true';
|
||||
|
||||
if (looksLikeImageReference(iconValue)) {
|
||||
const imageReference = iconValue.startsWith('url(')
|
||||
? iconValue
|
||||
: `url('${iconValue}')`;
|
||||
|
||||
iconTarget.style.backgroundImage = imageReference;
|
||||
iconTarget.textContent = '';
|
||||
} else {
|
||||
iconTarget.style.backgroundImage = 'none';
|
||||
iconTarget.textContent = iconValue.slice(0, 2).toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
private applyLinkState(): void {
|
||||
const definition = this.registry.getDefinition(this.themeKey());
|
||||
const link = this.theme.getLink(this.themeKey());
|
||||
const supportsLink = definition?.supportsLink && !!link;
|
||||
|
||||
this.host.nativeElement.dataset['themeLinked'] = supportsLink ? 'true' : 'false';
|
||||
|
||||
if (!supportsLink) {
|
||||
this.host.nativeElement.removeAttribute('tabindex');
|
||||
this.host.nativeElement.removeAttribute('role');
|
||||
return;
|
||||
}
|
||||
|
||||
this.host.nativeElement.setAttribute('tabindex', '0');
|
||||
this.host.nativeElement.setAttribute('role', 'link');
|
||||
}
|
||||
|
||||
private applyPickerState(): void {
|
||||
const definition = this.registry.getDefinition(this.themeKey());
|
||||
const isHovered = this.picker.hoveredKey() === this.themeKey();
|
||||
const isSelected = this.picker.selectedKey() === this.themeKey();
|
||||
const isActive = this.picker.isPicking() && !!definition?.pickerVisible;
|
||||
|
||||
this.host.nativeElement.dataset['themePickerActive'] = isActive ? 'true' : 'false';
|
||||
this.host.nativeElement.dataset['themePickerHovered'] = isHovered ? 'true' : 'false';
|
||||
this.host.nativeElement.dataset['themePickerSelected'] = isSelected ? 'true' : 'false';
|
||||
}
|
||||
|
||||
private openConfiguredLink(event: Event): void {
|
||||
if (this.picker.isPicking()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const definition = this.registry.getDefinition(this.themeKey());
|
||||
const link = this.theme.getLink(this.themeKey());
|
||||
|
||||
if (!definition?.supportsLink || !link) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target;
|
||||
|
||||
if (target instanceof Element && target.closest('button, input, textarea, select, a, [role="button"]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.externalLinks.open(link);
|
||||
}
|
||||
|
||||
private clearAppliedStyles(): void {
|
||||
for (const styleKey of this.appliedStyleKeys) {
|
||||
this.host.nativeElement.style.removeProperty(styleKey);
|
||||
}
|
||||
|
||||
this.appliedStyleKeys.clear();
|
||||
}
|
||||
|
||||
private clearAppliedClasses(): void {
|
||||
for (const className of this.appliedClasses) {
|
||||
this.host.nativeElement.classList.remove(className);
|
||||
}
|
||||
|
||||
this.appliedClasses.clear();
|
||||
}
|
||||
|
||||
private restoreTextTarget(): void {
|
||||
const textTarget = this.host.nativeElement.querySelector<HTMLElement>('[data-theme-slot="text"]');
|
||||
|
||||
if (!textTarget || !this.originalTextContent.has(textTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
textTarget.textContent = this.originalTextContent.get(textTarget) ?? '';
|
||||
}
|
||||
|
||||
private resetIconTarget(): void {
|
||||
const iconTarget = this.host.nativeElement.querySelector<HTMLElement>('[data-theme-slot="icon"]');
|
||||
|
||||
if (!iconTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
iconTarget.dataset['themeVisible'] = 'false';
|
||||
iconTarget.style.backgroundImage = 'none';
|
||||
iconTarget.textContent = '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { ElementPickerService } from '../application/element-picker.service';
|
||||
import { ThemeRegistryService } from '../application/theme-registry.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-theme-picker-overlay',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
@if (picker.isPicking()) {
|
||||
<div class="pointer-events-none fixed inset-x-0 bottom-4 z-[95] flex justify-center px-4">
|
||||
<div class="pointer-events-auto max-w-xl rounded-2xl border border-border bg-card/95 px-4 py-3 shadow-2xl backdrop-blur-xl">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-primary">Theme Picker Active</p>
|
||||
<p class="mt-1 text-sm text-foreground">
|
||||
Click a highlighted area to inspect its theme key.
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
Hovering:
|
||||
<span class="font-medium text-foreground">{{ hoveredEntry()?.label || 'Move over a themeable region' }}</span>
|
||||
@if (hoveredEntry()) {
|
||||
<span class="ml-1 rounded-full bg-secondary px-2 py-0.5 font-mono text-[11px] text-foreground/80">{{ hoveredEntry()!.key }}</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="cancel()"
|
||||
class="inline-flex items-center rounded-full border border-border bg-secondary px-3 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
`
|
||||
})
|
||||
export class ThemePickerOverlayComponent {
|
||||
readonly picker = inject(ElementPickerService);
|
||||
private readonly registry = inject(ThemeRegistryService);
|
||||
|
||||
readonly hoveredEntry = computed(() => {
|
||||
return this.registry.getDefinition(this.picker.hoveredKey());
|
||||
});
|
||||
|
||||
cancel(): void {
|
||||
this.picker.cancel();
|
||||
}
|
||||
}
|
||||
13
toju-app/src/app/domains/theme/index.ts
Normal file
13
toju-app/src/app/domains/theme/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export * from './application/theme.service';
|
||||
export * from './application/theme-library.service';
|
||||
export * from './application/theme-registry.service';
|
||||
export * from './application/element-picker.service';
|
||||
export * from './application/layout-sync.service';
|
||||
export * from './domain/theme.models';
|
||||
export * from './domain/theme.defaults';
|
||||
export * from './domain/theme.registry';
|
||||
export * from './domain/theme.schema';
|
||||
export * from './domain/theme.validation';
|
||||
|
||||
export { ThemeNodeDirective } from './feature/theme-node.directive';
|
||||
export { ThemePickerOverlayComponent } from './feature/theme-picker-overlay.component';
|
||||
@@ -0,0 +1,221 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
||||
import type { SavedThemeFileDescriptor } from '../../../core/platform/electron/electron-api.models';
|
||||
import type { SavedThemeSummary } from '../domain/theme.models';
|
||||
import { validateThemeDocument } from '../domain/theme.validation';
|
||||
|
||||
const THEME_LIBRARY_REQUEST_TIMEOUT_MS = 4000;
|
||||
|
||||
function sanitizeSavedThemeStem(value: string): string {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
return normalized.length > 0
|
||||
? normalized
|
||||
: 'theme';
|
||||
}
|
||||
|
||||
function buildSavedThemeFileName(stem: string, suffix?: number): string {
|
||||
return suffix && suffix > 1
|
||||
? `${stem}-${suffix}.json`
|
||||
: `${stem}.json`;
|
||||
}
|
||||
|
||||
function fallbackThemeName(fileName: string): string {
|
||||
return fileName.replace(/\.json$/i, '');
|
||||
}
|
||||
|
||||
async function withTimeout<T>(operation: Promise<T>, label: string): Promise<T> {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation,
|
||||
new Promise<T>((_resolve, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error(`${label} timed out.`));
|
||||
}, THEME_LIBRARY_REQUEST_TIMEOUT_MS);
|
||||
})
|
||||
]);
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ThemeLibraryStorageService {
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
get isAvailable(): boolean {
|
||||
return this.electronBridge.isAvailable;
|
||||
}
|
||||
|
||||
async getSavedThemesPath(): Promise<string | null> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (!electronApi) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await withTimeout(electronApi.getSavedThemesPath(), 'Loading the saved themes path');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async listThemes(): Promise<SavedThemeSummary[]> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (!electronApi) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const files = await withTimeout(electronApi.listSavedThemes(), 'Loading saved themes');
|
||||
const summaries = await Promise.all(files.map(async (file) => await this.readSavedThemeSummary(file)));
|
||||
|
||||
return summaries.sort((left, right) => {
|
||||
const nameOrder = left.themeName.localeCompare(right.themeName, undefined, { sensitivity: 'base' });
|
||||
|
||||
return nameOrder !== 0
|
||||
? nameOrder
|
||||
: right.modifiedAt - left.modifiedAt;
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async readThemeText(fileName: string): Promise<string | null> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (!electronApi) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await withTimeout(electronApi.readSavedTheme(fileName), `Reading saved theme ${fileName}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async saveNewTheme(preferredName: string, text: string): Promise<string | null> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (!electronApi) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const existingFiles = await withTimeout(electronApi.listSavedThemes(), 'Loading saved themes');
|
||||
const existingNames = new Set(existingFiles.map((file) => file.fileName.toLowerCase()));
|
||||
const stem = sanitizeSavedThemeStem(preferredName);
|
||||
|
||||
let suffix = 1;
|
||||
let fileName = buildSavedThemeFileName(stem);
|
||||
|
||||
while (existingNames.has(fileName.toLowerCase())) {
|
||||
suffix += 1;
|
||||
fileName = buildSavedThemeFileName(stem, suffix);
|
||||
}
|
||||
|
||||
await withTimeout(electronApi.writeSavedTheme(fileName, text), `Saving theme ${fileName}`);
|
||||
|
||||
return fileName;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async overwriteTheme(fileName: string, text: string): Promise<boolean> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (!electronApi) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return await withTimeout(electronApi.writeSavedTheme(fileName, text), `Saving theme ${fileName}`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTheme(fileName: string): Promise<boolean> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (!electronApi) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return await withTimeout(electronApi.deleteSavedTheme(fileName), `Deleting theme ${fileName}`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async readSavedThemeSummary(file: SavedThemeFileDescriptor): Promise<SavedThemeSummary> {
|
||||
const text = await this.readThemeText(file.fileName);
|
||||
|
||||
if (!text) {
|
||||
return {
|
||||
description: null,
|
||||
error: 'File could not be read.',
|
||||
fileName: file.fileName,
|
||||
filePath: file.path,
|
||||
isValid: false,
|
||||
modifiedAt: file.modifiedAt,
|
||||
themeName: fallbackThemeName(file.fileName),
|
||||
version: null
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
const result = validateThemeDocument(parsed);
|
||||
|
||||
if (!result.valid || !result.value) {
|
||||
return {
|
||||
description: null,
|
||||
error: result.errors[0] ?? 'Theme JSON is invalid.',
|
||||
fileName: file.fileName,
|
||||
filePath: file.path,
|
||||
isValid: false,
|
||||
modifiedAt: file.modifiedAt,
|
||||
themeName: fallbackThemeName(file.fileName),
|
||||
version: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
description: result.value.meta.description ?? null,
|
||||
error: null,
|
||||
fileName: file.fileName,
|
||||
filePath: file.path,
|
||||
isValid: true,
|
||||
modifiedAt: file.modifiedAt,
|
||||
themeName: result.value.meta.name,
|
||||
version: result.value.meta.version,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
description: null,
|
||||
error: error instanceof Error ? error.message : 'Theme JSON could not be parsed.',
|
||||
fileName: file.fileName,
|
||||
filePath: file.path,
|
||||
isValid: false,
|
||||
modifiedAt: file.modifiedAt,
|
||||
themeName: fallbackThemeName(file.fileName),
|
||||
version: null
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
STORAGE_KEY_THEME_ACTIVE,
|
||||
STORAGE_KEY_THEME_DRAFT
|
||||
} from '../../../core/constants';
|
||||
|
||||
export interface ThemeStorageSnapshot {
|
||||
activeText: string | null;
|
||||
draftText: string | null;
|
||||
}
|
||||
|
||||
function readStoredThemeText(key: string): string | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
|
||||
return typeof raw === 'string' && raw.length > 0
|
||||
? raw
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredThemeText(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
/* storage can be unavailable in private contexts */
|
||||
}
|
||||
}
|
||||
|
||||
export function loadThemeStorageSnapshot(): ThemeStorageSnapshot {
|
||||
return {
|
||||
activeText: readStoredThemeText(STORAGE_KEY_THEME_ACTIVE),
|
||||
draftText: readStoredThemeText(STORAGE_KEY_THEME_DRAFT)
|
||||
};
|
||||
}
|
||||
|
||||
export function saveActiveThemeText(text: string): void {
|
||||
writeStoredThemeText(STORAGE_KEY_THEME_ACTIVE, text);
|
||||
}
|
||||
|
||||
export function saveDraftThemeText(text: string): void {
|
||||
writeStoredThemeText(STORAGE_KEY_THEME_DRAFT, text);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
@if (showFloatingControls()) {
|
||||
<div class="fixed bottom-4 right-4 z-50 border border-border bg-card shadow-lg">
|
||||
<div
|
||||
appThemeNode="floatingVoiceControls"
|
||||
class="fixed bottom-4 right-4 z-50 border border-border bg-card shadow-lg"
|
||||
>
|
||||
<div class="flex items-center gap-2 p-2">
|
||||
<!-- Back to server button -->
|
||||
<button
|
||||
|
||||
@@ -27,6 +27,7 @@ import { ScreenShareFacade, ScreenShareQuality } from '../../../../domains/scree
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../../../shared';
|
||||
import { ThemeNodeDirective } from '../../../../domains/theme';
|
||||
|
||||
@Component({
|
||||
selector: 'app-floating-voice-controls',
|
||||
@@ -35,7 +36,8 @@ import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
DebugConsoleComponent,
|
||||
ScreenShareQualityDialogComponent
|
||||
ScreenShareQualityDialogComponent,
|
||||
ThemeNodeDirective
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
<div
|
||||
class="flex flex-col rounded-md border border-border bg-background px-3 py-2.5"
|
||||
[style.height]="showConnectionError() ? null : 'calc(100px - 0.75rem)'"
|
||||
>
|
||||
<div class="flex flex-col rounded-md border border-border bg-background px-3 py-2.5">
|
||||
<!-- Connection Error Banner -->
|
||||
@if (showConnectionError()) {
|
||||
<div class="mb-3 flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/10 p-2">
|
||||
@@ -18,7 +15,7 @@
|
||||
}
|
||||
|
||||
<!-- User Info -->
|
||||
<div class="mb-2 flex items-center gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<app-user-avatar
|
||||
[name]="currentUser()?.displayName || '?'"
|
||||
size="sm"
|
||||
@@ -56,89 +53,100 @@
|
||||
</div>
|
||||
|
||||
<!-- Voice Controls -->
|
||||
<div class="mt-auto flex items-center justify-center gap-2">
|
||||
@if (isConnected()) {
|
||||
<!-- Mute Toggle -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleMute()"
|
||||
[class]="getMuteButtonClass()"
|
||||
>
|
||||
@if (isMuted()) {
|
||||
<ng-icon
|
||||
name="lucideMicOff"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
} @else {
|
||||
<ng-icon
|
||||
name="lucideMic"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
}
|
||||
</button>
|
||||
<div
|
||||
class="grid overflow-hidden duration-200 ease-out motion-reduce:transition-none"
|
||||
style="transition-property: grid-template-rows, opacity, margin-top"
|
||||
[style.gridTemplateRows]="isConnected() ? '1fr' : '0fr'"
|
||||
[style.opacity]="isConnected() ? '1' : '0'"
|
||||
[style.marginTop.rem]="isConnected() ? 0.5 : 0"
|
||||
[style.visibility]="isConnected() ? 'visible' : 'hidden'"
|
||||
[class.pointer-events-none]="!isConnected()"
|
||||
[attr.aria-hidden]="isConnected() ? null : 'true'"
|
||||
>
|
||||
<div class="overflow-hidden">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<!-- Mute Toggle -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleMute()"
|
||||
[class]="getMuteButtonClass()"
|
||||
>
|
||||
@if (isMuted()) {
|
||||
<ng-icon
|
||||
name="lucideMicOff"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
} @else {
|
||||
<ng-icon
|
||||
name="lucideMic"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
}
|
||||
</button>
|
||||
|
||||
<!-- Deafen Toggle -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleDeafen()"
|
||||
[class]="getDeafenButtonClass()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideHeadphones"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
<!-- Deafen Toggle -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleDeafen()"
|
||||
[class]="getDeafenButtonClass()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideHeadphones"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Camera Toggle -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleCamera()"
|
||||
[class]="getCameraButtonClass()"
|
||||
>
|
||||
@if (isCameraEnabled()) {
|
||||
<ng-icon
|
||||
name="lucideVideoOff"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
} @else {
|
||||
<ng-icon
|
||||
name="lucideVideo"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
}
|
||||
</button>
|
||||
<!-- Camera Toggle -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleCamera()"
|
||||
[class]="getCameraButtonClass()"
|
||||
>
|
||||
@if (isCameraEnabled()) {
|
||||
<ng-icon
|
||||
name="lucideVideoOff"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
} @else {
|
||||
<ng-icon
|
||||
name="lucideVideo"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
}
|
||||
</button>
|
||||
|
||||
<!-- Screen Share Toggle -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleScreenShare()"
|
||||
[class]="getScreenShareButtonClass()"
|
||||
>
|
||||
@if (isScreenSharing()) {
|
||||
<ng-icon
|
||||
name="lucideMonitorOff"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
} @else {
|
||||
<ng-icon
|
||||
name="lucideMonitor"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
}
|
||||
</button>
|
||||
<!-- Screen Share Toggle -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleScreenShare()"
|
||||
[class]="getScreenShareButtonClass()"
|
||||
>
|
||||
@if (isScreenSharing()) {
|
||||
<ng-icon
|
||||
name="lucideMonitorOff"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
} @else {
|
||||
<ng-icon
|
||||
name="lucideMonitor"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
}
|
||||
</button>
|
||||
|
||||
<!-- Disconnect -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="disconnect()"
|
||||
class="inline-flex h-10 w-10 items-center justify-center rounded-md border border-destructive/20 bg-destructive/10 text-destructive transition-colors hover:bg-destructive/15"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePhoneOff"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
<!-- Disconnect -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="disconnect()"
|
||||
class="inline-flex h-10 w-10 items-center justify-center rounded-md border border-destructive/20 bg-destructive/10 text-destructive transition-colors hover:bg-destructive/15"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePhoneOff"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,29 +1,50 @@
|
||||
<div class="flex h-full flex-col bg-background">
|
||||
@if (currentRoom()) {
|
||||
<!-- Main Content -->
|
||||
<div class="flex min-h-0 flex-1 overflow-hidden">
|
||||
<aside class="flex min-h-0 w-[17rem] shrink-0 overflow-hidden border-r border-border bg-card">
|
||||
<div
|
||||
class="grid min-h-0 flex-1 overflow-hidden"
|
||||
[ngStyle]="roomLayoutStyles()"
|
||||
>
|
||||
<aside
|
||||
appThemeNode="chatRoomChannelsPanel"
|
||||
class="flex min-h-0 overflow-hidden border-r border-border bg-card"
|
||||
[ngStyle]="channelsPanelLayoutStyles()"
|
||||
>
|
||||
<app-rooms-side-panel
|
||||
panelMode="channels"
|
||||
class="block h-full w-full"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<!-- Chat Area -->
|
||||
<main class="relative min-h-0 min-w-0 flex-1 overflow-hidden bg-background">
|
||||
<main
|
||||
appThemeNode="chatRoomMainPanel"
|
||||
class="relative min-h-0 min-w-0 overflow-hidden bg-background"
|
||||
[ngStyle]="mainPanelLayoutStyles()"
|
||||
>
|
||||
@if (!isVoiceWorkspaceExpanded()) {
|
||||
@if (hasTextChannels()) {
|
||||
<div class="h-full overflow-hidden">
|
||||
<app-chat-messages />
|
||||
</div>
|
||||
} @else {
|
||||
<div class="flex h-full items-center justify-center px-6">
|
||||
<div
|
||||
appThemeNode="chatRoomEmptyState"
|
||||
class="flex h-full items-center justify-center px-6"
|
||||
>
|
||||
<div class="max-w-md text-center text-muted-foreground">
|
||||
<div
|
||||
data-theme-slot="icon"
|
||||
class="theme-icon-slot mx-auto mb-4 h-14 w-14 items-center justify-center rounded-3xl border border-border/70 bg-secondary/70 bg-center bg-cover bg-no-repeat text-sm font-semibold uppercase tracking-[0.18em] text-foreground"
|
||||
></div>
|
||||
<ng-icon
|
||||
name="lucideHash"
|
||||
class="mx-auto mb-4 h-16 w-16 opacity-30"
|
||||
/>
|
||||
<h2 class="mb-2 text-xl font-medium text-foreground">No text channels</h2>
|
||||
<h2
|
||||
data-theme-slot="text"
|
||||
class="mb-2 text-xl font-medium text-foreground"
|
||||
>
|
||||
No text channels
|
||||
</h2>
|
||||
<p class="text-sm">There are no existing text channels currently.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -33,7 +54,11 @@
|
||||
<app-voice-workspace />
|
||||
</main>
|
||||
|
||||
<aside class="flex min-h-0 w-[17rem] shrink-0 overflow-hidden border-l border-border bg-card">
|
||||
<aside
|
||||
appThemeNode="chatRoomMembersPanel"
|
||||
class="flex min-h-0 overflow-hidden border-l border-border bg-card"
|
||||
[ngStyle]="membersPanelLayoutStyles()"
|
||||
>
|
||||
<app-rooms-side-panel
|
||||
panelMode="users"
|
||||
[showVoiceControls]="false"
|
||||
@@ -42,14 +67,25 @@
|
||||
</aside>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- No Room Selected -->
|
||||
<div class="flex flex-1 items-center justify-center bg-background px-6">
|
||||
<div
|
||||
appThemeNode="chatRoomEmptyState"
|
||||
class="flex flex-1 items-center justify-center bg-background px-6"
|
||||
>
|
||||
<div class="text-center text-muted-foreground">
|
||||
<div
|
||||
data-theme-slot="icon"
|
||||
class="theme-icon-slot mx-auto mb-4 h-14 w-14 items-center justify-center rounded-3xl border border-border/70 bg-secondary/70 bg-center bg-cover bg-no-repeat text-sm font-semibold uppercase tracking-[0.18em] text-foreground"
|
||||
></div>
|
||||
<ng-icon
|
||||
name="lucideHash"
|
||||
class="mx-auto mb-4 h-16 w-16 opacity-30"
|
||||
/>
|
||||
<h2 class="mb-2 text-xl font-medium">No room selected</h2>
|
||||
<h2
|
||||
data-theme-slot="text"
|
||||
class="mb-2 text-xl font-medium"
|
||||
>
|
||||
No room selected
|
||||
</h2>
|
||||
<p class="text-sm">Select or create a room to start chatting</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,10 @@ import {
|
||||
import { SettingsModalService } from '../../../core/services/settings-modal.service';
|
||||
import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
||||
import { VoiceWorkspaceService } from '../../../domains/voice-session';
|
||||
import {
|
||||
ThemeNodeDirective,
|
||||
ThemeService
|
||||
} from '../../../domains/theme';
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-room',
|
||||
@@ -38,7 +42,8 @@ import { VoiceWorkspaceService } from '../../../domains/voice-session';
|
||||
NgIcon,
|
||||
ChatMessagesComponent,
|
||||
VoiceWorkspaceComponent,
|
||||
RoomsSidePanelComponent
|
||||
RoomsSidePanelComponent,
|
||||
ThemeNodeDirective
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
@@ -59,6 +64,7 @@ import { VoiceWorkspaceService } from '../../../domains/voice-session';
|
||||
export class ChatRoomComponent {
|
||||
private readonly store = inject(Store);
|
||||
private readonly settingsModal = inject(SettingsModalService);
|
||||
private readonly theme = inject(ThemeService);
|
||||
private voiceWorkspace = inject(VoiceWorkspaceService);
|
||||
showMenu = signal(false);
|
||||
showAdminPanel = signal(false);
|
||||
@@ -68,6 +74,10 @@ export class ChatRoomComponent {
|
||||
textChannels = this.store.selectSignal(selectTextChannels);
|
||||
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
|
||||
hasTextChannels = computed(() => this.textChannels().length > 0);
|
||||
roomLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('roomLayout'));
|
||||
channelsPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomChannelsPanel'));
|
||||
mainPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomMainPanel'));
|
||||
membersPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomMembersPanel'));
|
||||
|
||||
/** Open the settings modal to the Server admin page for the current room. */
|
||||
toggleAdminPanel() {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div class="absolute inset-0">
|
||||
@if (showExpanded()) {
|
||||
<section
|
||||
appThemeNode="voiceWorkspace"
|
||||
class="pointer-events-auto absolute inset-0 bg-background/95 backdrop-blur-xl"
|
||||
(mouseenter)="onWorkspacePointerMove()"
|
||||
(mousemove)="onWorkspacePointerMove()"
|
||||
@@ -276,6 +277,7 @@
|
||||
|
||||
@if (showMiniWindow()) {
|
||||
<div
|
||||
appThemeNode="voiceWorkspace"
|
||||
class="pointer-events-auto absolute z-20 w-[20rem] select-none overflow-hidden rounded-[1.75rem] border border-white/10 bg-card/95 shadow-2xl backdrop-blur-xl"
|
||||
[style.left.px]="miniPosition().left"
|
||||
[style.top.px]="miniPosition().top"
|
||||
|
||||
@@ -50,6 +50,7 @@ import { ScreenShareQualityDialogComponent, UserAvatarComponent } from '../../..
|
||||
import { VoiceWorkspacePlaybackService } from './voice-workspace-playback.service';
|
||||
import { VoiceWorkspaceStreamTileComponent } from './voice-workspace-stream-tile.component';
|
||||
import { VoiceWorkspaceStreamItem } from './voice-workspace.models';
|
||||
import { ThemeNodeDirective } from '../../../domains/theme';
|
||||
|
||||
@Component({
|
||||
selector: 'app-voice-workspace',
|
||||
@@ -59,7 +60,8 @@ import { VoiceWorkspaceStreamItem } from './voice-workspace.models';
|
||||
NgIcon,
|
||||
ScreenShareQualityDialogComponent,
|
||||
VoiceWorkspaceStreamTileComponent,
|
||||
UserAvatarComponent
|
||||
UserAvatarComponent,
|
||||
ThemeNodeDirective
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
|
||||
@if (isOpen()) {
|
||||
@if (isOpen() && !isThemeStudioFullscreen()) {
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-[90] bg-black/80 backdrop-blur-sm transition-opacity duration-200"
|
||||
@@ -16,7 +16,8 @@
|
||||
<!-- Modal -->
|
||||
<div class="fixed inset-0 z-[91] flex items-center justify-center p-4 pointer-events-none">
|
||||
<div
|
||||
class="pointer-events-auto relative bg-card border border-border rounded-xl shadow-2xl w-full max-w-4xl h-[min(680px,85vh)] flex overflow-hidden transition-all duration-200"
|
||||
class="pointer-events-auto relative flex w-full max-w-4xl overflow-hidden rounded-xl border border-border bg-card shadow-2xl transition-all duration-200"
|
||||
style="height: min(680px, 85vh)"
|
||||
[class.scale-100]="animating()"
|
||||
[class.opacity-100]="animating()"
|
||||
[class.scale-95]="!animating()"
|
||||
@@ -29,7 +30,7 @@
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- Side Navigation -->
|
||||
<nav class="w-52 flex-shrink-0 bg-secondary/40 border-r border-border flex flex-col">
|
||||
<nav class="flex w-52 flex-shrink-0 flex-col border-r border-border bg-secondary/40">
|
||||
<div class="p-4 border-b border-border">
|
||||
<h2 class="text-lg font-semibold text-foreground">Settings</h2>
|
||||
</div>
|
||||
@@ -99,7 +100,15 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mt-auto border-t border-border px-4 py-3">
|
||||
<div class="mt-auto space-y-3 border-t border-border px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
(click)="restoreDefaultTheme()"
|
||||
class="w-full rounded-lg border border-destructive/25 bg-destructive/10 px-3 py-2 text-left text-xs font-medium text-destructive transition-colors hover:bg-destructive/15"
|
||||
>
|
||||
Restore default theme
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="openThirdPartyLicenses()"
|
||||
@@ -122,6 +131,9 @@
|
||||
@case ('network') {
|
||||
Network
|
||||
}
|
||||
@case ('theme') {
|
||||
Theme Studio
|
||||
}
|
||||
@case ('notifications') {
|
||||
Notifications
|
||||
}
|
||||
@@ -148,16 +160,18 @@
|
||||
}
|
||||
}
|
||||
</h3>
|
||||
<button
|
||||
(click)="close()"
|
||||
type="button"
|
||||
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
(click)="close()"
|
||||
type="button"
|
||||
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable Content Area -->
|
||||
@@ -169,6 +183,74 @@
|
||||
@case ('network') {
|
||||
<app-network-settings />
|
||||
}
|
||||
@case ('theme') {
|
||||
<div class="mx-auto flex h-full max-w-3xl items-center justify-center">
|
||||
<div class="w-full rounded-[1.5rem] border border-border bg-card/90 p-6 shadow-sm">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-primary">Active Theme</p>
|
||||
<h4 class="mt-2 text-xl font-semibold text-foreground">{{ activeThemeName() }}</h4>
|
||||
</div>
|
||||
|
||||
@if (themeStudioMinimized()) {
|
||||
<span class="rounded-full border border-primary/25 bg-primary/10 px-3 py-1 text-xs font-semibold text-primary">Minimized</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (savedThemesAvailable()) {
|
||||
<div class="mt-5">
|
||||
<label class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Saved Theme</label>
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<select
|
||||
class="min-w-[16rem] flex-1 rounded-full border border-border bg-background px-3 py-2 text-sm font-medium text-foreground outline-none transition-colors focus:border-primary/40 focus:ring-2 focus:ring-primary/15"
|
||||
[value]="selectedSavedTheme()?.fileName || ''"
|
||||
[disabled]="savedThemesBusy() && savedThemes().length === 0"
|
||||
(change)="onSavedThemeSelect($event)"
|
||||
>
|
||||
<option value="">{{ savedThemes().length > 0 ? 'Choose saved theme' : 'No saved themes' }}</option>
|
||||
@for (savedTheme of savedThemes(); track savedTheme.fileName) {
|
||||
<option
|
||||
[value]="savedTheme.fileName"
|
||||
[disabled]="!savedTheme.isValid"
|
||||
>
|
||||
{{ savedTheme.themeName }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="editSelectedSavedTheme()"
|
||||
[disabled]="!selectedSavedTheme()?.isValid || (savedThemesBusy() && savedThemes().length === 0)"
|
||||
class="inline-flex items-center rounded-full border border-border bg-background px-3 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Edit In Studio
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mt-5 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
(click)="openThemeStudio()"
|
||||
class="inline-flex items-center rounded-full bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
{{ themeStudioMinimized() ? 'Re-open Theme Studio' : 'Open Theme Studio' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="restoreDefaultTheme()"
|
||||
class="inline-flex items-center rounded-full border border-destructive/25 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive transition-colors hover:bg-destructive/15"
|
||||
>
|
||||
Restore Default
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@case ('notifications') {
|
||||
<app-notifications-settings />
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
signal,
|
||||
computed,
|
||||
effect,
|
||||
untracked,
|
||||
HostListener,
|
||||
viewChild
|
||||
} from '@angular/core';
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
lucideDownload,
|
||||
lucideGlobe,
|
||||
lucideAudioLines,
|
||||
lucidePalette,
|
||||
lucideSettings,
|
||||
lucideUsers,
|
||||
lucideBan,
|
||||
@@ -43,6 +45,7 @@ import { PermissionsSettingsComponent } from './permissions-settings/permissions
|
||||
import { DebuggingSettingsComponent } from './debugging-settings/debugging-settings.component';
|
||||
import { UpdatesSettingsComponent } from './updates-settings/updates-settings.component';
|
||||
import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-licenses';
|
||||
import { ThemeLibraryService, ThemeService } from '../../../domains/theme';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-modal',
|
||||
@@ -70,6 +73,7 @@ import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-lice
|
||||
lucideDownload,
|
||||
lucideGlobe,
|
||||
lucideAudioLines,
|
||||
lucidePalette,
|
||||
lucideSettings,
|
||||
lucideUsers,
|
||||
lucideBan,
|
||||
@@ -82,6 +86,8 @@ export class SettingsModalComponent {
|
||||
readonly modal = inject(SettingsModalService);
|
||||
private store = inject(Store);
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
private theme = inject(ThemeService);
|
||||
private themeLibrary = inject(ThemeLibraryService);
|
||||
readonly thirdPartyLicenses: readonly ThirdPartyLicense[] = THIRD_PARTY_LICENSES;
|
||||
private lastRequestedServerId: string | null = null;
|
||||
|
||||
@@ -93,11 +99,22 @@ export class SettingsModalComponent {
|
||||
|
||||
isOpen = this.modal.isOpen;
|
||||
activePage = this.modal.activePage;
|
||||
themeStudioFullscreen = this.modal.themeStudioFullscreen;
|
||||
themeStudioMinimized = this.modal.themeStudioMinimized;
|
||||
isThemeStudioFullscreen = computed(() => this.activePage() === 'theme' && this.themeStudioFullscreen());
|
||||
activeThemeName = this.theme.activeThemeName;
|
||||
savedThemesAvailable = this.themeLibrary.isAvailable;
|
||||
savedThemes = this.themeLibrary.entries;
|
||||
savedThemesBusy = this.themeLibrary.isBusy;
|
||||
selectedSavedTheme = this.themeLibrary.selectedEntry;
|
||||
|
||||
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
|
||||
{ id: 'general',
|
||||
label: 'General',
|
||||
icon: 'lucideSettings' },
|
||||
{ id: 'theme',
|
||||
label: 'Theme Studio',
|
||||
icon: 'lucidePalette' },
|
||||
{ id: 'network',
|
||||
label: 'Network',
|
||||
icon: 'lucideGlobe' },
|
||||
@@ -220,6 +237,16 @@ export class SettingsModalComponent {
|
||||
this.animating.set(true);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (!this.isOpen() || this.activePage() !== 'theme' || !this.savedThemesAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
untracked(() => {
|
||||
void this.refreshSavedThemes();
|
||||
});
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const server = this.selectedServer();
|
||||
|
||||
@@ -280,6 +307,11 @@ export class SettingsModalComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isThemeStudioFullscreen()) {
|
||||
this.modal.minimizeThemeStudio();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isOpen()) {
|
||||
this.close();
|
||||
}
|
||||
@@ -303,6 +335,40 @@ export class SettingsModalComponent {
|
||||
this.modal.navigate(page);
|
||||
}
|
||||
|
||||
openThemeStudio(): void {
|
||||
this.modal.openThemeStudio();
|
||||
}
|
||||
|
||||
async refreshSavedThemes(): Promise<void> {
|
||||
await this.themeLibrary.refresh();
|
||||
this.syncSavedThemeSelectionToActiveTheme();
|
||||
}
|
||||
|
||||
async onSavedThemeSelect(event: Event): Promise<void> {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
const fileName = select.value || null;
|
||||
|
||||
this.themeLibrary.select(fileName);
|
||||
|
||||
if (!fileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const applied = await this.themeLibrary.useSelectedTheme();
|
||||
|
||||
if (!applied) {
|
||||
this.syncSavedThemeSelectionToActiveTheme();
|
||||
}
|
||||
}
|
||||
|
||||
async editSelectedSavedTheme(): Promise<void> {
|
||||
const opened = await this.themeLibrary.openSelectedThemeInDraft();
|
||||
|
||||
if (opened) {
|
||||
this.modal.openThemeStudio();
|
||||
}
|
||||
}
|
||||
|
||||
onBackdropClick(): void {
|
||||
this.close();
|
||||
}
|
||||
@@ -312,4 +378,16 @@ export class SettingsModalComponent {
|
||||
|
||||
this.selectedServerId.set(select.value || null);
|
||||
}
|
||||
|
||||
restoreDefaultTheme(): void {
|
||||
this.theme.resetToDefault('button');
|
||||
this.syncSavedThemeSelectionToActiveTheme();
|
||||
this.navigate('theme');
|
||||
}
|
||||
|
||||
private syncSavedThemeSelectionToActiveTheme(): void {
|
||||
const matchingTheme = this.savedThemes().find((entry) => entry.isValid && entry.themeName === this.activeThemeName()) ?? null;
|
||||
|
||||
this.themeLibrary.select(matchingTheme?.fileName ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,38 @@
|
||||
<div
|
||||
class="fixed left-16 right-0 top-0 z-50 flex h-10 items-center justify-between border-b border-border bg-card px-4 select-none"
|
||||
appThemeNode="titleBar"
|
||||
class="relative z-50 flex h-10 w-full items-center justify-between border-b border-border bg-card px-4 select-none"
|
||||
style="-webkit-app-region: drag"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2 min-w-0 relative"
|
||||
style="-webkit-app-region: no-drag"
|
||||
>
|
||||
<span
|
||||
data-theme-slot="icon"
|
||||
class="theme-icon-slot h-6 w-6 shrink-0 items-center justify-center rounded-xl border border-border/70 bg-secondary/70 bg-center bg-cover bg-no-repeat text-[10px] font-semibold uppercase tracking-[0.16em] text-foreground"
|
||||
></span>
|
||||
|
||||
@if (inRoom()) {
|
||||
<ng-icon
|
||||
name="lucideHash"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
<span class="truncate text-sm font-semibold text-foreground">{{ roomContextTitle() }}</span>
|
||||
<span
|
||||
data-theme-slot="text"
|
||||
class="flex min-w-0 items-center gap-2 text-sm font-semibold text-foreground"
|
||||
>
|
||||
<span class="truncate">{{ roomName() }}</span>
|
||||
|
||||
@if (isVoiceWorkspaceExpanded()) {
|
||||
<span class="shrink-0 text-muted-foreground">/</span>
|
||||
<span class="truncate">{{ connectedVoiceChannelName() }}</span>
|
||||
} @else if (textChannels().length > 0) {
|
||||
<span class="shrink-0 text-muted-foreground">/</span>
|
||||
<span class="flex min-w-0 items-center gap-1 text-foreground">
|
||||
<ng-icon
|
||||
name="lucideHash"
|
||||
class="h-4 w-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<span class="truncate">{{ activeTextChannelName() }}</span>
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
|
||||
@if (showRoomCompatibilityNotice()) {
|
||||
<span class="inline-flex items-center gap-1 rounded bg-destructive/15 px-2 py-0.5 text-xs text-destructive">
|
||||
@@ -36,7 +57,11 @@
|
||||
}
|
||||
} @else {
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-sm text-muted-foreground truncate">{{ username() }} | {{ serverName() }}</span>
|
||||
<span
|
||||
data-theme-slot="text"
|
||||
class="text-sm text-muted-foreground truncate"
|
||||
>{{ username() }} | {{ serverName() }}</span
|
||||
>
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive"
|
||||
[class.hidden]="!isReconnecting()"
|
||||
@@ -59,57 +84,62 @@
|
||||
Login
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleMenu()"
|
||||
class="ml-2 rounded-md p-2 transition-colors hover:bg-secondary"
|
||||
title="Menu"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideMenu"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
<!-- Anchored dropdown under the menu button -->
|
||||
@if (showMenu()) {
|
||||
<div class="absolute right-0 top-full z-50 mt-2 w-64 rounded-md border border-border bg-popover p-1 shadow-lg">
|
||||
@if (inRoom()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="createInviteLink()"
|
||||
[disabled]="creatingInvite()"
|
||||
class="w-full rounded-md px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleMenu()"
|
||||
class="grid h-8 w-8 place-items-center rounded-md transition-colors hover:bg-secondary"
|
||||
title="Menu"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideMenu"
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
|
||||
@if (showMenu()) {
|
||||
<div
|
||||
class="absolute right-0 top-full z-50 mt-2 w-64 rounded-md border border-border bg-popover p-1 shadow-lg"
|
||||
style="-webkit-app-region: no-drag"
|
||||
>
|
||||
@if (inRoom()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="createInviteLink()"
|
||||
[disabled]="creatingInvite()"
|
||||
class="w-full rounded-md px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
@if (creatingInvite()) {
|
||||
Creating Invite Link…
|
||||
} @else {
|
||||
Create Invite Link
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="leaveServer()"
|
||||
class="w-full rounded-md px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-secondary"
|
||||
>
|
||||
Leave Server
|
||||
</button>
|
||||
}
|
||||
<div
|
||||
class="px-3 py-2 text-xs leading-5 text-muted-foreground"
|
||||
[class.hidden]="!inviteStatus()"
|
||||
>
|
||||
@if (creatingInvite()) {
|
||||
Creating Invite Link…
|
||||
} @else {
|
||||
Create Invite Link
|
||||
}
|
||||
</button>
|
||||
{{ inviteStatus() }}
|
||||
</div>
|
||||
<div class="mx-2 my-1 h-px bg-border"></div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="leaveServer()"
|
||||
(click)="logout()"
|
||||
class="w-full rounded-md px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-secondary"
|
||||
>
|
||||
Leave Server
|
||||
Logout
|
||||
</button>
|
||||
}
|
||||
<div
|
||||
class="px-3 py-2 text-xs leading-5 text-muted-foreground"
|
||||
[class.hidden]="!inviteStatus()"
|
||||
>
|
||||
{{ inviteStatus() }}
|
||||
</div>
|
||||
<div class="mx-2 my-1 h-px bg-border"></div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="logout()"
|
||||
class="w-full rounded-md px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-secondary"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (isElectron()) {
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -37,6 +37,7 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
|
||||
import { LeaveServerDialogComponent } from '../../shared';
|
||||
import { Room } from '../../shared-kernel';
|
||||
import { VoiceWorkspaceService } from '../../domains/voice-session';
|
||||
import { ThemeNodeDirective } from '../../domains/theme';
|
||||
|
||||
@Component({
|
||||
selector: 'app-title-bar',
|
||||
@@ -44,7 +45,8 @@ import { VoiceWorkspaceService } from '../../domains/voice-session';
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
LeaveServerDialogComponent
|
||||
LeaveServerDialogComponent,
|
||||
ThemeNodeDirective
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({ lucideMinus,
|
||||
@@ -109,23 +111,6 @@ export class TitleBarComponent {
|
||||
|
||||
return voiceChannel?.name || 'Voice Lounge';
|
||||
});
|
||||
roomContextTitle = computed(() => {
|
||||
const room = this.currentRoom();
|
||||
|
||||
if (!room) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (this.isVoiceWorkspaceExpanded()) {
|
||||
return `${room.name} / ${this.connectedVoiceChannelName()}`;
|
||||
}
|
||||
|
||||
if (this.textChannels().length === 0) {
|
||||
return room.name;
|
||||
}
|
||||
|
||||
return `${room.name} / #${this.activeTextChannelName()}`;
|
||||
});
|
||||
roomContextMeta = computed(() => {
|
||||
if (!this.currentRoom()) {
|
||||
return '';
|
||||
|
||||
@@ -87,3 +87,306 @@
|
||||
--ring: 214 72% 50%;
|
||||
--radius: 0.375rem;
|
||||
}
|
||||
|
||||
.theme-icon-slot {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.theme-icon-slot[data-theme-visible='true'] {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.theme-settings__workspace {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: minmax(18rem, 22rem) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.theme-settings__sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.theme-settings__main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.theme-settings--fullscreen .theme-settings__workspace {
|
||||
gap: 0.875rem;
|
||||
grid-template-columns: minmax(15.5rem, 17rem) minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.theme-studio-fullscreen-shell {
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--muted-foreground) / 0.6) hsl(var(--card) / 0.75);
|
||||
}
|
||||
|
||||
.theme-studio-fullscreen-shell::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.theme-studio-fullscreen-shell::-webkit-scrollbar-track {
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--card) / 0.75);
|
||||
}
|
||||
|
||||
.theme-studio-fullscreen-shell::-webkit-scrollbar-thumb {
|
||||
border: 2px solid transparent;
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--muted-foreground) / 0.6);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.theme-studio-fullscreen-shell::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--primary) / 0.45);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.theme-settings--fullscreen .theme-settings__sidebar {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.theme-settings--fullscreen .theme-settings__sidebar .theme-studio-card:last-child {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.theme-settings--fullscreen .theme-settings__main {
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.theme-settings--fullscreen .theme-studio-card {
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 10px 24px rgb(15 23 42 / 0.08);
|
||||
}
|
||||
|
||||
.theme-settings--fullscreen .theme-settings__hero-grid {
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.theme-settings--fullscreen .theme-settings__hero-stat {
|
||||
border-radius: 0.85rem;
|
||||
padding: 0.7rem 0.85rem;
|
||||
}
|
||||
|
||||
.theme-settings--fullscreen .theme-settings__hero-label {
|
||||
font-size: 0.64rem;
|
||||
}
|
||||
|
||||
.theme-settings--fullscreen .theme-settings__hero-value {
|
||||
margin-top: 0.3rem;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.theme-settings--fullscreen .theme-settings__workspace-tab,
|
||||
.theme-settings--fullscreen .theme-settings__entry-button {
|
||||
border-radius: 0.85rem;
|
||||
padding: 0.72rem 0.8rem;
|
||||
}
|
||||
|
||||
.theme-settings--fullscreen .theme-settings__search-input {
|
||||
border-radius: 0.85rem;
|
||||
padding: 0.72rem 0.85rem;
|
||||
}
|
||||
|
||||
.theme-settings--fullscreen .theme-settings__entry-list {
|
||||
max-height: none;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.theme-studio-card {
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 1.5rem;
|
||||
background:
|
||||
linear-gradient(180deg, hsl(var(--card) / 0.96), hsl(var(--background) / 0.96)),
|
||||
radial-gradient(circle at top right, hsl(var(--primary) / 0.08), transparent 45%);
|
||||
box-shadow: 0 14px 34px rgb(15 23 42 / 0.08);
|
||||
}
|
||||
|
||||
.theme-settings__hero-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.theme-settings__hero-stat {
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 1rem;
|
||||
background: hsl(var(--background) / 0.72);
|
||||
padding: 0.9rem 1rem;
|
||||
}
|
||||
|
||||
.theme-settings__hero-label {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.theme-settings__hero-value {
|
||||
display: block;
|
||||
margin-top: 0.45rem;
|
||||
font-size: 0.98rem;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.theme-settings__workspace-tab {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 1rem;
|
||||
background: hsl(var(--background) / 0.72);
|
||||
padding: 0.85rem 0.95rem;
|
||||
text-align: left;
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
border-color 160ms ease,
|
||||
transform 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.theme-settings__workspace-tab:hover {
|
||||
background: hsl(var(--secondary) / 0.78);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.theme-settings__workspace-tab--active {
|
||||
border-color: hsl(var(--primary) / 0.4);
|
||||
background: hsl(var(--primary) / 0.08);
|
||||
box-shadow: 0 8px 22px hsl(var(--primary) / 0.12);
|
||||
}
|
||||
|
||||
.theme-settings__workspace-tab-title {
|
||||
display: block;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.theme-settings__workspace-tab-description {
|
||||
display: block;
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.45;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.theme-settings__search-input {
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.95rem;
|
||||
background: hsl(var(--background) / 0.82);
|
||||
padding: 0.8rem 0.95rem;
|
||||
font-size: 0.9rem;
|
||||
color: hsl(var(--foreground));
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease,
|
||||
background-color 160ms ease;
|
||||
}
|
||||
|
||||
.theme-settings__search-input:focus {
|
||||
border-color: hsl(var(--primary) / 0.4);
|
||||
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.12);
|
||||
}
|
||||
|
||||
.theme-settings__entry-list {
|
||||
display: flex;
|
||||
max-height: min(60vh, 34rem);
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
overflow: auto;
|
||||
padding-right: 0.15rem;
|
||||
}
|
||||
|
||||
.theme-settings__entry-button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 1rem;
|
||||
background: hsl(var(--background) / 0.72);
|
||||
padding: 0.9rem 0.95rem;
|
||||
text-align: left;
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
border-color 160ms ease,
|
||||
transform 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.theme-settings__entry-button:hover {
|
||||
background: hsl(var(--secondary) / 0.72);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.theme-settings__entry-button--active {
|
||||
border-color: hsl(var(--primary) / 0.42);
|
||||
background: hsl(var(--primary) / 0.08);
|
||||
box-shadow: 0 8px 24px hsl(var(--primary) / 0.12);
|
||||
}
|
||||
|
||||
@media (max-width: 1279px) {
|
||||
.theme-settings__hero-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.theme-settings__workspace {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.theme-settings__sidebar {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.theme-settings__entry-list {
|
||||
max-height: 24rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.theme-settings__hero-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme-key][data-theme-linked='true'] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-theme-key][data-theme-picker-active='true'] {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
[data-theme-key][data-theme-picker-hovered='true'] {
|
||||
outline: 2px solid hsl(var(--primary));
|
||||
outline-offset: -2px;
|
||||
box-shadow: inset 0 0 0 9999px hsl(var(--primary) / 0.08);
|
||||
}
|
||||
|
||||
[data-theme-key][data-theme-picker-selected='true'] {
|
||||
outline: 2px solid hsl(var(--primary));
|
||||
outline-offset: -2px;
|
||||
box-shadow:
|
||||
0 0 0 3px hsl(var(--primary) / 0.18),
|
||||
inset 0 0 0 9999px hsl(var(--primary) / 0.06);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user