diff --git a/electron/ipc/system.ts b/electron/ipc/system.ts index cc701a4..e1fe385 100644 --- a/electron/ipc/system.ts +++ b/electron/ipc/system.ts @@ -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()); diff --git a/electron/preload.ts b/electron/preload.ts index f956dbe..29daf80 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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; + getSavedThemesPath: () => Promise; + listSavedThemes: () => Promise; + readSavedTheme: (fileName: string) => Promise; + writeSavedTheme: (fileName: string, text: string) => Promise; + deleteSavedTheme: (fileName: string) => Promise; consumePendingDeepLink: () => Promise; 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), diff --git a/electron/theme-library.ts b/electron/theme-library.ts new file mode 100644 index 0000000..da1c180 --- /dev/null +++ b/electron/theme-library.ts @@ -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 { + 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 { + const themesPath = await ensureSavedThemesPath(); + + return path.join(themesPath, assertSavedThemeFileName(fileName)); +} + +export async function getSavedThemesPath(): Promise { + return await ensureSavedThemesPath(); +} + +export async function listSavedThemes(): Promise { + 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 { + const filePath = await resolveSavedThemeFilePath(fileName); + + return await fsp.readFile(filePath, 'utf8'); +} + +export async function writeSavedTheme(fileName: string, text: string): Promise { + const filePath = await resolveSavedThemeFilePath(fileName); + + await fsp.writeFile(filePath, text, 'utf8'); + return true; +} + +export async function deleteSavedTheme(fileName: string): Promise { + 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; + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ac94883..4966a71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index de36c4f..f6afff6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/data/metoyou.sqlite b/server/data/metoyou.sqlite index f19b4c7..d146c46 100644 Binary files a/server/data/metoyou.sqlite and b/server/data/metoyou.sqlite differ diff --git a/toju-app/angular.json b/toju-app/angular.json index f182026..dd6ef4a 100644 --- a/toju-app/angular.json +++ b/toju-app/angular.json @@ -97,7 +97,7 @@ { "type": "initial", "maximumWarning": "1MB", - "maximumError": "2.1MB" + "maximumError": "2.15MB" }, { "type": "anyComponentStyle", diff --git a/toju-app/src/app/app.html b/toju-app/src/app/app.html index b2b15f4..8982483 100644 --- a/toju-app/src/app/app.html +++ b/toju-app/src/app/app.html @@ -1,60 +1,139 @@ -
- - -
- - +
+
+ - @if (desktopUpdateState().restartRequired) { -
-
-
-
-

Update ready to install

-

- MetoYou {{ desktopUpdateState().targetVersion || 'update' }} has been downloaded. Restart the app to finish applying it. -

-
+
+ -
- +
+ @if (isThemeStudioFullscreen()) { +
+ @if (themeStudioFullscreenComponent()) { + + } @else { +
Loading Theme Studio…
+ } +
+ } @else { @if (desktopUpdateState().restartRequired) { +
+
+
+
+

Update ready to install

+

+ MetoYou {{ desktopUpdateState().targetVersion || 'update' }} has been downloaded. Restart the app to finish applying it. +

+
- +
+ + + +
+
+ } + +
+ +
+ }
-
- } +
+
- -
- -
-
+ @if (isThemeStudioFullscreen()) { +
+
+
+ Theme Studio +
- + + + +
+
+ } @if (isThemeStudioMinimized()) { +
+
+
+

Theme Studio

+

Minimized

+
+ + + + +
+
+ } @if (!isThemeStudioFullscreen()) { + } + + + +
- - - - - - - - - diff --git a/toju-app/src/app/app.ts b/toju-app/src/app/app.ts index 81b51f8..c0eb431 100644 --- a/toju-app/src/app/app.ts +++ b/toju-app/src/app/app.ts @@ -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 | 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 { + 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 { 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 { const electronApi = this.electronBridge.getApi(); diff --git a/toju-app/src/app/core/constants.ts b/toju-app/src/app/core/constants.ts index 5ba6e31..db4bc8b 100644 --- a/toju-app/src/app/core/constants.ts +++ b/toju-app/src/app/core/constants.ts @@ -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; diff --git a/toju-app/src/app/core/platform/electron/electron-api.models.ts b/toju-app/src/app/core/platform/electron/electron-api.models.ts index defb1fc..65d34e2 100644 --- a/toju-app/src/app/core/platform/electron/electron-api.models.ts +++ b/toju-app/src/app/core/platform/electron/electron-api.models.ts @@ -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; + getSavedThemesPath: () => Promise; + listSavedThemes: () => Promise; + readSavedTheme: (fileName: string) => Promise; + writeSavedTheme: (fileName: string, text: string) => Promise; + deleteSavedTheme: (fileName: string) => Promise; consumePendingDeepLink: () => Promise; getDesktopSettings: () => Promise; showDesktopNotification: (payload: DesktopNotificationPayload) => Promise; diff --git a/toju-app/src/app/core/services/settings-modal.service.ts b/toju-app/src/app/core/services/settings-modal.service.ts index dcfb525..03349fb 100644 --- a/toju-app/src/app/core/services/settings-modal.service.ts +++ b/toju-app/src/app/core/services/settings-modal.service.ts @@ -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('general'); readonly targetServerId = signal(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); } } diff --git a/toju-app/src/app/domains/README.md b/toju-app/src/app/domains/README.md index 673fa87..96e4352 100644 --- a/toju-app/src/app/domains/README.md +++ b/toju-app/src/app/domains/README.md @@ -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` | diff --git a/toju-app/src/app/domains/theme/application/element-picker.service.ts b/toju-app/src/app/domains/theme/application/element-picker.service.ts new file mode 100644 index 0000000..cf136f7 --- /dev/null +++ b/toju-app/src/app/domains/theme/application/element-picker.service.ts @@ -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(null); + readonly selectedKey = signal(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('[data-theme-key]'); + const key = themedElement?.dataset['themeKey'] ?? null; + const definition = this.registry.getDefinition(key); + + return definition?.pickerVisible + ? key + : null; + } +} \ No newline at end of file diff --git a/toju-app/src/app/domains/theme/application/layout-sync.service.ts b/toju-app/src/app/domains/theme/application/layout-sync.service.ts new file mode 100644 index 0000000..8ebead8 --- /dev/null +++ b/toju-app/src/app/domains/theme/application/layout-sync.service.ts @@ -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) => { + draft.layout[key] = { + ...draft.layout[key], + grid + }; + }, true, `${key} layout updated.`); + } + + resetContainer(containerKey: ThemeContainerKey): void { + const defaults = createDefaultThemeDocument(); + + this.theme.updateStructuredDraft((draft: ReturnType) => { + 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.`); + } +} \ No newline at end of file diff --git a/toju-app/src/app/domains/theme/application/theme-library.service.ts b/toju-app/src/app/domains/theme/application/theme-library.service.ts new file mode 100644 index 0000000..0e12963 --- /dev/null +++ b/toju-app/src/app/domains/theme/application/theme-library.service.ts @@ -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([]); + readonly selectedFileName = signal(null); + readonly isBusy = signal(false); + readonly savedThemesPath = signal(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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } +} \ No newline at end of file diff --git a/toju-app/src/app/domains/theme/application/theme-registry.service.ts b/toju-app/src/app/domains/theme/application/theme-registry.service.ts new file mode 100644 index 0000000..cb68dc3 --- /dev/null +++ b/toju-app/src/app/domains/theme/application/theme-registry.service.ts @@ -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>({}); + private readonly mountedHosts = new Map>(); + + 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(); + + 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 = {}; + + for (const [key, hosts] of this.mountedHosts.entries()) { + nextCounts[key] = hosts.size; + } + + this.mountedCounts.set(nextCounts); + } +} \ No newline at end of file diff --git a/toju-app/src/app/domains/theme/application/theme.service.ts b/toju-app/src/app/domains/theme/application/theme.service.ts new file mode 100644 index 0000000..2214590 --- /dev/null +++ b/toju-app/src/app/domains/theme/application/theme.service.ts @@ -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(createDefaultThemeDocument()); + private readonly activeThemeTextInternal = signal(DEFAULT_THEME_JSON); + private readonly draftThemeInternal = signal(createDefaultThemeDocument()); + private readonly draftTextInternal = signal(DEFAULT_THEME_JSON); + private readonly draftIsValidInternal = signal(true); + private readonly draftErrorsInternal = signal([]); + private readonly statusMessageInternal = signal(null); + + private initialized = false; + private statusTimeoutId: ReturnType | 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 { + const elementTheme = this.activeThemeInternal().elements[key] ?? {}; + const styles: Record = {}; + + 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 { + 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 { + 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 { + const styles: Record = {}; + + 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); + } +} \ No newline at end of file diff --git a/toju-app/src/app/domains/theme/domain/theme-llm-guide.ts b/toju-app/src/app/domains/theme/domain/theme-llm-guide.ts new file mode 100644 index 0000000..6bef479 --- /dev/null +++ b/toju-app/src/app/domains/theme/domain/theme-llm-guide.ts @@ -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-.', + '- tokens.radii.radius maps to --radius. Other radius keys become --theme-radius-.', + '- tokens.effects entries become --theme-effect-.', + `- 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, spacing: Record, radii: Record, effects: Record }', + '- layout: Record', + '- elements: Record', + '- animations: Record', + '', + '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'); \ No newline at end of file diff --git a/toju-app/src/app/domains/theme/domain/theme.defaults.ts b/toju-app/src/app/domains/theme/domain/theme.defaults.ts new file mode 100644 index 0000000..a0dc64f --- /dev/null +++ b/toju-app/src/app/domains/theme/domain/theme.defaults.ts @@ -0,0 +1,241 @@ +import { + ThemeDocument, + ThemeElementStyles, + ThemeLayoutEntry +} from './theme.models'; +import { + THEME_LAYOUT_CONTAINERS, + THEME_REGISTRY, + getLayoutEditableThemeKeys +} from './theme.registry'; + +function createDefaultElements(): Record { + return Object.fromEntries( + THEME_REGISTRY.map((entry) => [entry.key, {}]) + ) as Record; +} + +function createDefaultLayout(): Record { + const layoutEntries: Record = {}; + + 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 { + 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): boolean { + const keys = Object.keys(radii); + + return keys.length === 1 && radii['radius'] === '0.375rem'; +} + +function allElementsEmpty(elements: Record): 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); \ No newline at end of file diff --git a/toju-app/src/app/domains/theme/domain/theme.models.ts b/toju-app/src/app/domains/theme/domain/theme.models.ts new file mode 100644 index 0000000..b65d1c5 --- /dev/null +++ b/toju-app/src/app/domains/theme/domain/theme.models.ts @@ -0,0 +1,133 @@ +export type ThemeContainerKey = 'appShell' | 'roomLayout'; + +export interface ThemeMeta { + name: string; + version: string; + description?: string; +} + +export interface ThemeTokenGroups { + colors: Record; + spacing: Record; + radii: Record; + effects: Record; +} + +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>; +} + +export interface ThemeDocument { + meta: ThemeMeta; + tokens: ThemeTokenGroups; + layout: Record; + elements: Record; + animations: Record; +} + +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 { + key: T; + description: string; + type: 'string' | 'number' | 'object'; + example: string | number; + examples: readonly (string | number)[]; +} \ No newline at end of file diff --git a/toju-app/src/app/domains/theme/domain/theme.registry.ts b/toju-app/src/app/domains/theme/domain/theme.registry.ts new file mode 100644 index 0000000..9a51b62 --- /dev/null +++ b/toju-app/src/app/domains/theme/domain/theme.registry.ts @@ -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); +} \ No newline at end of file diff --git a/toju-app/src/app/domains/theme/domain/theme.schema.ts b/toju-app/src/app/domains/theme/domain/theme.schema.ts new file mode 100644 index 0000000..6f1b44e --- /dev/null +++ b/toju-app/src/app/domains/theme/domain/theme.schema.ts @@ -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[] = [ + { + 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 | 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)' + } + } + }; +} \ No newline at end of file diff --git a/toju-app/src/app/domains/theme/domain/theme.validation.ts b/toju-app/src/app/domains/theme/domain/theme.validation.ts new file mode 100644 index 0000000..95b5480 --- /dev/null +++ b/toju-app/src/app/domains/theme/domain/theme.validation.ts @@ -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 { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function validateUnknownKeys( + value: Record, + 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( + 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 { + 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 { + 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) + }; +} \ No newline at end of file diff --git a/toju-app/src/app/domains/theme/feature/settings/theme-grid-editor.component.html b/toju-app/src/app/domains/theme/feature/settings/theme-grid-editor.component.html new file mode 100644 index 0000000..6109531 --- /dev/null +++ b/toju-app/src/app/domains/theme/feature/settings/theme-grid-editor.component.html @@ -0,0 +1,66 @@ +
+
+
+

{{ container().label }}

+

{{ container().description }}

+
+ +
+ {{ container().columns }} cols x {{ container().rows }} rows +
+
+ +
+
+ + @for (item of items(); track item.key) { +
+
+
+
+

{{ item.label }}

+

{{ item.description }}

+
+ +
+ {{ item.grid.x }},{{ item.grid.y }} · {{ item.grid.w }}x{{ item.grid.h }} +
+
+
+ + +
+ } + + @if (disabled()) { +
+ Fix JSON validation errors to re-enable the grid editor. +
+ } +
+
diff --git a/toju-app/src/app/domains/theme/feature/settings/theme-grid-editor.component.scss b/toju-app/src/app/domains/theme/feature/settings/theme-grid-editor.component.scss new file mode 100644 index 0000000..85dd6f6 --- /dev/null +++ b/toju-app/src/app/domains/theme/feature/settings/theme-grid-editor.component.scss @@ -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)); +} \ No newline at end of file diff --git a/toju-app/src/app/domains/theme/feature/settings/theme-grid-editor.component.ts b/toju-app/src/app/domains/theme/feature/settings/theme-grid-editor.component.ts new file mode 100644 index 0000000..8091a4b --- /dev/null +++ b/toju-app/src/app/domains/theme/feature/settings/theme-grid-editor.component.ts @@ -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(); + readonly items = input.required(); + readonly selectedKey = input(null); + readonly disabled = input(false); + + readonly itemChanged = output<{ key: string; grid: ThemeGridRect }>(); + readonly itemSelected = output(); + + private readonly host = inject>(ElementRef); + private dragState: DragState | null = null; + + readonly canvasRef = viewChild.required>('canvasRef'); + readonly frameStyle = computed(() => ({ + '--theme-grid-columns': `${this.container().columns}`, + '--theme-grid-rows': `${this.container().rows}` + })); + + itemStyle(item: ThemeGridEditorItem): Record { + 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); + } +} \ No newline at end of file diff --git a/toju-app/src/app/domains/theme/feature/settings/theme-json-code-editor.component.ts b/toju-app/src/app/domains/theme/feature/settings/theme-json-code-editor.component.ts new file mode 100644 index 0000000..370ed48 --- /dev/null +++ b/toju-app/src/app/domains/theme/feature/settings/theme-json-code-editor.component.ts @@ -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: ` +
+
+
+ `, + 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>('editorHostRef'); + readonly value = input.required(); + readonly fullscreen = input(false); + readonly valueChange = output(); + + 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 + }); + }); + } +} \ No newline at end of file diff --git a/toju-app/src/app/domains/theme/feature/settings/theme-settings.component.html b/toju-app/src/app/domains/theme/feature/settings/theme-settings.component.html new file mode 100644 index 0000000..a8fb835 --- /dev/null +++ b/toju-app/src/app/domains/theme/feature/settings/theme-settings.component.html @@ -0,0 +1,498 @@ +
+
+
+
+

