feat: Theme engine

big changes
This commit is contained in:
2026-04-02 00:08:38 +02:00
parent 65b9419869
commit bbb6deb0a2
48 changed files with 6150 additions and 235 deletions

View File

@@ -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());

View File

@@ -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
View 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
View File

@@ -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",

View File

@@ -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.

View File

@@ -97,7 +97,7 @@
{
"type": "initial",
"maximumWarning": "1MB",
"maximumError": "2.1MB"
"maximumError": "2.15MB"
},
{
"type": "anyComponentStyle",

View File

@@ -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" />

View File

@@ -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();

View File

@@ -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;

View File

@@ -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>;

View File

@@ -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);
}
}

View File

@@ -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` |

View File

@@ -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;
}
}

View File

@@ -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.`);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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);
}
}

View 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');

View 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);

View 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)[];
}

View 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);
}

View 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)'
}
}
};
}

View 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>)
};
}

View File

@@ -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>

View File

@@ -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));
}

View File

@@ -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);
}
}

View File

@@ -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
});
});
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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);
}
}

View 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 = '';
}
}

View File

@@ -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();
}
}

View 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';

View File

@@ -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
};
}
}
}

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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({

View File

@@ -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>

View File

@@ -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>

View File

@@ -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() {

View File

@@ -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"

View File

@@ -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({

View File

@@ -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 />
}

View File

@@ -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);
}
}

View File

@@ -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"

View File

@@ -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 '';

View File

@@ -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);
}