Theme Studio

+

{{ draftTheme().meta.name }}

+
+ +
+ + + + + +
+
+ + @if (llmGuideCopyMessage()) { +
+ {{ llmGuideCopyMessage() }} +
+ } + +
+
+
+ + +
+
+
+ Regions + {{ mountedEntryCount() }} +
+
+ Draft + + {{ isDraftDirty() ? 'Unsaved changes' : 'In sync' }} + +
+
+ + @if (statusMessage()) { +
+ {{ statusMessage() }} +
+ } + + @if (!draftIsValid()) { +
+

The draft is invalid. The last working theme is still active.

+
    + @for (error of draftErrors(); track error) { +
  • {{ error }}
  • + } +
+
+ } +
+ +
+ + +
+
+
+
+ @if (selectedElement()) { +
+ {{ selectedElement()!.label }} + {{ selectedElement()!.key }} + @if (selectedElement()!.container) { + {{ + selectedElement()!.container + }} + } +
+

{{ selectedElement()!.description }}

+ } +
+ + @if (selectedElement()) { +
+ + @if (selectedElement()!.layoutEditable) { + + } +
+ } +
+ + @if (selectedElementCapabilities().length > 0) { +
+ @for (capability of selectedElementCapabilities(); track capability) { + {{ capability }} + } +
+ } +
+ + @if (activeWorkspace() === 'editor') { +
+
+

Theme JSON

+ +
+ {{ draftLineCount() }} lines + {{ draftCharacterCount() }} chars + {{ draftErrorCount() }} errors + IDE editor +
+
+ +
+ +
+
+ } + + @if (activeWorkspace() === 'inspector') { +
+
+
+

Selection

+ + +
+ + @if (selectedElement()) { +
+
+

{{ selectedElement()!.label }}

+ {{ selectedElement()!.key }} + @if (isMounted(selectedElement()!)) { + Mounted now + } +
+

{{ selectedElement()!.description }}

+
+ } +
+ +
+
+

Schema Hints

+ + +
+ +
+ @for (field of visiblePropertyHints(); track field.key) { + + } +
+
+ +
+

Animation Keys

+ + @if (animationKeys().length > 0) { +
+ @for (animationKey of animationKeys(); track animationKey) { + + } +
+ } @else { +
+ No custom animation keys yet. +
+ } + +
+
+ @for (field of THEME_ANIMATION_FIELDS; track field.key) { + {{ field.key }} + } +
+
+
+
+ } + + @if (activeWorkspace() === 'layout') { +
+
+

Layout Grid

+ +
+ @for (container of layoutContainers; track container.key) { + + } + + +
+
+ +
+ +
+ + @if (selectedElementGrid()) { +
+
+

x

+

{{ selectedElementGrid()!.grid.x }}

+
+
+

y

+

{{ selectedElementGrid()!.grid.y }}

+
+
+

w

+

{{ selectedElementGrid()!.grid.w }}

+
+
+

h

+

{{ selectedElementGrid()!.grid.h }}

+
+
+ } +
+ } +
+
+
diff --git a/toju-app/src/app/domains/theme/feature/settings/theme-settings.component.scss b/toju-app/src/app/domains/theme/feature/settings/theme-settings.component.scss new file mode 100644 index 0000000..083eaaf --- /dev/null +++ b/toju-app/src/app/domains/theme/feature/settings/theme-settings.component.scss @@ -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; +} \ No newline at end of file diff --git a/toju-app/src/app/domains/theme/feature/settings/theme-settings.component.ts b/toju-app/src/app/domains/theme/feature/settings/theme-settings.component.ts new file mode 100644 index 0000000..e74b6dc --- /dev/null +++ b/toju-app/src/app/domains/theme/feature/settings/theme-settings.component.ts @@ -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('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('editor'); + readonly explorerQuery = signal(''); + + readonly selectedContainer = signal('roomLayout'); + readonly selectedElementKey = signal('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(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 | 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 { + 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 { + await this.themeLibrary.refresh(); + } + + async saveDraftAsNewTheme(): Promise { + await this.themeLibrary.saveDraftAsNewTheme(); + } + + async saveDraftToSelectedTheme(): Promise { + await this.themeLibrary.saveDraftToSelectedTheme(); + } + + async useSelectedSavedTheme(): Promise { + await this.themeLibrary.useSelectedTheme(); + } + + async editSelectedSavedTheme(): Promise { + const opened = await this.themeLibrary.openSelectedThemeInDraft(); + + if (!opened) { + return; + } + + this.setWorkspace('editor'); + this.focusEditor(); + } + + async removeSelectedSavedTheme(): Promise { + 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 { + 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); + } +} \ No newline at end of file diff --git a/toju-app/src/app/domains/theme/feature/theme-node.directive.ts b/toju-app/src/app/domains/theme/feature/theme-node.directive.ts new file mode 100644 index 0000000..b649de5 --- /dev/null +++ b/toju-app/src/app/domains/theme/feature/theme-node.directive.ts @@ -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({ alias: 'appThemeNode' }); + + private readonly host = inject>(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(); + private appliedClasses = new Set(); + private originalTextContent = new WeakMap(); + 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('[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('[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('[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('[data-theme-slot="icon"]'); + + if (!iconTarget) { + return; + } + + iconTarget.dataset['themeVisible'] = 'false'; + iconTarget.style.backgroundImage = 'none'; + iconTarget.textContent = ''; + } +} \ No newline at end of file diff --git a/toju-app/src/app/domains/theme/feature/theme-picker-overlay.component.ts b/toju-app/src/app/domains/theme/feature/theme-picker-overlay.component.ts new file mode 100644 index 0000000..aafd905 --- /dev/null +++ b/toju-app/src/app/domains/theme/feature/theme-picker-overlay.component.ts @@ -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()) { +
+
+
+
+

Theme Picker Active

+

+ Click a highlighted area to inspect its theme key. +

+

+ Hovering: + {{ hoveredEntry()?.label || 'Move over a themeable region' }} + @if (hoveredEntry()) { + {{ hoveredEntry()!.key }} + } +

+
+ + +
+
+
+ } + ` +}) +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(); + } +} \ No newline at end of file diff --git a/toju-app/src/app/domains/theme/index.ts b/toju-app/src/app/domains/theme/index.ts new file mode 100644 index 0000000..014614a --- /dev/null +++ b/toju-app/src/app/domains/theme/index.ts @@ -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'; \ No newline at end of file diff --git a/toju-app/src/app/domains/theme/infrastructure/theme-library.storage.ts b/toju-app/src/app/domains/theme/infrastructure/theme-library.storage.ts new file mode 100644 index 0000000..639ea39 --- /dev/null +++ b/toju-app/src/app/domains/theme/infrastructure/theme-library.storage.ts @@ -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(operation: Promise, label: string): Promise { + let timeoutId: ReturnType | null = null; + + try { + return await Promise.race([ + operation, + new Promise((_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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + }; + } + } +} \ No newline at end of file diff --git a/toju-app/src/app/domains/theme/infrastructure/theme.storage.ts b/toju-app/src/app/domains/theme/infrastructure/theme.storage.ts new file mode 100644 index 0000000..e36032d --- /dev/null +++ b/toju-app/src/app/domains/theme/infrastructure/theme.storage.ts @@ -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); +} \ No newline at end of file diff --git a/toju-app/src/app/domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component.html b/toju-app/src/app/domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component.html index 232c8cc..083bd40 100644 --- a/toju-app/src/app/domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component.html +++ b/toju-app/src/app/domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component.html @@ -1,5 +1,8 @@ @if (showFloatingControls()) { -
+
+
+
+
+ + - - + + - - + + - - + + - - - } + + +
+
diff --git a/toju-app/src/app/features/room/chat-room/chat-room.component.html b/toju-app/src/app/features/room/chat-room/chat-room.component.html index 7cfc546..f89f591 100644 --- a/toju-app/src/app/features/room/chat-room/chat-room.component.html +++ b/toju-app/src/app/features/room/chat-room/chat-room.component.html @@ -1,29 +1,50 @@
@if (currentRoom()) { - -
-