feat: Theme studio v2

This commit is contained in:
2026-04-27 03:02:13 +02:00
parent 11c2588e45
commit 1b91eacb5b
52 changed files with 2792 additions and 844 deletions

26
package-lock.json generated
View File

@@ -15,8 +15,10 @@
"@angular/platform-browser": "^21.0.0", "@angular/platform-browser": "^21.0.0",
"@angular/router": "^21.0.0", "@angular/router": "^21.0.0",
"@codemirror/commands": "^6.10.3", "@codemirror/commands": "^6.10.3",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-json": "^6.0.2", "@codemirror/lang-json": "^6.0.2",
"@codemirror/language": "^6.12.3", "@codemirror/language": "^6.12.3",
"@codemirror/lint": "^6.9.5",
"@codemirror/state": "^6.6.0", "@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.41.0", "@codemirror/view": "^6.41.0",
@@ -2731,6 +2733,19 @@
"@lezer/common": "^1.1.0" "@lezer/common": "^1.1.0"
} }
}, },
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-json": { "node_modules/@codemirror/lang-json": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
@@ -5791,6 +5806,17 @@
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==", "integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@lezer/css": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.3.tgz",
"integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": { "node_modules/@lezer/highlight": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",

View File

@@ -65,8 +65,10 @@
"@angular/platform-browser": "^21.0.0", "@angular/platform-browser": "^21.0.0",
"@angular/router": "^21.0.0", "@angular/router": "^21.0.0",
"@codemirror/commands": "^6.10.3", "@codemirror/commands": "^6.10.3",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-json": "^6.0.2", "@codemirror/lang-json": "^6.0.2",
"@codemirror/language": "^6.12.3", "@codemirror/language": "^6.12.3",
"@codemirror/lint": "^6.9.5",
"@codemirror/state": "^6.6.0", "@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.41.0", "@codemirror/view": "^6.41.0",

View File

@@ -1,4 +1,7 @@
<div class="chat-layout relative h-full"> <div
appThemeNode="chatSurface"
class="chat-layout relative h-full"
>
<app-chat-message-list <app-chat-message-list
[allMessages]="allMessages()" [allMessages]="allMessages()"
[channelMessages]="channelMessages()" [channelMessages]="channelMessages()"
@@ -19,7 +22,10 @@
(embedRemoved)="handleEmbedRemoved($event)" (embedRemoved)="handleEmbedRemoved($event)"
/> />
<div class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10"> <div
appThemeNode="chatComposerBar"
class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10"
>
<app-chat-message-composer <app-chat-message-composer
[replyTo]="replyTo()" [replyTo]="replyTo()"
[showKlipyGifPicker]="showKlipyGifPicker()" [showKlipyGifPicker]="showKlipyGifPicker()"
@@ -47,6 +53,7 @@
<div class="pointer-events-none fixed inset-0 z-[90]"> <div class="pointer-events-none fixed inset-0 z-[90]">
<div <div
appThemeNode="chatGifPickerSurface"
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]" class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
[style.bottom.px]="composerBottomPadding() + 8" [style.bottom.px]="composerBottomPadding() + 8"
[style.right.px]="klipyGifPickerAnchorRight()" [style.right.px]="klipyGifPickerAnchorRight()"

View File

@@ -22,6 +22,7 @@ import {
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../../store/users/users.selectors'; import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../../store/users/users.selectors';
import { selectActiveChannelId, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors'; import { selectActiveChannelId, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
import { Message } from '../../../../shared-kernel'; import { Message } from '../../../../shared-kernel';
import { ThemeNodeDirective } from '../../../theme';
import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component'; import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component';
import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component'; import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component';
import { ChatMessageListComponent } from './components/message-list/chat-message-list.component'; import { ChatMessageListComponent } from './components/message-list/chat-message-list.component';
@@ -43,7 +44,8 @@ import {
ChatMessageComposerComponent, ChatMessageComposerComponent,
KlipyGifPickerComponent, KlipyGifPickerComponent,
ChatMessageListComponent, ChatMessageListComponent,
ChatMessageOverlaysComponent ChatMessageOverlaysComponent,
ThemeNodeDirective
], ],
templateUrl: './chat-messages.component.html', templateUrl: './chat-messages.component.html',
styleUrl: './chat-messages.component.scss' styleUrl: './chat-messages.component.scss'
@@ -70,16 +72,10 @@ export class ChatMessagesComponent {
const channelId = this.activeChannelId(); const channelId = this.activeChannelId();
const roomId = this.currentRoom()?.id; const roomId = this.currentRoom()?.id;
return this.allMessages().filter( return this.allMessages().filter((message) => message.roomId === roomId && (message.channelId || 'general') === channelId);
(message) =>
message.roomId === roomId &&
(message.channelId || 'general') === channelId
);
}); });
readonly conversationKey = computed( readonly conversationKey = computed(() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`);
() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`
);
readonly klipyEnabled = computed(() => this.klipy.isEnabled(this.currentRoom())); readonly klipyEnabled = computed(() => this.klipy.isEnabled(this.currentRoom()));
readonly composerBottomPadding = signal(140); readonly composerBottomPadding = signal(140);
readonly klipyGifPickerAnchorRight = signal(16); readonly klipyGifPickerAnchorRight = signal(16);
@@ -176,9 +172,7 @@ export class ChatMessagesComponent {
if (!message || !currentUserId) if (!message || !currentUserId)
return; return;
const hasReacted = message.reactions.some( const hasReacted = message.reactions.some((reaction) => reaction.emoji === event.emoji && reaction.userId === currentUserId);
(reaction) => reaction.emoji === event.emoji && reaction.userId === currentUserId
);
if (hasReacted) { if (hasReacted) {
this.store.dispatch( this.store.dispatch(
@@ -243,9 +237,7 @@ export class ChatMessagesComponent {
const minRight = 16; const minRight = 16;
const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16); const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16);
this.klipyGifPickerAnchorRight.set( this.klipyGifPickerAnchorRight.set(Math.min(Math.max(Math.round(preferredRight), minRight), maxRight));
Math.min(Math.max(Math.round(preferredRight), minRight), maxRight)
);
} }
private getKlipyGifPickerWidth(viewportWidth: number): number { private getKlipyGifPickerWidth(viewportWidth: number): number {
@@ -290,10 +282,7 @@ export class ChatMessagesComponent {
if (blob) { if (blob) {
try { try {
const result = await electronApi.saveFileAs( const result = await electronApi.saveFileAs(attachment.filename, await this.blobToBase64(blob));
attachment.filename,
await this.blobToBase64(blob)
);
if (result.saved || result.cancelled) if (result.saved || result.cancelled)
return; return;
@@ -416,12 +405,7 @@ export class ChatMessagesComponent {
const message = [...this.channelMessages()] const message = [...this.channelMessages()]
.reverse() .reverse()
.find( .find((entry) => entry.senderId === currentUserId && entry.content === content && !entry.isDeleted);
(entry) =>
entry.senderId === currentUserId &&
entry.content === content &&
!entry.isDeleted
);
if (!message) { if (!message) {
setTimeout(() => this.attachFilesToLastOwnMessage(content, pendingFiles), 150); setTimeout(() => this.attachFilesToLastOwnMessage(content, pendingFiles), 150);

View File

@@ -1,7 +1,13 @@
<!-- eslint-disable @angular-eslint/template/button-has-type --> <!-- eslint-disable @angular-eslint/template/button-has-type -->
<div #composerRoot> <div
#composerRoot
appThemeNode="chatComposerBar"
>
@if (replyTo()) { @if (replyTo()) {
<div class="pointer-events-auto flex items-center gap-2 bg-secondary/50 px-4 py-2"> <div
appThemeNode="chatComposerReplyBar"
class="pointer-events-auto flex items-center gap-2 bg-secondary/50 px-4 py-2"
>
<ng-icon <ng-icon
name="lucideReply" name="lucideReply"
class="h-4 w-4 text-muted-foreground" class="h-4 w-4 text-muted-foreground"
@@ -31,6 +37,7 @@
(mouseleave)="onToolbarMouseLeave()" (mouseleave)="onToolbarMouseLeave()"
> >
<div <div
appThemeNode="chatComposerToolbar"
class="mx-4 -mb-2 flex flex-wrap items-center justify-start gap-2 rounded-lg border border-border bg-card/70 px-2 py-1 shadow-sm backdrop-blur" class="mx-4 -mb-2 flex flex-wrap items-center justify-start gap-2 rounded-lg border border-border bg-card/70 px-2 py-1 shadow-sm backdrop-blur"
> >
<button <button
@@ -124,6 +131,7 @@
<div class="border-border p-4"> <div class="border-border p-4">
<div <div
appThemeNode="chatComposerInput"
class="chat-input-wrapper relative" class="chat-input-wrapper relative"
(mouseenter)="inputHovered.set(true)" (mouseenter)="inputHovered.set(true)"
(mouseleave)="inputHovered.set(false)" (mouseleave)="inputHovered.set(false)"
@@ -156,6 +164,7 @@
} }
<button <button
appThemeNode="chatComposerSendButton"
type="button" type="button"
(click)="sendMessage()" (click)="sendMessage()"
[disabled]="!messageContent.trim() && pendingFiles.length === 0 && !pendingKlipyGif()" [disabled]="!messageContent.trim() && pendingFiles.length === 0 && !pendingKlipyGif()"

View File

@@ -23,6 +23,7 @@ import type { ClipboardFilePayload } from '../../../../../../core/platform/elect
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
import { KlipyGif, KlipyService } from '../../../../application/services/klipy.service'; import { KlipyGif, KlipyService } from '../../../../application/services/klipy.service';
import { Message } from '../../../../../../shared-kernel'; import { Message } from '../../../../../../shared-kernel';
import { ThemeNodeDirective } from '../../../../../theme';
import type { RoomSignalSourceInput } from '../../../../../server-directory'; import type { RoomSignalSourceInput } from '../../../../../server-directory';
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive'; import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component'; import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component';
@@ -43,7 +44,8 @@ const DEFAULT_TEXTAREA_HEIGHT = 62;
FormsModule, FormsModule,
NgIcon, NgIcon,
ChatImageProxyFallbackDirective, ChatImageProxyFallbackDirective,
TypingIndicatorComponent TypingIndicatorComponent,
ThemeNodeDirective
], ],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
@@ -415,11 +417,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus()); requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus());
} }
private hasPotentialFilePayload( private hasPotentialFilePayload(dataTransfer: DataTransfer | null, treatMissingTypesAsPotentialFile = true): boolean {
dataTransfer: DataTransfer | null,
treatMissingTypesAsPotentialFile = true
): boolean {
if (!dataTransfer) if (!dataTransfer)
return false; return false;

View File

@@ -2,11 +2,13 @@
@let msg = message(); @let msg = message();
@let attachmentsList = attachmentViewModels(); @let attachmentsList = attachmentViewModels();
<div <div
appThemeNode="chatMessageBubble"
[attr.data-message-id]="msg.id" [attr.data-message-id]="msg.id"
class="group relative flex gap-3 rounded-lg p-2 transition-colors hover:bg-secondary/30" class="group relative flex gap-3 rounded-lg p-2 transition-colors hover:bg-secondary/30"
[class.opacity-50]="msg.isDeleted" [class.opacity-50]="msg.isDeleted"
> >
<div <div
appThemeNode="chatMessageAvatar"
class="flex-shrink-0 cursor-pointer" class="flex-shrink-0 cursor-pointer"
(click)="openSenderProfileCard($event); $event.stopPropagation()" (click)="openSenderProfileCard($event); $event.stopPropagation()"
> >
@@ -17,7 +19,10 @@
/> />
</div> </div>
<div class="min-w-0 flex-1"> <div
appThemeNode="chatMessageContent"
class="min-w-0 flex-1"
>
@if (msg.replyToId) { @if (msg.replyToId) {
@let reply = repliedMessage(); @let reply = repliedMessage();
<div <div
@@ -150,7 +155,10 @@
</div> </div>
</div> </div>
} @else if ((att.receivedBytes || 0) > 0) { } @else if ((att.receivedBytes || 0) > 0) {
<div class="max-w-xs rounded-md border border-border bg-secondary/40 p-3"> <div
appThemeNode="chatAttachmentCard"
class="max-w-xs rounded-md border border-border bg-secondary/40 p-3"
>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-primary/10"> <div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-primary/10">
<ng-icon <ng-icon
@@ -172,7 +180,10 @@
</div> </div>
</div> </div>
} @else { } @else {
<div class="max-w-xs rounded-md border border-dashed border-border bg-secondary/20 p-4"> <div
appThemeNode="chatAttachmentCard"
class="max-w-xs rounded-md border border-dashed border-border bg-secondary/20 p-4"
>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-muted"> <div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-muted">
<ng-icon <ng-icon
@@ -220,7 +231,10 @@
/> />
} }
} @else if ((att.receivedBytes || 0) > 0) { } @else if ((att.receivedBytes || 0) > 0) {
<div class="max-w-xl rounded-md border border-border bg-secondary/40 p-3"> <div
appThemeNode="chatAttachmentCard"
class="max-w-xl rounded-md border border-border bg-secondary/40 p-3"
>
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">{{ att.filename }}</div> <div class="truncate text-sm font-medium">{{ att.filename }}</div>
@@ -247,7 +261,10 @@
</div> </div>
</div> </div>
} @else { } @else {
<div class="max-w-xl rounded-md border border-dashed border-border bg-secondary/20 p-4"> <div
appThemeNode="chatAttachmentCard"
class="max-w-xl rounded-md border border-dashed border-border bg-secondary/20 p-4"
>
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-foreground">{{ att.filename }}</div> <div class="truncate text-sm font-medium text-foreground">{{ att.filename }}</div>
@@ -271,7 +288,10 @@
</div> </div>
} }
} @else { } @else {
<div class="rounded-md border border-border bg-secondary/40 p-2"> <div
appThemeNode="chatAttachmentCard"
class="rounded-md border border-border bg-secondary/40 p-2"
>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="min-w-0"> <div class="min-w-0">
<div class="truncate text-sm font-medium">{{ att.filename }}</div> <div class="truncate text-sm font-medium">{{ att.filename }}</div>
@@ -339,6 +359,7 @@
<div class="mt-2 flex flex-wrap gap-1"> <div class="mt-2 flex flex-wrap gap-1">
@for (reaction of getGroupedReactions(); track reaction.emoji) { @for (reaction of getGroupedReactions(); track reaction.emoji) {
<button <button
appThemeNode="chatReactionPill"
(click)="toggleReaction(reaction.emoji)" (click)="toggleReaction(reaction.emoji)"
class="flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-xs transition-colors hover:bg-secondary/80" class="flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-xs transition-colors hover:bg-secondary/80"
[class.ring-1]="reaction.hasCurrentUser" [class.ring-1]="reaction.hasCurrentUser"
@@ -354,6 +375,7 @@
@if (!msg.isDeleted) { @if (!msg.isDeleted) {
<div <div
appThemeNode="chatMessageActions"
class="absolute right-2 top-2 flex items-center gap-1 rounded-lg border border-border bg-card shadow-lg opacity-0 transition-opacity group-hover:opacity-100" class="absolute right-2 top-2 flex items-center gap-1 rounded-lg border border-border bg-card shadow-lg opacity-0 transition-opacity group-hover:opacity-100"
> >
<div class="relative"> <div class="relative">

View File

@@ -37,6 +37,7 @@ import {
Message, Message,
User User
} from '../../../../../../shared-kernel'; } from '../../../../../../shared-kernel';
import { ThemeNodeDirective } from '../../../../../theme';
import { import {
ChatAudioPlayerComponent, ChatAudioPlayerComponent,
@@ -96,7 +97,8 @@ interface ChatMessageAttachmentViewModel extends Attachment {
ChatVideoPlayerComponent, ChatVideoPlayerComponent,
ChatMessageMarkdownComponent, ChatMessageMarkdownComponent,
ChatLinkEmbedComponent, ChatLinkEmbedComponent,
UserAvatarComponent UserAvatarComponent,
ThemeNodeDirective
], ],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
@@ -150,7 +152,8 @@ export class ChatMessageItemComponent {
const msg = this.message(); const msg = this.message();
const found = this.userLookup().get(msg.senderId); const found = this.userLookup().get(msg.senderId);
return found ?? { return (
found ?? {
id: msg.senderId, id: msg.senderId,
oderId: msg.senderId, oderId: msg.senderId,
username: msg.senderName, username: msg.senderName,
@@ -158,7 +161,8 @@ export class ChatMessageItemComponent {
status: 'disconnected', status: 'disconnected',
role: 'member', role: 'member',
joinedAt: 0 joinedAt: 0
}; }
);
}); });
editContent = ''; editContent = '';
@@ -175,9 +179,7 @@ export class ChatMessageItemComponent {
readonly attachmentViewModels = computed<ChatMessageAttachmentViewModel[]>(() => { readonly attachmentViewModels = computed<ChatMessageAttachmentViewModel[]>(() => {
void this.attachmentVersion(); void this.attachmentVersion();
return this.attachmentsSvc.getForMessage(this.message().id).map((attachment) => return this.attachmentsSvc.getForMessage(this.message().id).map((attachment) => this.buildAttachmentViewModel(attachment));
this.buildAttachmentViewModel(attachment)
);
}); });
private readonly syncAttachmentVersion = effect(() => { private readonly syncAttachmentVersion = effect(() => {
const version = this.attachmentsSvc.updated(); const version = this.attachmentsSvc.updated();
@@ -320,8 +322,7 @@ export class ChatMessageItemComponent {
hour: '2-digit', hour: '2-digit',
minute: '2-digit' minute: '2-digit'
}); });
const toDay = (value: Date) => const toDay = (value: Date) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime();
new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime();
const dayDiff = Math.round((toDay(now) - toDay(date)) / (1000 * 60 * 60 * 24)); const dayDiff = Math.round((toDay(now) - toDay(date)) / (1000 * 60 * 60 * 24));
if (dayDiff === 0) if (dayDiff === 0)
@@ -331,11 +332,7 @@ export class ChatMessageItemComponent {
return 'Yesterday ' + time; return 'Yesterday ' + time;
if (dayDiff < 7) { if (dayDiff < 7) {
return ( return date.toLocaleDateString([], { weekday: 'short' }) + ' ' + time;
date.toLocaleDateString([], { weekday: 'short' }) +
' ' +
time
);
} }
return ( return (
@@ -402,10 +399,7 @@ export class ChatMessageItemComponent {
} }
requiresMediaDownloadAcceptance(attachment: Attachment): boolean { requiresMediaDownloadAcceptance(attachment: Attachment): boolean {
return ( return (this.isVideoAttachment(attachment) || this.isAudioAttachment(attachment)) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES;
(this.isVideoAttachment(attachment) || this.isAudioAttachment(attachment)) &&
attachment.size > MAX_AUTO_SAVE_SIZE_BYTES
);
} }
getMediaAttachmentStatusText(attachment: Attachment): string { getMediaAttachmentStatusText(attachment: Attachment): string {
@@ -418,9 +412,7 @@ export class ChatMessageItemComponent {
: 'Large audio file. Accept the download to play it in chat.'; : 'Large audio file. Accept the download to play it in chat.';
} }
return this.isVideoAttachment(attachment) return this.isVideoAttachment(attachment) ? 'Waiting for video source...' : 'Waiting for audio source...';
? 'Waiting for video source...'
: 'Waiting for audio source...';
} }
getMediaAttachmentActionLabel(attachment: Attachment): string { getMediaAttachmentActionLabel(attachment: Attachment): string {
@@ -484,8 +476,7 @@ export class ChatMessageItemComponent {
private buildAttachmentViewModel(attachment: Attachment): ChatMessageAttachmentViewModel { private buildAttachmentViewModel(attachment: Attachment): ChatMessageAttachmentViewModel {
const isVideo = this.isVideoAttachment(attachment); const isVideo = this.isVideoAttachment(attachment);
const isAudio = this.isAudioAttachment(attachment); const isAudio = this.isAudioAttachment(attachment);
const requiresMediaDownloadAcceptance = const requiresMediaDownloadAcceptance = (isVideo || isAudio) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES;
(isVideo || isAudio) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES;
return { return {
...attachment, ...attachment,
@@ -493,8 +484,12 @@ export class ChatMessageItemComponent {
isUploader: this.isUploader(attachment), isUploader: this.isUploader(attachment),
isVideo, isVideo,
mediaActionLabel: requiresMediaDownloadAcceptance mediaActionLabel: requiresMediaDownloadAcceptance
? attachment.requestError ? 'Retry download' : 'Accept download' ? attachment.requestError
: attachment.requestError ? 'Retry' : 'Request', ? 'Retry download'
: 'Accept download'
: attachment.requestError
? 'Retry'
: 'Request',
mediaStatusText: attachment.requestError mediaStatusText: attachment.requestError
? attachment.requestError ? attachment.requestError
: requiresMediaDownloadAcceptance : requiresMediaDownloadAcceptance
@@ -504,15 +499,11 @@ export class ChatMessageItemComponent {
: isVideo : isVideo
? 'Waiting for video source...' ? 'Waiting for video source...'
: 'Waiting for audio source...', : 'Waiting for audio source...',
progressPercent: attachment.size > 0 progressPercent: attachment.size > 0 ? ((attachment.receivedBytes || 0) * 100) / attachment.size : 0
? ((attachment.receivedBytes || 0) * 100) / attachment.size
: 0
}; };
} }
private getLiveAttachment(attachmentId: string): Attachment | undefined { private getLiveAttachment(attachmentId: string): Attachment | undefined {
return this.attachmentsSvc return this.attachmentsSvc.getForMessage(this.message().id).find((attachment) => attachment.id === attachmentId);
.getForMessage(this.message().id)
.find((attachment) => attachment.id === attachmentId);
} }
} }

View File

@@ -1,5 +1,6 @@
<div <div
#messagesContainer #messagesContainer
appThemeNode="chatMessageList"
class="absolute inset-0 space-y-4 overflow-y-auto p-4" class="absolute inset-0 space-y-4 overflow-y-auto p-4"
[style.padding-bottom.px]="bottomPadding()" [style.padding-bottom.px]="bottomPadding()"
(scroll)="onScroll()" (scroll)="onScroll()"
@@ -39,7 +40,10 @@
@for (message of messages(); track message.id; let index = $index) { @for (message of messages(); track message.id; let index = $index) {
@if (dateSeparatorLabels().get(index); as separatorLabel) { @if (dateSeparatorLabels().get(index); as separatorLabel) {
<div class="flex items-center gap-3 py-1"> <div
appThemeNode="chatDateSeparator"
class="flex items-center gap-3 py-1"
>
<div class="h-px flex-1 bg-border"></div> <div class="h-px flex-1 bg-border"></div>
<span class="rounded-full border border-border bg-background/90 px-3 py-1 text-xs font-medium text-muted-foreground shadow-sm"> <span class="rounded-full border border-border bg-background/90 px-3 py-1 text-xs font-medium text-muted-foreground shadow-sm">
{{ separatorLabel }} {{ separatorLabel }}
@@ -70,7 +74,10 @@
@if (showNewMessagesBar()) { @if (showNewMessagesBar()) {
<div class="pointer-events-none sticky bottom-4 flex justify-center"> <div class="pointer-events-none sticky bottom-4 flex justify-center">
<div class="pointer-events-auto flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-2 shadow"> <div
appThemeNode="chatNewMessagesBar"
class="pointer-events-auto flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-2 shadow"
>
<span class="text-sm text-muted-foreground">New messages</span> <span class="text-sm text-muted-foreground">New messages</span>
<button <button
type="button" type="button"

View File

@@ -26,6 +26,7 @@ import {
ChatMessageReplyEvent ChatMessageReplyEvent
} from '../../models/chat-messages.model'; } from '../../models/chat-messages.model';
import { selectAllUsers } from '../../../../../../store/users/users.selectors'; import { selectAllUsers } from '../../../../../../store/users/users.selectors';
import { ThemeNodeDirective } from '../../../../../theme';
import { ChatMessageItemComponent } from '../message-item/chat-message-item.component'; import { ChatMessageItemComponent } from '../message-item/chat-message-item.component';
interface PrismGlobal { interface PrismGlobal {
@@ -41,7 +42,11 @@ declare global {
@Component({ @Component({
selector: 'app-chat-message-list', selector: 'app-chat-message-list',
standalone: true, standalone: true,
imports: [CommonModule, ChatMessageItemComponent], imports: [
CommonModule,
ChatMessageItemComponent,
ThemeNodeDirective
],
templateUrl: './chat-message-list.component.html', templateUrl: './chat-message-list.component.html',
host: { host: {
style: 'display: contents;' style: 'display: contents;'
@@ -94,9 +99,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
return all.slice(all.length - limit); return all.slice(all.length - limit);
}); });
readonly hasMoreMessages = computed( readonly hasMoreMessages = computed(() => this.channelMessages().length > this.displayLimit());
() => this.channelMessages().length > this.displayLimit()
);
readonly dateSeparatorLabels = computed(() => { readonly dateSeparatorLabels = computed(() => {
const labels = new Map<number, string>(); const labels = new Map<number, string>();
@@ -165,8 +168,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
return; return;
} }
const distanceFromBottom = const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
element.scrollHeight - element.scrollTop - element.clientHeight;
const newMessages = currentCount > this.lastMessageCount; const newMessages = currentCount > this.lastMessageCount;
if (newMessages) { if (newMessages) {
@@ -228,8 +230,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
if (!element || this.isAutoScrolling) if (!element || this.isAutoScrolling)
return; return;
const distanceFromBottom = const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
element.scrollHeight - element.scrollTop - element.clientHeight;
const shouldStickToBottom = distanceFromBottom <= 300; const shouldStickToBottom = distanceFromBottom <= 300;
if (shouldStickToBottom) { if (shouldStickToBottom) {
@@ -386,11 +387,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
} }
if (this.boundOnImageLoad && this.messagesContainer) { if (this.boundOnImageLoad && this.messagesContainer) {
this.messagesContainer.nativeElement.removeEventListener( this.messagesContainer.nativeElement.removeEventListener('load', this.boundOnImageLoad, true);
'load',
this.boundOnImageLoad,
true
);
this.boundOnImageLoad = null; this.boundOnImageLoad = null;
} }

View File

@@ -1,5 +1,11 @@
<section class="chat-layout relative h-full bg-background"> <section
<header class="flex h-14 shrink-0 items-center gap-3 border-b border-border px-4"> appThemeNode="dmChatSurface"
class="chat-layout relative h-full bg-background"
>
<header
appThemeNode="dmChatHeader"
class="flex h-14 shrink-0 items-center gap-3 border-b border-border px-4"
>
<app-user-avatar <app-user-avatar
[name]="peerName()" [name]="peerName()"
[avatarUrl]="peerUser()?.avatarUrl" [avatarUrl]="peerUser()?.avatarUrl"
@@ -14,7 +20,10 @@
</header> </header>
@if (conversation()) { @if (conversation()) {
<div class="absolute inset-x-0 bottom-0 top-14"> <div
appThemeNode="dmMessageRegion"
class="absolute inset-x-0 bottom-0 top-14"
>
<app-chat-message-list <app-chat-message-list
[allMessages]="chatMessages()" [allMessages]="chatMessages()"
[channelMessages]="chatMessages()" [channelMessages]="chatMessages()"
@@ -45,7 +54,10 @@
} }
</div> </div>
<div class="chat-bottom-bar absolute bottom-0 left-0 right-2 z-10 bg-background/85 backdrop-blur-md"> <div
appThemeNode="chatComposerBar"
class="chat-bottom-bar absolute bottom-0 left-0 right-2 z-10 bg-background/85 backdrop-blur-md"
>
<app-chat-message-composer <app-chat-message-composer
[replyTo]="replyTo()" [replyTo]="replyTo()"
[showKlipyGifPicker]="showGifPicker()" [showKlipyGifPicker]="showGifPicker()"
@@ -72,6 +84,7 @@
<div class="pointer-events-none fixed inset-0 z-[90]"> <div class="pointer-events-none fixed inset-0 z-[90]">
<div <div
appThemeNode="chatGifPickerSurface"
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]" class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
[style.bottom.px]="composerBottomPadding() + 8" [style.bottom.px]="composerBottomPadding() + 8"
[style.right.px]="gifPickerAnchorRight()" [style.right.px]="gifPickerAnchorRight()"

View File

@@ -16,6 +16,7 @@ import { map } from 'rxjs';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { UserAvatarComponent } from '../../../../shared'; import { UserAvatarComponent } from '../../../../shared';
import { Attachment, AttachmentFacade } from '../../../attachment'; import { Attachment, AttachmentFacade } from '../../../attachment';
import { ThemeNodeDirective } from '../../../theme';
import { DirectMessageService } from '../../application/services/direct-message.service'; import { DirectMessageService } from '../../application/services/direct-message.service';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors'; import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import { import {
@@ -56,6 +57,7 @@ interface DmStatusLabel {
ChatMessageListComponent, ChatMessageListComponent,
ChatMessageOverlaysComponent, ChatMessageOverlaysComponent,
KlipyGifPickerComponent, KlipyGifPickerComponent,
ThemeNodeDirective,
UserAvatarComponent UserAvatarComponent
], ],
templateUrl: './dm-chat.component.html', templateUrl: './dm-chat.component.html',
@@ -106,7 +108,8 @@ export class DmChatComponent {
const knownUser = knownUsers.find((user) => user.id === participantId || user.oderId === participantId); const knownUser = knownUsers.find((user) => user.id === participantId || user.oderId === participantId);
const participant = conversation.participantProfiles[participantId]; const participant = conversation.participantProfiles[participantId];
return knownUser ?? { return (
knownUser ?? {
id: participantId, id: participantId,
oderId: participantId, oderId: participantId,
username: participant?.username || participant?.displayName || participantId, username: participant?.username || participant?.displayName || participantId,
@@ -120,7 +123,8 @@ export class DmChatComponent {
status: 'disconnected', status: 'disconnected',
role: 'member', role: 'member',
joinedAt: 0 joinedAt: 0
}; }
);
}); });
}); });
readonly messageStatuses = computed<DmStatusLabel[]>(() => { readonly messageStatuses = computed<DmStatusLabel[]>(() => {
@@ -218,8 +222,7 @@ export class DmChatComponent {
const content = event.content.trim() || event.pendingFiles.map((file) => file.name).join('\n'); const content = event.content.trim() || event.pendingFiles.map((file) => file.name).join('\n');
void this.directMessages.sendMessage(conversation.id, content, this.replyTo()?.id) void this.directMessages.sendMessage(conversation.id, content, this.replyTo()?.id).then((message) => {
.then((message) => {
this.replyTo.set(null); this.replyTo.set(null);
if (event.pendingFiles.length > 0) { if (event.pendingFiles.length > 0) {
@@ -388,8 +391,7 @@ export class DmChatComponent {
continue; continue;
} }
const urls = this.linkMetadata.extractUrls(message.content) const urls = this.linkMetadata.extractUrls(message.content).filter((url) => !hasDedicatedChatEmbed(url));
.filter((url) => !hasDedicatedChatEmbed(url));
if (urls.length === 0) { if (urls.length === 0) {
continue; continue;

View File

@@ -8,7 +8,10 @@
[ngStyle]="listPanelStyles()" [ngStyle]="listPanelStyles()"
> >
<section class="flex h-full w-full min-w-0 flex-col"> <section class="flex h-full w-full min-w-0 flex-col">
<header class="flex h-14 shrink-0 items-center gap-2 border-b border-border px-3"> <header
appThemeNode="dmConversationsHeader"
class="flex h-14 shrink-0 items-center gap-2 border-b border-border px-3"
>
<div class="grid h-8 w-8 place-items-center rounded-lg bg-secondary text-muted-foreground"> <div class="grid h-8 w-8 place-items-center rounded-lg bg-secondary text-muted-foreground">
<ng-icon <ng-icon
name="lucideMessageCircle" name="lucideMessageCircle"
@@ -21,13 +24,17 @@
</div> </div>
</header> </header>
<div class="min-h-0 flex-1 overflow-y-auto p-2"> <div
appThemeNode="dmConversationList"
class="min-h-0 flex-1 overflow-y-auto p-2"
>
@if (directMessages.conversations().length === 0) { @if (directMessages.conversations().length === 0) {
<div class="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground">No direct messages yet.</div> <div class="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground">No direct messages yet.</div>
} @else { } @else {
<div class="space-y-1"> <div class="space-y-1">
@for (conversation of directMessages.conversations(); track conversation.id) { @for (conversation of directMessages.conversations(); track conversation.id) {
<div <div
appThemeNode="dmConversationItem"
class="group flex w-full items-center gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-secondary/60" class="group flex w-full items-center gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-secondary/60"
[class.bg-primary/10]="isSelectedConversation(conversation)" [class.bg-primary/10]="isSelectedConversation(conversation)"
[class.text-foreground]="isSelectedConversation(conversation)" [class.text-foreground]="isSelectedConversation(conversation)"
@@ -72,7 +79,10 @@
} }
</div> </div>
<div class="border-t border-border px-2 py-3"> <div
appThemeNode="dmVoiceControlsArea"
class="border-t border-border px-2 py-3"
>
<app-voice-controls /> <app-voice-controls />
</div> </div>
</section> </section>

View File

@@ -50,7 +50,7 @@ export class DmWorkspaceComponent implements OnDestroy {
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), { readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
initialValue: this.route.snapshot.paramMap.get('conversationId') initialValue: this.route.snapshot.paramMap.get('conversationId')
}); });
readonly layoutStyles = computed(() => this.theme.getLayoutContainerStyles('roomLayout')); readonly layoutStyles = computed(() => this.theme.getLayoutContainerStyles('dmLayout'));
readonly listPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmConversationsPanel')); readonly listPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmConversationsPanel'));
readonly chatPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmChatPanel')); readonly chatPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmChatPanel'));

View File

@@ -59,6 +59,11 @@ export class ElementPickerService {
this.hoveredKey.set(null); this.hoveredKey.set(null);
this.isPicking.set(false); this.isPicking.set(false);
if (this.resumePage === 'theme') {
this.modal.openThemeStudio();
return;
}
if (this.resumePage) { if (this.resumePage) {
this.modal.open(this.resumePage); this.modal.open(this.resumePage);
} }
@@ -124,8 +129,6 @@ export class ElementPickerService {
const key = themedElement?.dataset['themeKey'] ?? null; const key = themedElement?.dataset['themeKey'] ?? null;
const definition = this.registry.getDefinition(key); const definition = this.registry.getDefinition(key);
return definition?.pickerVisible return definition?.pickerVisible ? key : null;
? key
: null;
} }
} }

View File

@@ -9,7 +9,7 @@ import {
ThemeGridEditorItem, ThemeGridEditorItem,
ThemeGridRect ThemeGridRect
} from '../../domain/models/theme.model'; } from '../../domain/models/theme.model';
import { createDefaultThemeDocument } from '../../domain/logic/theme-defaults.logic'; import { createDefaultThemeDocument, createDefaultThemeLayout } from '../../domain/logic/theme-defaults.logic';
import { ThemeRegistryService } from './theme-registry.service'; import { ThemeRegistryService } from './theme-registry.service';
import { ThemeService } from './theme.service'; import { ThemeService } from './theme.service';
@@ -26,37 +26,46 @@ export class LayoutSyncService {
itemsForContainer(containerKey: ThemeContainerKey): ThemeGridEditorItem[] { itemsForContainer(containerKey: ThemeContainerKey): ThemeGridEditorItem[] {
const draftTheme = this.theme.draftTheme(); const draftTheme = this.theme.draftTheme();
const defaults = createDefaultThemeDocument(); const defaults = createDefaultThemeLayout();
return this.registry.entries() return this.registry
.entries()
.filter((entry) => entry.layoutEditable && entry.container === containerKey) .filter((entry) => entry.layoutEditable && entry.container === containerKey)
.map((entry) => ({ .map((entry) => ({
key: entry.key, key: entry.key,
label: entry.label, label: entry.label,
description: entry.description, description: entry.description,
grid: draftTheme.layout[entry.key]?.grid ?? defaults.layout[entry.key].grid grid: draftTheme.layout[entry.key]?.grid ?? defaults[entry.key].grid
})); }));
} }
updateGrid(key: string, grid: ThemeGridRect): void { updateGrid(key: string, grid: ThemeGridRect): void {
this.theme.ensureLayoutEntry(key); this.theme.ensureLayoutEntry(key);
this.theme.updateStructuredDraft((draft: ReturnType<typeof createDefaultThemeDocument>) => { this.theme.updateStructuredDraft(
(draft: ReturnType<typeof createDefaultThemeDocument>) => {
draft.layout[key] = { draft.layout[key] = {
...draft.layout[key], ...draft.layout[key],
grid grid
}; };
}, true, `${key} layout updated.`); },
true,
`${key} layout updated.`
);
} }
resetContainer(containerKey: ThemeContainerKey): void { resetContainer(containerKey: ThemeContainerKey): void {
const defaults = createDefaultThemeDocument(); const defaults = createDefaultThemeLayout();
this.theme.updateStructuredDraft((draft: ReturnType<typeof createDefaultThemeDocument>) => { this.theme.updateStructuredDraft(
(draft: ReturnType<typeof createDefaultThemeDocument>) => {
for (const entry of this.registry.entries()) { for (const entry of this.registry.entries()) {
if (entry.container === containerKey && entry.layoutEditable) { if (entry.container === containerKey && entry.layoutEditable) {
draft.layout[entry.key] = defaults.layout[entry.key]; draft.layout[entry.key] = defaults[entry.key];
} }
} }
}, true, `${containerKey} restored to its default layout.`); },
true,
`${containerKey} restored to its default layout.`
);
} }
} }

View File

@@ -0,0 +1,489 @@
import { DOCUMENT } from '@angular/common';
import { EnvironmentInjector, createEnvironmentInjector } from '@angular/core';
import { DEFAULT_THEME_JSON, createDefaultThemeDocument } from '../../domain/logic/theme-defaults.logic';
import { validateThemeDocument } from '../../domain/logic/theme-validation.logic';
import { ThemeService } from './theme.service';
describe('ThemeService theme application', () => {
let injector: EnvironmentInjector;
let styleElements: TestStyleElement[];
let service: ThemeService;
beforeEach(() => {
installLocalStorageMock();
styleElements = [];
injector = createEnvironmentInjector([
ThemeService,
{
provide: DOCUMENT,
useValue: createDocumentStub(styleElements)
}
]);
service = injector.get(ThemeService);
service.initialize();
});
afterEach(() => {
injector.destroy();
vi.unstubAllGlobals();
});
it('uses the compact Toju dark theme as the built-in default JSON', () => {
expect(JSON.parse(DEFAULT_THEME_JSON) as unknown).toEqual({
meta: {
name: 'Toju Default Dark',
version: '2.0.0',
description: 'Built-in dark glass theme for the full Toju app shell.'
},
tokens: {
colors: {
background: '224 28% 7%',
foreground: '210 40% 96%',
card: '224 25% 10%',
cardForeground: '210 40% 96%',
popover: '224 26% 9%',
popoverForeground: '210 40% 96%',
primary: '193 95% 68%',
primaryForeground: '222 47% 11%',
secondary: '223 19% 16%',
secondaryForeground: '210 40% 96%',
muted: '223 18% 14%',
mutedForeground: '215 20% 70%',
accent: '218 22% 18%',
accentForeground: '210 40% 98%',
destructive: '0 72% 55%',
destructiveForeground: '0 0% 100%',
border: '222 18% 22%',
input: '222 18% 22%',
ring: '193 95% 68%',
railBackground: '226 33% 8%',
workspaceBackground: '224 30% 9%',
panelBackground: '224 24% 11%',
panelBackgroundAlt: '222 22% 13%',
titleBarBackground: '226 34% 7%',
surfaceHighlight: '193 95% 68%',
surfaceHighlightAlt: '261 82% 72%'
},
spacing: {},
radii: {
radius: '0.875rem',
surface: '1.35rem',
pill: '999px'
},
effects: {
panelShadow: '0 24px 60px rgba(0, 0, 0, 0.42)',
softShadow: '0 14px 36px rgba(0, 0, 0, 0.28)',
glassBlur: 'blur(18px) saturate(135%)'
}
},
layout: {
serversRail: {
container: 'appShell',
grid: { x: 0, y: 0, w: 1, h: 1 }
},
appWorkspace: {
container: 'appShell',
grid: { x: 1, y: 0, w: 19, h: 1 }
},
chatRoomChannelsPanel: {
container: 'roomLayout',
grid: { x: 0, y: 0, w: 4, h: 12 }
},
chatRoomMainPanel: {
container: 'roomLayout',
grid: { x: 4, y: 0, w: 12, h: 12 }
},
chatRoomMembersPanel: {
container: 'roomLayout',
grid: { x: 16, y: 0, w: 4, h: 12 }
}
}
});
});
it('applies a JSON theme with tokens, layout, backgrounds, effects, metadata, and animations', () => {
const theme = createCompleteThemeDocument();
const loaded = service.loadThemeText(JSON.stringify(theme), 'apply', 'Theme applied.', 'complete JSON theme');
expect(loaded).toBe(true);
expect(service.activeThemeName()).toBe('Complete Theme Fixture');
expect(service.getTextOverride('titleBar')).toBe('MetoYou Lab');
expect(service.getIcon('titleBar')).toBe('MT');
expect(service.getLink('titleBar')).toBe('https://example.com/theme');
expect(service.getAnimationClass('titleBar')).toBe('themePulse');
expect(service.getHostStyles('appRoot')).toMatchObject({
'--background': '210 22% 8%',
'--theme-spacing-panel-gap': '14px',
'--radius': '1rem',
'--theme-radius-surface': '1.25rem',
'--theme-effect-glass-blur': 'blur(20px) saturate(140%)'
});
expect(service.getHostStyles('titleBar')).toMatchObject({
width: 'min(100%, 64rem)',
height: '4rem',
minWidth: '18rem',
minHeight: '3rem',
maxWidth: '72rem',
maxHeight: '5rem',
position: 'sticky',
top: '0',
right: '0',
bottom: 'auto',
left: '0',
padding: '0.75rem 1rem',
margin: '0 auto',
border: '1px solid hsl(var(--border) / 0.75)',
borderRadius: '1rem',
backgroundColor: 'rgba(7, 11, 20, 0.88)',
color: '#f8fafc',
backgroundImage: 'linear-gradient(90deg, rgba(14, 165, 233, 0.28), transparent), url("/themes/city.png")',
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
boxShadow: '0 18px 50px rgba(0, 0, 0, 0.35)',
backdropFilter: 'var(--theme-effect-glass-blur)',
opacity: '0.82'
});
expect(service.getLayoutContainerStyles('dmLayout')).toMatchObject({
display: 'grid',
gridTemplateColumns: 'repeat(4, 4.25rem) repeat(16, minmax(0, 1fr))'
});
expect(service.getLayoutItemStyles('dmChatPanel')).toMatchObject({
gridColumn: '7 / span 14',
gridRow: '2 / span 10'
});
const animationStylesheet = styleElements.find((styleElement) => styleElement.textContent?.includes('@keyframes themePulse'));
expect(animationStylesheet?.textContent).toContain('@keyframes themePulse');
expect(animationStylesheet?.textContent).toContain('animation-direction: alternate-reverse;');
expect(animationStylesheet?.textContent).toContain('transform: scale(1);');
});
it('applies raw CSS from the theme JSON', () => {
const loaded = service.loadThemeText(
JSON.stringify({
meta: {
name: 'CSS Theme',
version: '1.0.0'
},
css: '.theme-css-probe { color: hsl(var(--primary)); }'
}),
'apply',
'Theme applied.',
'CSS JSON theme'
);
expect(loaded).toBe(true);
expect(styleElements.some((styleElement) => styleElement.textContent === '.theme-css-probe { color: hsl(var(--primary)); }')).toBe(true);
expect(service.activeThemeText()).toContain('"css"');
});
it('applies a CSS-only theme over the default JSON theme', () => {
const applied = service.applyCssOnlyTheme('.css-only-theme { background: hsl(var(--background)); }');
expect(applied).toBe(true);
expect(service.activeThemeName()).toBe('Toju Default Dark');
expect(service.getLayoutItemStyles('dmChatPanel')).toMatchObject({
gridColumn: '5 / span 16',
gridRow: '1 / span 12'
});
expect(service.getHostStyles('appRoot')).toMatchObject({
'--background': '224 28% 7%'
});
expect(styleElements.some((styleElement) => styleElement.textContent === '.css-only-theme { background: hsl(var(--background)); }')).toBe(true);
});
it('applies CSS over the current JSON draft theme', () => {
const draftTheme = createDefaultThemeDocument();
draftTheme.meta = {
name: 'Draft Base Theme',
version: '1.0.0'
};
draftTheme.tokens.colors['background'] = '200 50% 10%';
draftTheme.layout['dmChatPanel'] = {
container: 'dmLayout',
grid: { x: 6, y: 0, w: 14, h: 12 }
};
const loadedDraft = service.loadThemeText(JSON.stringify(draftTheme), 'draft', 'Draft loaded.', 'draft JSON theme');
const applied = service.applyCssOnlyTheme('.draft-base-theme { color: hsl(var(--foreground)); }');
expect(loadedDraft).toBe(true);
expect(applied).toBe(true);
expect(service.activeThemeName()).toBe('Draft Base Theme');
expect(service.getHostStyles('appRoot')).toMatchObject({
'--background': '200 50% 10%'
});
expect(service.getLayoutItemStyles('dmChatPanel')).toMatchObject({
gridColumn: '7 / span 14',
gridRow: '1 / span 12'
});
expect(styleElements.some((styleElement) => styleElement.textContent === '.draft-base-theme { color: hsl(var(--foreground)); }')).toBe(true);
});
it('builds exportable JSON with CSS over the current draft without applying it', () => {
const draftTheme = createDefaultThemeDocument();
draftTheme.meta = {
name: 'Export Base Theme',
version: '1.0.0'
};
draftTheme.tokens.colors['background'] = '180 40% 12%';
const loadedDraft = service.loadThemeText(JSON.stringify(draftTheme), 'draft', 'Draft loaded.', 'draft JSON theme');
const exportText = service.buildDraftTextWithCss('.export-base-theme { color: hsl(var(--foreground)); }');
expect(loadedDraft).toBe(true);
expect(exportText).not.toBeNull();
expect(JSON.parse(exportText ?? '{}')).toMatchObject({
meta: {
name: 'Export Base Theme'
},
css: '.export-base-theme { color: hsl(var(--foreground)); }',
tokens: {
colors: {
background: '180 40% 12%'
}
}
});
expect(service.activeThemeName()).toBe('Toju Default Dark');
});
it('validates the dedicated DM workspace layout container', () => {
const theme = createDefaultThemeDocument();
theme.layout['dmConversationsPanel'] = {
container: 'dmLayout',
grid: { x: 0, y: 0, w: 5, h: 12 }
};
theme.layout['dmChatPanel'] = {
container: 'dmLayout',
grid: { x: 5, y: 0, w: 15, h: 12 }
};
const result = validateThemeDocument(theme);
expect(result.valid).toBe(true);
expect(result.value?.layout['dmChatPanel'].container).toBe('dmLayout');
});
it('allows compact JSON themes with omitted element sections', () => {
const loaded = service.loadThemeText(
JSON.stringify({
meta: {
name: 'Compact Theme',
version: '1.0.0'
},
elements: {
titleBar: {
backgroundImage: '/themes/city.png',
backgroundSize: 'cover'
}
}
}),
'apply',
'Theme applied.',
'compact JSON theme'
);
expect(loaded).toBe(true);
expect(service.getHostStyles('titleBar')).toMatchObject({
backgroundImage: 'url("/themes/city.png")',
backgroundSize: 'cover'
});
expect(service.getHostStyles('voiceWorkspace')).toEqual({});
expect(service.activeThemeText()).not.toContain('voiceWorkspace');
});
it('omits empty element stubs when formatting themes', () => {
const loaded = service.loadThemeText(
JSON.stringify({
meta: {
name: 'Theme With Empty Elements',
version: '1.0.0'
},
elements: {
titleBar: {},
voiceWorkspace: {}
}
}),
'apply',
'Theme applied.',
'theme with empty elements'
);
expect(loaded).toBe(true);
expect(service.activeThemeText()).not.toContain('"elements"');
});
it('keeps only non-empty element overrides when formatting themes', () => {
const loaded = service.loadThemeText(
JSON.stringify({
meta: {
name: 'Theme With Mixed Elements',
version: '1.0.0'
},
elements: {
titleBar: {},
chatRoomMainPanel: {
backgroundImage: '/themes/city.png'
}
}
}),
'apply',
'Theme applied.',
'theme with mixed elements'
);
const activeTheme = JSON.parse(service.activeThemeText()) as { elements?: Record<string, unknown> };
expect(loaded).toBe(true);
expect(activeTheme.elements).toEqual({
chatRoomMainPanel: {
backgroundImage: '/themes/city.png'
}
});
});
it('allows removing the entire elements section', () => {
const loaded = service.loadThemeText(
JSON.stringify({
meta: {
name: 'No Elements Theme',
version: '1.0.0'
}
}),
'apply',
'Theme applied.',
'compact JSON theme'
);
expect(loaded).toBe(true);
expect(service.getHostStyles('titleBar')).toEqual({});
expect(service.activeThemeText()).not.toContain('"elements"');
});
});
interface TestStyleElement {
textContent: string | null;
setAttribute(name: string, value: string): void;
}
function createCompleteThemeDocument() {
const theme = createDefaultThemeDocument();
theme.meta = {
name: 'Complete Theme Fixture',
version: '9.9.9',
description: 'Exercises every supported applied theme surface.'
};
theme.tokens.colors['background'] = '210 22% 8%';
theme.tokens.spacing['panelGap'] = '14px';
theme.tokens.radii['radius'] = '1rem';
theme.tokens.radii['surface'] = '1.25rem';
theme.tokens.effects['glassBlur'] = 'blur(20px) saturate(140%)';
theme.layout['dmChatPanel'] = {
container: 'dmLayout',
grid: { x: 6, y: 1, w: 14, h: 10 }
};
theme.elements['titleBar'] = {
width: 'min(100%, 64rem)',
height: '4rem',
minWidth: '18rem',
minHeight: '3rem',
maxWidth: '72rem',
maxHeight: '5rem',
position: 'sticky',
top: '0',
right: '0',
bottom: 'auto',
left: '0',
opacity: 0.82,
padding: '0.75rem 1rem',
margin: '0 auto',
border: '1px solid hsl(var(--border) / 0.75)',
borderRadius: '1rem',
backgroundColor: 'rgba(7, 11, 20, 0.88)',
color: '#f8fafc',
backgroundImage: '/themes/city.png',
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
gradient: 'linear-gradient(90deg, rgba(14, 165, 233, 0.28), transparent)',
boxShadow: '0 18px 50px rgba(0, 0, 0, 0.35)',
backdropFilter: 'var(--theme-effect-glass-blur)',
icon: 'MT',
textOverride: 'MetoYou Lab',
link: 'https://example.com/theme',
animationClass: 'themePulse'
};
theme.animations['themePulse'] = {
duration: '600ms',
easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
delay: '50ms',
iterationCount: 'infinite',
fillMode: 'both',
direction: 'alternate-reverse',
keyframes: {
from: {
opacity: 0,
transform: 'scale(0.98)'
},
to: {
opacity: 1,
transform: 'scale(1)'
}
}
};
return theme;
}
function createDocumentStub(styleElements: TestStyleElement[]): Document {
return {
createElement: () => {
const styleElement: TestStyleElement = {
textContent: null,
setAttribute: () => undefined
};
return styleElement;
},
head: {
appendChild: (styleElement: TestStyleElement) => {
styleElements.push(styleElement);
return styleElement;
}
}
} as unknown as Document;
}
function installLocalStorageMock(): void {
const values = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
clear: () => values.clear()
});
}

View File

@@ -17,6 +17,7 @@ import {
import { import {
DEFAULT_THEME_JSON, DEFAULT_THEME_JSON,
createDefaultThemeDocument, createDefaultThemeDocument,
createDefaultThemeLayout,
isLegacyDefaultThemeDocument isLegacyDefaultThemeDocument
} from '../../domain/logic/theme-defaults.logic'; } from '../../domain/logic/theme-defaults.logic';
import { createAnimationStarterDefinition } from '../../domain/logic/theme-schema.logic'; import { createAnimationStarterDefinition } from '../../domain/logic/theme-schema.logic';
@@ -35,14 +36,68 @@ function toKebabCase(value: string): string {
.toLowerCase(); .toLowerCase();
} }
function isEmptyRecord(value: Record<string, unknown>): boolean {
return Object.keys(value).length === 0;
}
function hasTokenOverrides(document: ThemeDocument): boolean {
return (
!isEmptyRecord(document.tokens.colors) ||
!isEmptyRecord(document.tokens.spacing) ||
!isEmptyRecord(document.tokens.radii) ||
!isEmptyRecord(document.tokens.effects)
);
}
function compactThemeElements(elements: ThemeDocument['elements']): ThemeDocument['elements'] {
return Object.fromEntries(Object.entries(elements).filter(([_key, styles]) => !isEmptyRecord(styles as Record<string, unknown>)));
}
function stringifyTheme(document: ThemeDocument): string { function stringifyTheme(document: ThemeDocument): string {
return JSON.stringify(document, null, 2); const jsonDocument: Partial<ThemeDocument> = {
meta: document.meta
};
const compactElements = compactThemeElements(document.elements);
if (document.css.trim().length > 0) {
jsonDocument.css = document.css;
}
if (hasTokenOverrides(document)) {
jsonDocument.tokens = document.tokens;
}
if (!isEmptyRecord(document.layout)) {
jsonDocument.layout = document.layout;
}
if (!isEmptyRecord(compactElements)) {
jsonDocument.elements = compactElements;
}
if (!isEmptyRecord(document.animations)) {
jsonDocument.animations = document.animations;
}
return JSON.stringify(jsonDocument, null, 2);
}
function looksLikeImageReference(value: string): boolean {
return value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/') || value.startsWith('./') || value.startsWith('../');
}
function normalizeBackgroundImageLayer(value: string): string {
const trimmedValue = value.trim();
if (!looksLikeImageReference(trimmedValue)) {
return trimmedValue;
}
return `url("${trimmedValue.replace(/"/g, '\\"')}")`;
} }
function resolveBuiltInDefaultMigration(document: ThemeDocument): ThemeDocument { function resolveBuiltInDefaultMigration(document: ThemeDocument): ThemeDocument {
return isLegacyDefaultThemeDocument(document) return isLegacyDefaultThemeDocument(document) ? createDefaultThemeDocument() : document;
? createDefaultThemeDocument()
: document;
} }
const hostStylePropertyKeys = [ const hostStylePropertyKeys = [
@@ -96,6 +151,7 @@ export class ThemeService {
private initialized = false; private initialized = false;
private statusTimeoutId: ReturnType<typeof setTimeout> | null = null; private statusTimeoutId: ReturnType<typeof setTimeout> | null = null;
private animationStyleElement: HTMLStyleElement | null = null; private animationStyleElement: HTMLStyleElement | null = null;
private cssStyleElement: HTMLStyleElement | null = null;
constructor() { constructor() {
this.activeTheme = this.activeThemeInternal.asReadonly(); this.activeTheme = this.activeThemeInternal.asReadonly();
@@ -159,6 +215,7 @@ export class ThemeService {
} }
this.syncAnimationStylesheet(); this.syncAnimationStylesheet();
this.syncCssStylesheet();
} }
updateDraftText(text: string): void { updateDraftText(text: string): void {
@@ -203,12 +260,26 @@ export class ThemeService {
return true; return true;
} }
loadThemeText( buildDraftTextWithCss(css: string): string | null {
text: string, const theme = this.composeDraftThemeWithCss(css);
mode: 'draft' | 'apply',
successMessage: string, return theme ? stringifyTheme(theme) : null;
sourceLabel = 'theme' }
): boolean {
applyCssOnlyTheme(css: string): boolean {
const theme = this.composeDraftThemeWithCss(css);
if (!theme) {
return false;
}
const formatted = stringifyTheme(theme);
this.commitTheme(theme, formatted, 'CSS applied over the JSON theme.');
return true;
}
loadThemeText(text: string, mode: 'draft' | 'apply', successMessage: string, sourceLabel = 'theme'): boolean {
const result = this.parseAndValidateTheme(text, sourceLabel); const result = this.parseAndValidateTheme(text, sourceLabel);
if (!result.valid || !result.value) { if (!result.valid || !result.value) {
@@ -250,9 +321,8 @@ export class ThemeService {
saveActiveThemeText(defaultText); saveActiveThemeText(defaultText);
saveDraftThemeText(defaultText); saveDraftThemeText(defaultText);
this.syncAnimationStylesheet(); this.syncAnimationStylesheet();
this.setStatusMessage(reason === 'shortcut' this.syncCssStylesheet();
? 'Theme reset to the default preset by shortcut.' this.setStatusMessage(reason === 'shortcut' ? 'Theme reset to the default preset by shortcut.' : 'Theme reset to the default preset.');
: 'Theme reset to the default preset.');
} }
handleGlobalShortcut(event: KeyboardEvent): boolean { handleGlobalShortcut(event: KeyboardEvent): boolean {
@@ -268,41 +338,71 @@ export class ThemeService {
} }
ensureElementEntry(key: string): void { ensureElementEntry(key: string): void {
this.updateStructuredDraft((draft) => { if (!this.draftIsValidInternal()) {
draft.elements[key] = draft.elements[key] ?? {}; this.setStatusMessage('Fix JSON errors before using the structured theme tools.');
}, false, `Prepared ${key} in the theme draft.`); return;
}
const draftJson = JSON.parse(this.draftTextInternal()) as Record<string, unknown>;
const existingElements = draftJson['elements'];
const elements =
existingElements && typeof existingElements === 'object' && !Array.isArray(existingElements)
? { ...(existingElements as Record<string, unknown>) }
: {};
elements[key] = elements[key] ?? {};
draftJson['elements'] = elements;
const result = validateThemeDocument(draftJson);
if (!result.valid || !result.value) {
this.setStatusMessage('The structured change could not be validated.');
return;
}
const formatted = JSON.stringify(draftJson, null, 2);
this.draftThemeInternal.set(result.value);
this.draftTextInternal.set(formatted);
this.draftIsValidInternal.set(true);
this.draftErrorsInternal.set([]);
saveDraftThemeText(formatted);
this.setStatusMessage(`Prepared ${key} in the theme draft.`);
} }
ensureLayoutEntry(key: string): void { ensureLayoutEntry(key: string): void {
this.updateStructuredDraft((draft) => { this.updateStructuredDraft(
const defaults = createDefaultThemeDocument(); (draft) => {
const defaults = createDefaultThemeLayout();
draft.layout[key] = draft.layout[key] ?? defaults.layout[key]; draft.layout[key] = draft.layout[key] ?? defaults[key];
}, false, `Prepared ${key} layout in the theme draft.`); },
false,
`Prepared ${key} layout in the theme draft.`
);
} }
setElementStyle( setElementStyle(key: string, property: ThemeElementStyleProperty, value: string | number, applyImmediately = true): void {
key: string, this.updateStructuredDraft(
property: ThemeElementStyleProperty, (draft) => {
value: string | number,
applyImmediately = true
): void {
this.updateStructuredDraft((draft) => {
draft.elements[key] = { draft.elements[key] = {
...draft.elements[key], ...draft.elements[key],
[property]: value [property]: value
}; };
}, applyImmediately, `${key} updated.`); },
applyImmediately,
`${key} updated.`
);
} }
setAnimation( setAnimation(key: string, definition: ThemeAnimationDefinition = createAnimationStarterDefinition(), applyImmediately = true): void {
key: string, this.updateStructuredDraft(
definition: ThemeAnimationDefinition = createAnimationStarterDefinition(), (draft) => {
applyImmediately = true
): void {
this.updateStructuredDraft((draft) => {
draft.animations[key] = definition; draft.animations[key] = definition;
}, applyImmediately, `Animation ${key} updated.`); },
applyImmediately,
`Animation ${key} updated.`
);
} }
getHostStyles(key: string): Record<string, string> { getHostStyles(key: string): Record<string, string> {
@@ -314,7 +414,8 @@ export class ThemeService {
} }
const backgroundLayers = [elementTheme.gradient, elementTheme.backgroundImage] const backgroundLayers = [elementTheme.gradient, elementTheme.backgroundImage]
.filter((layer): layer is string => typeof layer === 'string' && layer.trim().length > 0); .filter((layer): layer is string => typeof layer === 'string' && layer.trim().length > 0)
.map((layer) => normalizeBackgroundImageLayer(layer));
if (backgroundLayers.length > 0) { if (backgroundLayers.length > 0) {
styles['backgroundImage'] = backgroundLayers.join(', '); styles['backgroundImage'] = backgroundLayers.join(', ');
@@ -338,9 +439,7 @@ export class ThemeService {
getAnimationClass(key: string): string | null { getAnimationClass(key: string): string | null {
const animationClass = this.activeThemeInternal().elements[key]?.animationClass?.trim(); const animationClass = this.activeThemeInternal().elements[key]?.animationClass?.trim();
return animationClass && animationClass.length > 0 return animationClass && animationClass.length > 0 ? animationClass : null;
? animationClass
: null;
} }
getLink(key: string): string | null { getLink(key: string): string | null {
@@ -367,17 +466,15 @@ export class ThemeService {
return { return {
display: 'grid', display: 'grid',
gridTemplateColumns: container.templateColumns ?? `repeat(${container.columns}, minmax(0, 1fr))`, gridTemplateColumns: container.templateColumns ?? `repeat(${container.columns}, minmax(0, 1fr))`,
gridTemplateRows: container.templateRows ?? (container.rows === 1 gridTemplateRows: container.templateRows ?? (container.rows === 1 ? 'minmax(0, 1fr)' : `repeat(${container.rows}, minmax(0, 1fr))`),
? 'minmax(0, 1fr)'
: `repeat(${container.rows}, minmax(0, 1fr))`),
minHeight: '0', minHeight: '0',
minWidth: '0' minWidth: '0'
}; };
} }
getLayoutItemStyles(key: string): Record<string, string> { getLayoutItemStyles(key: string): Record<string, string> {
const defaults = createDefaultThemeDocument(); const defaults = createDefaultThemeLayout();
const layoutEntry = this.activeThemeInternal().layout[key] ?? defaults.layout[key]; const layoutEntry = this.activeThemeInternal().layout[key] ?? defaults[key];
if (!layoutEntry) { if (!layoutEntry) {
return {}; return {};
@@ -391,11 +488,7 @@ export class ThemeService {
}; };
} }
updateStructuredDraft( updateStructuredDraft(mutator: (draft: ThemeDocument) => void, applyImmediately: boolean, successMessage: string): void {
mutator: (draft: ThemeDocument) => void,
applyImmediately: boolean,
successMessage: string
): void {
if (!this.draftIsValidInternal()) { if (!this.draftIsValidInternal()) {
this.setStatusMessage('Fix JSON errors before using the structured theme tools.'); this.setStatusMessage('Fix JSON errors before using the structured theme tools.');
return; return;
@@ -438,9 +531,30 @@ export class ThemeService {
saveActiveThemeText(text); saveActiveThemeText(text);
saveDraftThemeText(text); saveDraftThemeText(text);
this.syncAnimationStylesheet(); this.syncAnimationStylesheet();
this.syncCssStylesheet();
this.setStatusMessage(successMessage); this.setStatusMessage(successMessage);
} }
private composeDraftThemeWithCss(css: string): ThemeDocument | null {
if (!this.draftIsValidInternal()) {
this.setStatusMessage('Fix JSON errors before applying CSS over the theme draft.');
return null;
}
const theme = {
...structuredClone(this.draftThemeInternal()),
css
};
const result = validateThemeDocument(theme);
if (!result.valid || !result.value) {
this.setStatusMessage(result.errors[0] ?? 'The CSS-only theme could not be applied.');
return null;
}
return result.value;
}
private parseAndValidateTheme(text: string, label: string) { private parseAndValidateTheme(text: string, label: string) {
try { try {
return validateThemeDocument(JSON.parse(text) as unknown); return validateThemeDocument(JSON.parse(text) as unknown);
@@ -465,9 +579,7 @@ export class ThemeService {
} }
for (const [tokenName, tokenValue] of Object.entries(theme.tokens.radii)) { for (const [tokenName, tokenValue] of Object.entries(theme.tokens.radii)) {
const cssVariableName = tokenName === 'radius' const cssVariableName = tokenName === 'radius' ? '--radius' : `--theme-radius-${toKebabCase(tokenName)}`;
? '--radius'
: `--theme-radius-${toKebabCase(tokenName)}`;
styles[cssVariableName] = tokenValue; styles[cssVariableName] = tokenValue;
} }
@@ -495,6 +607,18 @@ export class ThemeService {
this.animationStyleElement.textContent = css; this.animationStyleElement.textContent = css;
} }
private syncCssStylesheet(): void {
const css = this.activeThemeInternal().css.trim();
if (!this.cssStyleElement) {
this.cssStyleElement = this.documentRef.createElement('style');
this.cssStyleElement.setAttribute('data-toju-theme-css', 'true');
this.documentRef.head.appendChild(this.cssStyleElement);
}
this.cssStyleElement.textContent = css;
}
private buildAnimationRule(className: string, definition: ThemeAnimationDefinition): string { private buildAnimationRule(className: string, definition: ThemeAnimationDefinition): string {
const animationClass = `.${className}`; const animationClass = `.${className}`;
const declarationLines = [ const declarationLines = [

View File

@@ -7,15 +7,11 @@ import {
} from '../logic/theme-schema.logic'; } from '../logic/theme-schema.logic';
function formatExample(example: string | number): string { function formatExample(example: string | number): string {
return typeof example === 'number' return typeof example === 'number' ? `${example}` : JSON.stringify(example);
? `${example}`
: JSON.stringify(example);
} }
function getLayoutKeysForContainer(containerKey: string): string[] { function getLayoutKeysForContainer(containerKey: string): string[] {
return THEME_REGISTRY return THEME_REGISTRY.filter((entry) => entry.container === containerKey && entry.layoutEditable).map((entry) => entry.key);
.filter((entry) => entry.container === containerKey && entry.layoutEditable)
.map((entry) => entry.key);
} }
function describeCapabilities(entry: (typeof THEME_REGISTRY)[number]): string { function describeCapabilities(entry: (typeof THEME_REGISTRY)[number]): string {
@@ -26,9 +22,7 @@ function describeCapabilities(entry: (typeof THEME_REGISTRY)[number]): string {
entry.supportsIcon ? 'icon' : null entry.supportsIcon ? 'icon' : null
].filter((value): value is string => value !== null); ].filter((value): value is string => value !== null);
return capabilities.length > 0 return capabilities.length > 0 ? capabilities.join(', ') : 'visual style overrides only';
? capabilities.join(', ')
: 'visual style overrides only';
} }
function describeLayoutContainer(container: (typeof THEME_LAYOUT_CONTAINERS)[number]): string { function describeLayoutContainer(container: (typeof THEME_LAYOUT_CONTAINERS)[number]): string {
@@ -56,15 +50,16 @@ function describeThemeEntry(entry: (typeof THEME_REGISTRY)[number]): string {
const colorTokenKeys = Object.keys(DEFAULT_THEME_DOCUMENT.tokens.colors); const colorTokenKeys = Object.keys(DEFAULT_THEME_DOCUMENT.tokens.colors);
const radiusTokenKeys = Object.keys(DEFAULT_THEME_DOCUMENT.tokens.radii); const radiusTokenKeys = Object.keys(DEFAULT_THEME_DOCUMENT.tokens.radii);
const effectTokenKeys = Object.keys(DEFAULT_THEME_DOCUMENT.tokens.effects); const effectTokenKeys = Object.keys(DEFAULT_THEME_DOCUMENT.tokens.effects);
const layoutEditableKeys = THEME_REGISTRY const layoutEditableKeys = THEME_REGISTRY.filter((entry) => entry.layoutEditable).map((entry) => entry.key);
.filter((entry) => entry.layoutEditable) const layoutContainerKeys = THEME_LAYOUT_CONTAINERS.map((container) => container.key);
.map((entry) => entry.key); const layoutContainerUnion = layoutContainerKeys.map((key) => `"${key}"`).join(' | ');
const guideTemplateDocument = { const guideTemplateDocument = {
meta: { meta: {
name: 'Theme Name', name: 'Theme Name',
version: '1.0.0', version: '1.0.0',
description: 'Short mood and material direction.' description: 'Short mood and material direction.'
}, },
css: '',
tokens: { tokens: {
colors: { colors: {
background: '224 28% 7%', background: '224 28% 7%',
@@ -102,6 +97,9 @@ const guideTemplateDocument = {
}, },
chatRoomMainPanel: { chatRoomMainPanel: {
backgroundColor: 'hsl(var(--panel-background) / 0.82)', backgroundColor: 'hsl(var(--panel-background) / 0.82)',
backgroundImage: '/assets/themes/paper-noise.png',
backgroundSize: 'cover',
backgroundPosition: 'center',
borderRadius: 'var(--theme-radius-surface)', borderRadius: 'var(--theme-radius-surface)',
boxShadow: 'var(--theme-effect-panel-shadow)' boxShadow: 'var(--theme-effect-panel-shadow)'
} }
@@ -119,12 +117,13 @@ export const THEME_LLM_GUIDE = [
'- Return JSON only when asked to generate a theme. Do not wrap the result in Markdown fences or add commentary.', '- Return JSON only when asked to generate a theme. Do not wrap the result in Markdown fences or add commentary.',
'', '',
'Core rules', 'Core rules',
'- Keep the top-level keys exactly: meta, tokens, layout, elements, animations.', '- Supported top-level keys are meta, css, tokens, layout, elements, and animations. Omit sections that do not need overrides.',
'- Use strict JSON with double-quoted keys, no comments, and no trailing commas.', '- Use strict JSON with double-quoted keys, no comments, and no trailing commas.',
'- Omitted optional keys inherit from the built-in default theme, so leave out anything you are not intentionally changing.', '- Omitted layout keys use the app default placement. Omitted element keys apply no Theme Studio visual override.',
'- Do not invent new top-level sections, layout containers, or element style properties.', '- Do not invent new top-level sections, layout containers, or element style properties.',
'- links must be absolute http or https URLs.', '- 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.', '- animationClass must be a safe CSS class token and should match an entry in animations or an existing class already shipped by the app.',
'- css is optional raw CSS applied after the JSON theme is active. Keep selectors scoped to app/theme surfaces when possible.',
'- layout.grid values must be integers. x and y are zero-based. w and h must be greater than 0.', '- 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.', '- opacity must be a number between 0 and 1.',
'', '',
@@ -139,6 +138,8 @@ export const THEME_LLM_GUIDE = [
'- tokens.colors entries become CSS variables like --background or --surface-highlight-alt.', '- 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)).', '- 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.', '- You may add extra color tokens if you also reference them with CSS variables in element overrides.',
'- elements.<key>.backgroundImage accepts a CSS background-image value, a local image path, or an http/https image URL.',
'- Use backgroundSize and backgroundPosition with backgroundImage when the image needs cover/center behavior.',
'- tokens.spacing entries become --theme-spacing-<kebab-case-key>.', '- tokens.spacing entries become --theme-spacing-<kebab-case-key>.',
'- tokens.radii.radius maps to --radius. Other radius keys become --theme-radius-<kebab-case-key>.', '- tokens.radii.radius maps to --radius. Other radius keys become --theme-radius-<kebab-case-key>.',
'- tokens.effects entries become --theme-effect-<kebab-case-key>.', '- tokens.effects entries become --theme-effect-<kebab-case-key>.',
@@ -148,8 +149,9 @@ export const THEME_LLM_GUIDE = [
'', '',
'Top-level schema reference', 'Top-level schema reference',
'- meta: { name: string, version: string, description?: string }', '- meta: { name: string, version: string, description?: string }',
'- css?: string',
'- tokens: { colors: Record<string, string>, spacing: Record<string, string>, radii: Record<string, string>, effects: Record<string, string> }', '- tokens: { colors: Record<string, string>, spacing: Record<string, string>, radii: Record<string, string>, effects: Record<string, string> }',
'- layout: Record<string, { container: "appShell" | "roomLayout", grid: { x: number, y: number, w: number, h: number } }>', `- layout: Record<string, { container: ${layoutContainerUnion}, grid: { x: number, y: number, w: number, h: number } }>`,
'- elements: Record<string, ThemeElementStyles>', '- elements: Record<string, ThemeElementStyles>',
'- animations: Record<string, { duration?, easing?, delay?, iterationCount?, fillMode?, direction?, keyframes? }>', '- animations: Record<string, { duration?, easing?, delay?, iterationCount?, fillMode?, direction?, keyframes? }>',
'', '',

View File

@@ -3,50 +3,41 @@ import {
ThemeElementStyles, ThemeElementStyles,
ThemeLayoutEntry ThemeLayoutEntry
} from '../models/theme.model'; } from '../models/theme.model';
import { import { THEME_LAYOUT_CONTAINERS, getLayoutEditableThemeKeys } from './theme-registry.logic';
THEME_LAYOUT_CONTAINERS,
THEME_REGISTRY,
getLayoutEditableThemeKeys
} from './theme-registry.logic';
const APP_ROOT_BASE_GRADIENT = function createProvidedDefaultLayout(): Record<string, ThemeLayoutEntry> {
'radial-gradient(circle at top, ' return {
+ 'hsl(var(--surface-highlight) / 0.18), transparent 34%)'; serversRail: {
const APP_ROOT_OVERLAY_GRADIENT = 'linear-gradient(180deg, rgba(5, 8, 15, 0.98), rgba(9, 12, 20, 1))'; container: 'appShell',
const APP_ROOT_GRADIENT_LAYERS = [APP_ROOT_BASE_GRADIENT, APP_ROOT_OVERLAY_GRADIENT] as const; grid: { x: 0, y: 0, w: 1, h: 1 }
const APP_WORKSPACE_BASE_GRADIENT = },
'radial-gradient(circle at top right, ' appWorkspace: {
+ 'hsl(var(--surface-highlight-alt) / 0.14), transparent 30%)'; container: 'appShell',
const APP_WORKSPACE_OVERLAY_GRADIENT = 'linear-gradient(180deg, rgba(9, 12, 21, 0.96), rgba(7, 10, 18, 1))'; grid: { x: 1, y: 0, w: 19, h: 1 }
const APP_WORKSPACE_GRADIENT_LAYERS = [APP_WORKSPACE_BASE_GRADIENT, APP_WORKSPACE_OVERLAY_GRADIENT] as const; },
const CHAT_ROOM_MAIN_PANEL_BASE_GRADIENT = chatRoomChannelsPanel: {
'radial-gradient(circle at top, ' container: 'roomLayout',
+ 'hsl(var(--surface-highlight) / 0.12), transparent 28%)'; grid: { x: 0, y: 0, w: 4, h: 12 }
const CHAT_ROOM_MAIN_PANEL_OVERLAY_GRADIENT = 'linear-gradient(180deg, rgba(16, 20, 34, 0.82), rgba(8, 11, 19, 0.92))'; },
const CHAT_ROOM_MAIN_PANEL_GRADIENT_LAYERS = [CHAT_ROOM_MAIN_PANEL_BASE_GRADIENT, CHAT_ROOM_MAIN_PANEL_OVERLAY_GRADIENT] as const; chatRoomMainPanel: {
const VOICE_WORKSPACE_BASE_GRADIENT = container: 'roomLayout',
'radial-gradient(circle at top right, ' grid: { x: 4, y: 0, w: 12, h: 12 }
+ 'hsl(var(--surface-highlight) / 0.14), transparent 32%)'; },
const VOICE_WORKSPACE_OVERLAY_GRADIENT = 'linear-gradient(180deg, rgba(18, 23, 37, 0.78), rgba(9, 12, 21, 0.88))'; chatRoomMembersPanel: {
const VOICE_WORKSPACE_GRADIENT_LAYERS = [VOICE_WORKSPACE_BASE_GRADIENT, VOICE_WORKSPACE_OVERLAY_GRADIENT] as const; container: 'roomLayout',
grid: { x: 16, y: 0, w: 4, h: 12 }
function createDefaultElements(): Record<string, ThemeElementStyles> { }
return Object.fromEntries( };
THEME_REGISTRY.map((entry) => [entry.key, {}])
) as Record<string, ThemeElementStyles>;
} }
function createDefaultLayout(): Record<string, ThemeLayoutEntry> { export function createDefaultThemeLayout(): Record<string, ThemeLayoutEntry> {
const layoutEntries: Record<string, ThemeLayoutEntry> = {}; const layoutEntries: Record<string, ThemeLayoutEntry> = {};
for (const key of getLayoutEditableThemeKeys()) { for (const key of getLayoutEditableThemeKeys()) {
if (key === 'serversRail') { if (key === 'serversRail') {
layoutEntries[key] = { layoutEntries[key] = {
container: 'appShell', container: 'appShell',
grid: { x: 0, grid: { x: 0, y: 0, w: 1, h: 1 }
y: 0,
w: 1,
h: 1 }
}; };
continue; continue;
@@ -57,10 +48,7 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
layoutEntries[key] = { layoutEntries[key] = {
container: 'appShell', container: 'appShell',
grid: { x: 1, grid: { x: 1, y: 0, w: (appShell?.columns ?? 20) - 1, h: 1 }
y: 0,
w: (appShell?.columns ?? 20) - 1,
h: 1 }
}; };
continue; continue;
@@ -69,10 +57,7 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
if (key === 'chatRoomChannelsPanel') { if (key === 'chatRoomChannelsPanel') {
layoutEntries[key] = { layoutEntries[key] = {
container: 'roomLayout', container: 'roomLayout',
grid: { x: 0, grid: { x: 0, y: 0, w: 4, h: 12 }
y: 0,
w: 4,
h: 12 }
}; };
continue; continue;
@@ -81,10 +66,7 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
if (key === 'chatRoomMainPanel') { if (key === 'chatRoomMainPanel') {
layoutEntries[key] = { layoutEntries[key] = {
container: 'roomLayout', container: 'roomLayout',
grid: { x: 4, grid: { x: 4, y: 0, w: 12, h: 12 }
y: 0,
w: 12,
h: 12 }
}; };
continue; continue;
@@ -93,10 +75,7 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
if (key === 'chatRoomMembersPanel') { if (key === 'chatRoomMembersPanel') {
layoutEntries[key] = { layoutEntries[key] = {
container: 'roomLayout', container: 'roomLayout',
grid: { x: 16, grid: { x: 16, y: 0, w: 4, h: 12 }
y: 0,
w: 4,
h: 12 }
}; };
continue; continue;
@@ -104,11 +83,8 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
if (key === 'dmConversationsPanel') { if (key === 'dmConversationsPanel') {
layoutEntries[key] = { layoutEntries[key] = {
container: 'roomLayout', container: 'dmLayout',
grid: { x: 0, grid: { x: 0, y: 0, w: 4, h: 12 }
y: 0,
w: 4,
h: 12 }
}; };
continue; continue;
@@ -116,11 +92,8 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
if (key === 'dmChatPanel') { if (key === 'dmChatPanel') {
layoutEntries[key] = { layoutEntries[key] = {
container: 'roomLayout', container: 'dmLayout',
grid: { x: 4, grid: { x: 4, y: 0, w: 16, h: 12 }
y: 0,
w: 16,
h: 12 }
}; };
} }
} }
@@ -128,105 +101,6 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
return layoutEntries; return layoutEntries;
} }
function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
const elements = createDefaultElements();
elements['appRoot'] = {
backgroundColor: 'hsl(var(--background))',
color: 'hsl(var(--foreground))',
gradient: APP_ROOT_GRADIENT_LAYERS.join(', ')
};
elements['serversRail'] = {
backgroundColor: 'hsl(var(--rail-background) / 0.96)',
gradient: 'linear-gradient(180deg, rgba(10, 14, 25, 0.92), rgba(6, 9, 16, 0.98))',
boxShadow: 'inset -1px 0 0 hsl(var(--border) / 0.82), 18px 0 38px rgba(0, 0, 0, 0.22)',
backdropFilter: 'var(--theme-effect-glass-blur)'
};
elements['appWorkspace'] = {
backgroundColor: 'hsl(var(--workspace-background))',
gradient: APP_WORKSPACE_GRADIENT_LAYERS.join(', ')
};
elements['titleBar'] = {
backgroundColor: 'hsl(var(--title-bar-background) / 0.82)',
color: 'hsl(var(--foreground))',
gradient: 'linear-gradient(180deg, rgba(20, 26, 41, 0.52), rgba(7, 10, 18, 0.18))',
boxShadow: ['inset 0 -1px 0 hsl(var(--border) / 0.78)', '0 12px 28px rgba(0, 0, 0, 0.18)'].join(', '),
backdropFilter: 'var(--theme-effect-glass-blur)'
};
elements['chatRoomChannelsPanel'] = {
backgroundColor: 'hsl(var(--panel-background) / 0.9)',
color: 'hsl(var(--foreground))',
border: '1px solid hsl(var(--border) / 0.7)',
borderRadius: 'var(--theme-radius-surface)',
gradient: 'linear-gradient(180deg, rgba(18, 24, 38, 0.82), rgba(10, 13, 23, 0.88))',
boxShadow: 'var(--theme-effect-soft-shadow)',
backdropFilter: 'var(--theme-effect-glass-blur)'
};
elements['chatRoomMainPanel'] = {
backgroundColor: 'hsl(var(--panel-background) / 0.82)',
color: 'hsl(var(--foreground))',
border: '1px solid hsl(var(--border) / 0.62)',
borderRadius: 'var(--theme-radius-surface)',
gradient: CHAT_ROOM_MAIN_PANEL_GRADIENT_LAYERS.join(', '),
boxShadow: 'var(--theme-effect-panel-shadow)',
backdropFilter: 'var(--theme-effect-glass-blur)'
};
elements['chatRoomMembersPanel'] = {
backgroundColor: 'hsl(var(--panel-background-alt) / 0.92)',
color: 'hsl(var(--foreground))',
border: '1px solid hsl(var(--border) / 0.7)',
borderRadius: 'var(--theme-radius-surface)',
gradient: 'linear-gradient(180deg, rgba(22, 27, 41, 0.82), rgba(11, 14, 24, 0.9))',
boxShadow: 'var(--theme-effect-soft-shadow)',
backdropFilter: 'var(--theme-effect-glass-blur)'
};
elements['dmConversationsPanel'] = {
...elements['chatRoomChannelsPanel']
};
elements['dmChatPanel'] = {
...elements['chatRoomMainPanel']
};
elements['chatRoomEmptyState'] = {
backgroundColor: 'hsl(var(--panel-background-alt) / 0.88)',
color: 'hsl(var(--muted-foreground))',
border: '1px dashed hsl(var(--border) / 0.7)',
borderRadius: 'var(--theme-radius-surface)',
gradient: 'radial-gradient(circle at top, hsl(var(--surface-highlight) / 0.08), transparent 45%)',
boxShadow: 'var(--theme-effect-soft-shadow)'
};
elements['voiceWorkspace'] = {
backgroundColor: 'hsl(var(--panel-background) / 0.74)',
color: 'hsl(var(--foreground))',
border: '1px solid hsl(var(--border) / 0.62)',
borderRadius: 'calc(var(--theme-radius-surface) + 0.2rem)',
gradient: VOICE_WORKSPACE_GRADIENT_LAYERS.join(', '),
boxShadow: 'var(--theme-effect-panel-shadow)',
backdropFilter: 'var(--theme-effect-glass-blur)'
};
elements['floatingVoiceControls'] = {
backgroundColor: 'hsl(var(--panel-background-alt) / 0.94)',
color: 'hsl(var(--foreground))',
border: '1px solid hsl(var(--border) / 0.78)',
borderRadius: 'var(--theme-radius-pill)',
gradient: 'linear-gradient(180deg, rgba(24, 31, 47, 0.92), rgba(13, 17, 29, 0.96))',
boxShadow: 'var(--theme-effect-soft-shadow)',
backdropFilter: 'var(--theme-effect-glass-blur)'
};
return elements;
}
function hasOnlyLegacyRadius(radii: Record<string, string>): boolean { function hasOnlyLegacyRadius(radii: Record<string, string>): boolean {
const keys = Object.keys(radii); const keys = Object.keys(radii);
@@ -239,6 +113,7 @@ function allElementsEmpty(elements: Record<string, ThemeElementStyles>): boolean
export function createDefaultThemeDocument(): ThemeDocument { export function createDefaultThemeDocument(): ThemeDocument {
return { return {
css: '',
meta: { meta: {
name: 'Toju Default Dark', name: 'Toju Default Dark',
version: '2.0.0', version: '2.0.0',
@@ -285,23 +160,34 @@ export function createDefaultThemeDocument(): ThemeDocument {
glassBlur: 'blur(18px) saturate(135%)' glassBlur: 'blur(18px) saturate(135%)'
} }
}, },
layout: createDefaultLayout(), layout: createProvidedDefaultLayout(),
elements: createDarkDefaultElements(), elements: {},
animations: {} animations: {}
}; };
} }
export function isLegacyDefaultThemeDocument(document: ThemeDocument): boolean { export function isLegacyDefaultThemeDocument(document: ThemeDocument): boolean {
return document.meta.name === 'Toju Default Theme' return (
&& document.meta.version === '1.0.0' document.meta.name === 'Toju Default Theme' &&
&& document.meta.description === 'Safe baseline theme that matches the built-in Toju shell layout.' document.meta.version === '1.0.0' &&
&& Object.keys(document.tokens.colors).length === 0 document.meta.description === 'Safe baseline theme that matches the built-in Toju shell layout.' &&
&& Object.keys(document.tokens.spacing).length === 0 Object.keys(document.tokens.colors).length === 0 &&
&& hasOnlyLegacyRadius(document.tokens.radii) Object.keys(document.tokens.spacing).length === 0 &&
&& Object.keys(document.tokens.effects).length === 0 hasOnlyLegacyRadius(document.tokens.radii) &&
&& allElementsEmpty(document.elements) Object.keys(document.tokens.effects).length === 0 &&
&& Object.keys(document.animations).length === 0; allElementsEmpty(document.elements) &&
Object.keys(document.animations).length === 0 &&
document.css.length === 0
);
} }
export const DEFAULT_THEME_DOCUMENT: ThemeDocument = createDefaultThemeDocument(); export const DEFAULT_THEME_DOCUMENT: ThemeDocument = createDefaultThemeDocument();
export const DEFAULT_THEME_JSON = JSON.stringify(DEFAULT_THEME_DOCUMENT, null, 2); export const DEFAULT_THEME_JSON = JSON.stringify(
{
meta: DEFAULT_THEME_DOCUMENT.meta,
tokens: DEFAULT_THEME_DOCUMENT.tokens,
layout: DEFAULT_THEME_DOCUMENT.layout
},
null,
2
);

View File

@@ -16,6 +16,14 @@ export const THEME_LAYOUT_CONTAINERS: readonly ThemeLayoutContainerDefinition[]
columns: 20, columns: 20,
rows: 12, rows: 12,
templateColumns: 'repeat(4, 4.25rem) repeat(12, minmax(0, 1fr)) repeat(4, 4.25rem)' templateColumns: 'repeat(4, 4.25rem) repeat(12, minmax(0, 1fr)) repeat(4, 4.25rem)'
},
{
key: 'dmLayout',
label: 'DM Workspace',
description: 'Controls the conversations list and private chat panel inside direct messages.',
columns: 20,
rows: 12,
templateColumns: 'repeat(4, 4.25rem) repeat(16, minmax(0, 1fr))'
} }
]; ];
@@ -66,6 +74,39 @@ export const THEME_REGISTRY: readonly ThemeRegistryEntry[] = [
supportsLink: true, supportsLink: true,
supportsIcon: true supportsIcon: true
}, },
{
key: 'serversRailCreateButton',
label: 'Create Server Button',
description: 'The primary action at the top of the server rail for creating or joining servers.',
category: 'shell',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: true
},
{
key: 'serversRailList',
label: 'Servers List',
description: 'The scrollable stack of saved server shortcuts in the left rail.',
category: 'shell',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: false
},
{
key: 'serversRailItem',
label: 'Server Shortcut',
description: 'An individual saved server icon button in the left rail.',
category: 'shell',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: true
},
{ {
key: 'chatRoomChannelsPanel', key: 'chatRoomChannelsPanel',
label: 'Channels Panel', label: 'Channels Panel',
@@ -78,6 +119,72 @@ export const THEME_REGISTRY: readonly ThemeRegistryEntry[] = [
supportsLink: false, supportsLink: false,
supportsIcon: false supportsIcon: false
}, },
{
key: 'roomPanelHeader',
label: 'Room Panel Header',
description: 'The header at the top of the room side panel with server or member context.',
category: 'room',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: true
},
{
key: 'roomTextChannelsSection',
label: 'Text Channels Section',
description: 'The side-panel section that groups text channels.',
category: 'room',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: false
},
{
key: 'roomTextChannelItem',
label: 'Text Channel Item',
description: 'An individual text channel row in the room side panel.',
category: 'room',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: true
},
{
key: 'roomVoiceChannelsSection',
label: 'Voice Channels Section',
description: 'The side-panel section that groups voice channels.',
category: 'room',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: false
},
{
key: 'roomVoiceChannelItem',
label: 'Voice Channel Item',
description: 'An individual voice channel row in the room side panel.',
category: 'room',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: true
},
{
key: 'roomVoiceUserItem',
label: 'Voice User Item',
description: 'A user row nested under a voice channel.',
category: 'room',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: true
},
{ {
key: 'chatRoomMainPanel', key: 'chatRoomMainPanel',
label: 'Chat Panel', label: 'Chat Panel',
@@ -90,6 +197,182 @@ export const THEME_REGISTRY: readonly ThemeRegistryEntry[] = [
supportsLink: false, supportsLink: false,
supportsIcon: false supportsIcon: false
}, },
{
key: 'chatSurface',
label: 'Chat Surface',
description: 'The message area surface shared by room chat views.',
category: 'room',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: false
},
{
key: 'chatMessageList',
label: 'Message List',
description: 'The scrollable list that contains message bubbles, date separators, and load-more states.',
category: 'room',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: false
},
{
key: 'chatDateSeparator',
label: 'Date Separator',
description: 'The horizontal date divider inserted between groups of chat messages.',
category: 'room',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: false
},
{
key: 'chatNewMessagesBar',
label: 'New Messages Bar',
description: 'The sticky prompt for jumping to unread messages.',
category: 'room',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: true,
supportsLink: false,
supportsIcon: false
},
{
key: 'chatMessageBubble',
label: 'Message Bubble',
description: 'The main row for an individual room or direct-message chat message.',
category: 'room',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: false
},
{
key: 'chatMessageAvatar',
label: 'Message Avatar',
description: 'The sender avatar hit target inside each message row.',
category: 'room',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: true
},
{
key: 'chatMessageContent',
label: 'Message Content',
description: 'The text and rich content column inside each chat message.',
category: 'room',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: false
},
{
key: 'chatAttachmentCard',
label: 'Attachment Card',
description: 'Cards that display attachment transfer, preview, request, and download states.',
category: 'room',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: true
},
{
key: 'chatReactionPill',
label: 'Reaction Pill',
description: 'A compact emoji reaction button under a message.',
category: 'room',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: true
},
{
key: 'chatMessageActions',
label: 'Message Actions',
description: 'The hover toolbar for reacting, replying, editing, and deleting a message.',
category: 'room',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: false
},
{
key: 'chatComposerBar',
label: 'Composer Bar',
description: 'The pinned area that holds reply state, typing indicator, markdown tools, and the message input.',
category: 'room',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: false
},
{
key: 'chatComposerReplyBar',
label: 'Composer Reply Bar',
description: 'The reply preview shown above the message composer.',
category: 'room',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: true,
supportsLink: false,
supportsIcon: true
},
{
key: 'chatComposerToolbar',
label: 'Composer Toolbar',
description: 'The inline markdown formatting toolbar above the composer input.',
category: 'room',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: false
},
{
key: 'chatComposerInput',
label: 'Composer Input',
description: 'The textarea region where chat messages are written.',
category: 'room',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: false
},
{
key: 'chatComposerSendButton',
label: 'Send Button',
description: 'The send action button inside the message composer.',
category: 'room',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: true
},
{
key: 'chatGifPickerSurface',
label: 'GIF Picker Surface',
description: 'The floating GIF picker container opened from the chat composer.',
category: 'overlay',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: false
},
{ {
key: 'chatRoomMembersPanel', key: 'chatRoomMembersPanel',
label: 'Members Panel', label: 'Members Panel',
@@ -107,25 +390,102 @@ export const THEME_REGISTRY: readonly ThemeRegistryEntry[] = [
label: 'DM Conversations Panel', label: 'DM Conversations Panel',
description: 'The direct-message sidebar showing private chat conversations.', description: 'The direct-message sidebar showing private chat conversations.',
category: 'room', category: 'room',
container: 'roomLayout', container: 'dmLayout',
layoutEditable: true, layoutEditable: true,
pickerVisible: true, pickerVisible: true,
supportsTextOverride: false, supportsTextOverride: false,
supportsLink: false, supportsLink: false,
supportsIcon: false supportsIcon: false
}, },
{
key: 'dmConversationsHeader',
label: 'DM Conversations Header',
description: 'The header above the direct-message conversations list.',
category: 'room',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: true,
supportsLink: false,
supportsIcon: true
},
{
key: 'dmConversationList',
label: 'DM Conversation List',
description: 'The scrollable list of direct-message conversations.',
category: 'room',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: false
},
{
key: 'dmConversationItem',
label: 'DM Conversation Item',
description: 'An individual direct-message conversation row.',
category: 'room',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: true
},
{
key: 'dmVoiceControlsArea',
label: 'DM Voice Controls Area',
description: 'The voice controls slot at the bottom of the direct-message sidebar.',
category: 'room',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: false
},
{ {
key: 'dmChatPanel', key: 'dmChatPanel',
label: 'DM Chat Panel', label: 'DM Chat Panel',
description: 'The main direct-message panel that hosts private chat messages.', description: 'The main direct-message panel that hosts private chat messages.',
category: 'room', category: 'room',
container: 'roomLayout', container: 'dmLayout',
layoutEditable: true, layoutEditable: true,
pickerVisible: true, pickerVisible: true,
supportsTextOverride: false, supportsTextOverride: false,
supportsLink: false, supportsLink: false,
supportsIcon: false supportsIcon: false
}, },
{
key: 'dmChatSurface',
label: 'DM Chat Surface',
description: 'The direct-message chat surface containing the DM header, messages, and composer.',
category: 'room',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: false
},
{
key: 'dmChatHeader',
label: 'DM Chat Header',
description: 'The header above a direct-message conversation.',
category: 'room',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: true,
supportsLink: false,
supportsIcon: true
},
{
key: 'dmMessageRegion',
label: 'DM Message Region',
description: 'The absolute region that holds direct-message history and status markers.',
category: 'room',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: false
},
{ {
key: 'chatRoomEmptyState', key: 'chatRoomEmptyState',
label: 'Room Empty State', label: 'Room Empty State',
@@ -148,6 +508,39 @@ export const THEME_REGISTRY: readonly ThemeRegistryEntry[] = [
supportsLink: false, supportsLink: false,
supportsIcon: false supportsIcon: false
}, },
{
key: 'voiceControlsPanel',
label: 'Voice Controls Panel',
description: 'The voice connection card embedded in sidebars and direct-message views.',
category: 'overlay',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: false
},
{
key: 'voiceControlsUserRow',
label: 'Voice Controls User Row',
description: 'The current-user row inside the voice controls panel.',
category: 'overlay',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: true
},
{
key: 'voiceControlsButtons',
label: 'Voice Controls Buttons',
description: 'The mute, deafen, camera, screen-share, and disconnect button group.',
category: 'overlay',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: false
},
{ {
key: 'floatingVoiceControls', key: 'floatingVoiceControls',
label: 'Floating Voice Controls', label: 'Floating Voice Controls',
@@ -158,6 +551,127 @@ export const THEME_REGISTRY: readonly ThemeRegistryEntry[] = [
supportsTextOverride: false, supportsTextOverride: false,
supportsLink: false, supportsLink: false,
supportsIcon: false supportsIcon: false
},
{
key: 'settingsModalSurface',
label: 'Settings Modal Surface',
description: 'The main settings modal frame.',
category: 'overlay',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: false
},
{
key: 'settingsModalNav',
label: 'Settings Navigation',
description: 'The left navigation column inside Settings.',
category: 'overlay',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: false
},
{
key: 'settingsModalHeader',
label: 'Settings Header',
description: 'The title and close-button row at the top of Settings content.',
category: 'overlay',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: true,
supportsLink: false,
supportsIcon: true
},
{
key: 'settingsModalContent',
label: 'Settings Content',
description: 'The scrollable settings page content area.',
category: 'overlay',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: false
},
{
key: 'contextMenuSurface',
label: 'Context Menu Surface',
description: 'The floating context menu panel used throughout the app.',
category: 'overlay',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: false
},
{
key: 'confirmDialogSurface',
label: 'Confirm Dialog Surface',
description: 'The shared confirmation dialog frame.',
category: 'overlay',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: true,
supportsLink: false,
supportsIcon: true
},
{
key: 'profileCardSurface',
label: 'Profile Card Surface',
description: 'The user profile popover card.',
category: 'overlay',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: false
},
{
key: 'profileCardBanner',
label: 'Profile Card Banner',
description: 'The decorative banner strip at the top of a profile card.',
category: 'overlay',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: true
},
{
key: 'profileCardBody',
label: 'Profile Card Body',
description: 'The main text and status content inside a profile card.',
category: 'overlay',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: false
},
{
key: 'screenShareSourcePicker',
label: 'Screen Share Source Picker',
description: 'The dialog for choosing a screen or window to share.',
category: 'overlay',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: true,
supportsLink: false,
supportsIcon: true
},
{
key: 'screenShareSourceCard',
label: 'Screen Share Source Card',
description: 'An individual screen or window option in the source picker.',
category: 'overlay',
layoutEditable: false,
pickerVisible: true,
supportsTextOverride: false,
supportsLink: false,
supportsIcon: true
} }
]; ];
@@ -170,13 +684,9 @@ export function findThemeLayoutContainer(key: string): ThemeLayoutContainerDefin
} }
export function getLayoutEditableThemeKeys(): string[] { export function getLayoutEditableThemeKeys(): string[] {
return THEME_REGISTRY return THEME_REGISTRY.filter((entry) => entry.layoutEditable).map((entry) => entry.key);
.filter((entry) => entry.layoutEditable)
.map((entry) => entry.key);
} }
export function getPickerVisibleThemeKeys(): string[] { export function getPickerVisibleThemeKeys(): string[] {
return THEME_REGISTRY return THEME_REGISTRY.filter((entry) => entry.pickerVisible).map((entry) => entry.key);
.filter((entry) => entry.pickerVisible)
.map((entry) => entry.key);
} }

View File

@@ -4,11 +4,16 @@ import {
ThemeSchemaField ThemeSchemaField
} from '../models/theme.model'; } from '../models/theme.model';
const RADIAL_GRADIENT_EXAMPLE = const RADIAL_GRADIENT_EXAMPLE = 'radial-gradient(circle at top, rgba(255,255,255,0.12), ' + 'transparent 60%)';
'radial-gradient(circle at top, rgba(255,255,255,0.12), '
+ 'transparent 60%)';
export const THEME_TOP_LEVEL_FIELDS: readonly ThemeSchemaField[] = [ export const THEME_TOP_LEVEL_FIELDS: readonly ThemeSchemaField[] = [
{
key: 'css',
description: 'Optional raw CSS applied after the theme JSON is active.',
type: 'string',
example: '.theme-settings { outline: 1px solid hsl(var(--primary)); }',
examples: ['.theme-settings { outline: 1px solid hsl(var(--primary)); }']
},
{ {
key: 'meta', key: 'meta',
description: 'Theme metadata used for naming, versioning, and describing the preset.', description: 'Theme metadata used for naming, versioning, and describing the preset.',
@@ -27,8 +32,8 @@ export const THEME_TOP_LEVEL_FIELDS: readonly ThemeSchemaField[] = [
key: 'layout', key: 'layout',
description: 'Grid layout entries for registered moveable surfaces.', description: 'Grid layout entries for registered moveable surfaces.',
type: 'object', type: 'object',
example: '{ "chatRoomMainPanel": { "container": "roomLayout", "grid": { "x": 4, "y": 0, "w": 12, "h": 12 } } }', example: '{ "dmChatPanel": { "container": "dmLayout", "grid": { "x": 4, "y": 0, "w": 16, "h": 12 } } }',
examples: ['{ "chatRoomMainPanel": { "container": "roomLayout", "grid": { "x": 4, "y": 0, "w": 12, "h": 12 } } }'] examples: ['{ "dmChatPanel": { "container": "dmLayout", "grid": { "x": 4, "y": 0, "w": 16, "h": 12 } } }']
}, },
{ {
key: 'elements', key: 'elements',
@@ -83,7 +88,11 @@ export const THEME_LAYOUT_FIELDS: readonly ThemeSchemaField[] = [
description: 'The registered layout container that owns this grid item.', description: 'The registered layout container that owns this grid item.',
type: 'string', type: 'string',
example: 'roomLayout', example: 'roomLayout',
examples: ['appShell', 'roomLayout'] examples: [
'appShell',
'roomLayout',
'dmLayout'
]
}, },
{ {
key: 'grid', key: 'grid',
@@ -384,10 +393,14 @@ export const THEME_ELEMENT_STYLE_FIELDS: readonly ThemeSchemaField<ThemeElementS
}, },
{ {
key: 'backgroundImage', key: 'backgroundImage',
description: 'CSS background image or image URL.', description: 'CSS background-image value, local image path, or http/https image URL.',
type: 'string', type: 'string',
example: "url('/assets/themes/paper-noise.png')", example: '/assets/themes/paper-noise.png',
examples: ["url('/assets/themes/paper-noise.png')", "url('https://example.com/bg.jpg')"] examples: [
'/assets/themes/paper-noise.png',
'https://example.com/bg.jpg',
"url('/assets/themes/paper-noise.png')"
]
}, },
{ {
key: 'backgroundSize', key: 'backgroundSize',
@@ -497,10 +510,7 @@ export function getSuggestedValueOptions(
return findThemeElementStyleField(field)?.examples ?? []; return findThemeElementStyleField(field)?.examples ?? [];
} }
export function getSuggestedFieldDefault( export function getSuggestedFieldDefault(field: ThemeElementStyleProperty, animationKeys: readonly string[] = []): string | number {
field: ThemeElementStyleProperty,
animationKeys: readonly string[] = []
): string | number {
return getSuggestedValueOptions(field, animationKeys)[0] ?? ''; return getSuggestedValueOptions(field, animationKeys)[0] ?? '';
} }

View File

@@ -1,11 +1,14 @@
import { import {
ThemeAnimationDefinition, ThemeAnimationDefinition,
ThemeContainerKey,
ThemeDocument, ThemeDocument,
ThemeElementStyleProperty, ThemeElementStyleProperty,
ThemeElementStyles, ThemeElementStyles,
ThemeGridRect,
ThemeLayoutEntry,
ThemeValidationResult ThemeValidationResult
} from '../models/theme.model'; } from '../models/theme.model';
import { createDefaultThemeDocument } from './theme-defaults.logic'; import { createDefaultThemeDocument, createDefaultThemeLayout } from './theme-defaults.logic';
import { import {
THEME_LAYOUT_CONTAINERS, THEME_LAYOUT_CONTAINERS,
THEME_REGISTRY, THEME_REGISTRY,
@@ -13,6 +16,7 @@ import {
} from './theme-registry.logic'; } from './theme-registry.logic';
const TOP_LEVEL_KEYS = [ const TOP_LEVEL_KEYS = [
'css',
'meta', 'meta',
'tokens', 'tokens',
'layout', 'layout',
@@ -103,12 +107,7 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value); return !!value && typeof value === 'object' && !Array.isArray(value);
} }
function validateUnknownKeys( function validateUnknownKeys(value: Record<string, unknown>, allowedKeys: readonly string[], path: string, errors: string[]): void {
value: Record<string, unknown>,
allowedKeys: readonly string[],
path: string,
errors: string[]
): void {
for (const key of Object.keys(value)) { for (const key of Object.keys(value)) {
if (!allowedKeys.includes(key)) { if (!allowedKeys.includes(key)) {
errors.push(`${path}.${key} is not part of the supported theme schema.`); errors.push(`${path}.${key} is not part of the supported theme schema.`);
@@ -130,12 +129,7 @@ function validateString(value: unknown, path: string, errors: string[], allowEmp
return true; return true;
} }
function validateEnum<T extends readonly string[]>( function validateEnum<T extends readonly string[]>(value: unknown, allowedValues: T, path: string, errors: string[]): value is T[number] {
value: unknown,
allowedValues: T,
path: string,
errors: string[]
): value is T[number] {
if (typeof value !== 'string' || !allowedValues.includes(value)) { if (typeof value !== 'string' || !allowedValues.includes(value)) {
errors.push(`${path} must be one of: ${allowedValues.join(', ')}.`); errors.push(`${path} must be one of: ${allowedValues.join(', ')}.`);
return false; return false;
@@ -144,13 +138,7 @@ function validateEnum<T extends readonly string[]>(
return true; return true;
} }
function validateInteger( function validateInteger(value: unknown, path: string, errors: string[], minimum: number, allowZero: boolean): value is number {
value: unknown,
path: string,
errors: string[],
minimum: number,
allowZero: boolean
): value is number {
if (typeof value !== 'number' || !Number.isInteger(value)) { if (typeof value !== 'number' || !Number.isInteger(value)) {
errors.push(`${path} must be an integer.`); errors.push(`${path} must be an integer.`);
return false; return false;
@@ -164,13 +152,7 @@ function validateInteger(
return true; return true;
} }
function validateNumberRange( function validateNumberRange(value: unknown, path: string, errors: string[], minimum: number, maximum: number): value is number {
value: unknown,
path: string,
errors: string[],
minimum: number,
maximum: number
): value is number {
if (typeof value !== 'number' || Number.isNaN(value)) { if (typeof value !== 'number' || Number.isNaN(value)) {
errors.push(`${path} must be a number.`); errors.push(`${path} must be a number.`);
return false; return false;
@@ -305,49 +287,56 @@ function validateAnimationDefinition(value: unknown, path: string, errors: strin
return true; return true;
} }
function normaliseThemeDocument(input: Partial<ThemeDocument>): ThemeDocument { function normaliseThemeDocument(input: Record<string, unknown>): ThemeDocument {
const document = createDefaultThemeDocument(); const defaults = createDefaultThemeDocument();
const meta = isPlainObject(input['meta']) ? input['meta'] : {};
const tokens = isPlainObject(input['tokens']) ? input['tokens'] : {};
const layout = isPlainObject(input['layout']) ? input['layout'] : {};
document.meta = { return {
...document.meta, css: typeof input['css'] === 'string' ? input['css'] : '',
...input.meta meta: {
name: typeof meta['name'] === 'string' ? meta['name'] : defaults.meta.name,
version: typeof meta['version'] === 'string' ? meta['version'] : defaults.meta.version,
description: typeof meta['description'] === 'string' ? meta['description'] : defaults.meta.description
},
tokens: {
colors: isPlainObject(tokens['colors']) ? (tokens['colors'] as Record<string, string>) : {},
spacing: isPlainObject(tokens['spacing']) ? (tokens['spacing'] as Record<string, string>) : {},
radii: isPlainObject(tokens['radii']) ? (tokens['radii'] as Record<string, string>) : {},
effects: isPlainObject(tokens['effects']) ? (tokens['effects'] as Record<string, string>) : {}
},
layout: normaliseThemeLayout(layout, createDefaultThemeLayout()),
elements: isPlainObject(input['elements']) ? (input['elements'] as Record<string, ThemeElementStyles>) : {},
animations: isPlainObject(input['animations']) ? (input['animations'] as Record<string, ThemeAnimationDefinition>) : {}
}; };
}
document.tokens = { function normaliseThemeLayout(input: Record<string, unknown>, defaults: Record<string, ThemeLayoutEntry>): Record<string, ThemeLayoutEntry> {
colors: { const layout: Record<string, ThemeLayoutEntry> = {};
...document.tokens.colors,
...(input.tokens?.colors ?? {}) for (const [key, value] of Object.entries(input)) {
}, if (!isPlainObject(value)) {
spacing: { continue;
...document.tokens.spacing, }
...(input.tokens?.spacing ?? {})
}, const defaultEntry = defaults[key];
radii: { const grid = isPlainObject(value['grid']) ? (value['grid'] as Partial<ThemeGridRect>) : {};
...document.tokens.radii,
...(input.tokens?.radii ?? {}) if (!defaultEntry) {
}, continue;
effects: { }
...document.tokens.effects,
...(input.tokens?.effects ?? {}) layout[key] = {
container: typeof value['container'] === 'string' ? (value['container'] as ThemeContainerKey) : defaultEntry.container,
grid: {
...defaultEntry.grid,
...grid
} }
}; };
}
document.layout = { return 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 { export function validateThemeDocument(input: unknown): ThemeValidationResult {
@@ -363,14 +352,24 @@ export function validateThemeDocument(input: unknown): ThemeValidationResult {
validateUnknownKeys(input, TOP_LEVEL_KEYS, 'theme', errors); validateUnknownKeys(input, TOP_LEVEL_KEYS, 'theme', errors);
if (input['css'] !== undefined) {
validateString(input['css'], 'theme.css', errors, true);
}
const meta = input['meta']; const meta = input['meta'];
if (!isPlainObject(meta)) { if (meta !== undefined && !isPlainObject(meta)) {
errors.push('theme.meta must be an object.'); errors.push('theme.meta must be an object.');
} else { } else if (isPlainObject(meta)) {
validateUnknownKeys(meta, META_KEYS, 'theme.meta', errors); validateUnknownKeys(meta, META_KEYS, 'theme.meta', errors);
if (meta['name'] !== undefined) {
validateString(meta['name'], 'theme.meta.name', errors); validateString(meta['name'], 'theme.meta.name', errors);
}
if (meta['version'] !== undefined) {
validateString(meta['version'], 'theme.meta.version', errors); validateString(meta['version'], 'theme.meta.version', errors);
}
if (meta['description'] !== undefined) { if (meta['description'] !== undefined) {
validateString(meta['description'], 'theme.meta.description', errors, true); validateString(meta['description'], 'theme.meta.description', errors, true);
@@ -492,6 +491,6 @@ export function validateThemeDocument(input: unknown): ThemeValidationResult {
return { return {
valid: true, valid: true,
errors: [], errors: [],
value: normaliseThemeDocument(input as Partial<ThemeDocument>) value: normaliseThemeDocument(input)
}; };
} }

View File

@@ -1,4 +1,4 @@
export type ThemeContainerKey = 'appShell' | 'roomLayout'; export type ThemeContainerKey = 'appShell' | 'roomLayout' | 'dmLayout';
export interface ThemeMeta { export interface ThemeMeta {
name: string; name: string;
@@ -70,6 +70,7 @@ export interface ThemeAnimationDefinition {
} }
export interface ThemeDocument { export interface ThemeDocument {
css: string;
meta: ThemeMeta; meta: ThemeMeta;
tokens: ThemeTokenGroups; tokens: ThemeTokenGroups;
layout: Record<string, ThemeLayoutEntry>; layout: Record<string, ThemeLayoutEntry>;

View File

@@ -15,11 +15,18 @@
class="theme-grid-editor__frame relative overflow-hidden rounded-lg border border-border/80" class="theme-grid-editor__frame relative overflow-hidden rounded-lg border border-border/80"
[ngStyle]="frameStyle()" [ngStyle]="frameStyle()"
> >
<div class="theme-grid-editor__grid"></div> <div
class="theme-grid-editor__grid"
[ngStyle]="frameStyle()"
>
@for (cell of gridCells(); track cell) {
<div class="theme-grid-editor__cell"></div>
}
</div>
@for (item of items(); track item.key) { @for (item of items(); track item.key) {
<div <div
class="theme-grid-editor__item absolute" class="theme-grid-editor__item"
[class.theme-grid-editor__item--selected]="selectedKey() === item.key" [class.theme-grid-editor__item--selected]="selectedKey() === item.key"
[class.theme-grid-editor__item--disabled]="disabled()" [class.theme-grid-editor__item--disabled]="disabled()"
[ngStyle]="itemStyle(item)" [ngStyle]="itemStyle(item)"

View File

@@ -3,6 +3,7 @@
} }
.theme-grid-editor__frame { .theme-grid-editor__frame {
display: grid;
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
background: background:
radial-gradient(circle at top, hsl(var(--primary) / 0.05), transparent 45%), radial-gradient(circle at top, hsl(var(--primary) / 0.05), transparent 45%),
@@ -12,15 +13,18 @@
.theme-grid-editor__grid { .theme-grid-editor__grid {
position: absolute; position: absolute;
inset: 0; inset: 0;
background-image: display: grid;
linear-gradient(to right, hsl(var(--border) / 0.65) 1px, transparent 1px), pointer-events: none;
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)), .theme-grid-editor__cell {
calc(100% / var(--theme-grid-columns)) calc(100% / var(--theme-grid-rows)); border-top: 1px solid hsl(var(--border) / 0.65);
border-left: 1px solid hsl(var(--border) / 0.65);
} }
.theme-grid-editor__item { .theme-grid-editor__item {
position: relative;
z-index: 1;
padding: 0.35rem; padding: 0.35rem;
} }

View File

@@ -45,8 +45,11 @@ export class ThemeGridEditorComponent {
readonly canvasRef = viewChild.required<ElementRef<HTMLElement>>('canvasRef'); readonly canvasRef = viewChild.required<ElementRef<HTMLElement>>('canvasRef');
readonly frameStyle = computed(() => ({ readonly frameStyle = computed(() => ({
'--theme-grid-columns': `${this.container().columns}`, '--theme-grid-columns': `${this.container().columns}`,
'--theme-grid-rows': `${this.container().rows}` '--theme-grid-rows': `${this.container().rows}`,
gridTemplateColumns: this.container().templateColumns ?? `repeat(${this.container().columns}, minmax(0, 1fr))`,
gridTemplateRows: this.container().templateRows ?? `repeat(${this.container().rows}, minmax(0, 1fr))`
})); }));
readonly gridCells = computed(() => Array.from({ length: this.container().columns * this.container().rows }, (_, index) => index));
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef); private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private dragState: DragState | null = null; private dragState: DragState | null = null;
@@ -57,11 +60,8 @@ export class ThemeGridEditorComponent {
return; return;
} }
const canvasRect = this.canvasRef().nativeElement.getBoundingClientRect(); const deltaColumns = this.gridDelta('columns', this.dragState.startClientX, event.clientX);
const columnWidth = canvasRect.width / this.container().columns; const deltaRows = this.gridDelta('rows', this.dragState.startClientY, event.clientY);
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 }; const nextGrid = { ...this.dragState.startGrid };
if (this.dragState.mode === 'move') { if (this.dragState.mode === 'move') {
@@ -90,13 +90,9 @@ export class ThemeGridEditorComponent {
} }
itemStyle(item: ThemeGridEditorItem): Record<string, string> { itemStyle(item: ThemeGridEditorItem): Record<string, string> {
const { columns, rows } = this.container();
return { return {
left: `${(item.grid.x / columns) * 100}%`, gridColumn: `${item.grid.x + 1} / span ${item.grid.w}`,
top: `${(item.grid.y / rows) * 100}%`, gridRow: `${item.grid.y + 1} / span ${item.grid.h}`
width: `${(item.grid.w / columns) * 100}%`,
height: `${(item.grid.h / rows) * 100}%`
}; };
} }
@@ -132,4 +128,61 @@ export class ThemeGridEditorComponent {
private clamp(value: number, minimum: number, maximum: number): number { private clamp(value: number, minimum: number, maximum: number): number {
return Math.min(Math.max(value, minimum), maximum); return Math.min(Math.max(value, minimum), maximum);
} }
private gridDelta(axis: 'columns' | 'rows', startClientPosition: number, currentClientPosition: number): number {
const canvasRect = this.canvasRef().nativeElement.getBoundingClientRect();
const linePositions = this.measureGridLines(axis, canvasRect);
const startOffset = startClientPosition - (axis === 'columns' ? canvasRect.left : canvasRect.top);
const currentOffset = currentClientPosition - (axis === 'columns' ? canvasRect.left : canvasRect.top);
return this.nearestGridLineIndex(currentOffset, linePositions) - this.nearestGridLineIndex(startOffset, linePositions);
}
private measureGridLines(axis: 'columns' | 'rows', canvasRect: DOMRect): number[] {
const canvas = this.canvasRef().nativeElement;
const computedStyles = getComputedStyle(canvas);
const expectedTracks = axis === 'columns' ? this.container().columns : this.container().rows;
const availableSize = axis === 'columns' ? canvasRect.width : canvasRect.height;
const template = axis === 'columns' ? computedStyles.gridTemplateColumns : computedStyles.gridTemplateRows;
const trackSizes = template
.split(/\s+/)
.map((track) => Number.parseFloat(track))
.filter((size) => Number.isFinite(size) && size > 0);
if (trackSizes.length !== expectedTracks) {
return Array.from({ length: expectedTracks + 1 }, (_, index) => (availableSize / expectedTracks) * index);
}
const totalTrackSize = trackSizes.reduce((sum, size) => sum + size, 0);
const scale = totalTrackSize > 0 ? availableSize / totalTrackSize : 1;
const linePositions = [0];
for (const trackSize of trackSizes) {
const previousLinePosition = linePositions[linePositions.length - 1] ?? 0;
linePositions.push(previousLinePosition + trackSize * scale);
}
linePositions[linePositions.length - 1] = availableSize;
return linePositions;
}
private nearestGridLineIndex(offset: number, linePositions: number[]): number {
const clampedOffset = this.clamp(offset, linePositions[0] ?? 0, linePositions.at(-1) ?? 0);
let nearestIndex = 0;
let nearestDistance = Number.POSITIVE_INFINITY;
for (let index = 0; index < linePositions.length; index++) {
const distance = Math.abs(linePositions[index] - clampedOffset);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestIndex = index;
}
}
return nearestIndex;
}
} }

View File

@@ -11,14 +11,32 @@ import {
viewChild viewChild
} from '@angular/core'; } from '@angular/core';
import { indentWithTab } from '@codemirror/commands'; import { indentWithTab } from '@codemirror/commands';
import { json } from '@codemirror/lang-json'; import { css } from '@codemirror/lang-css';
import { indentUnit } from '@codemirror/language'; import { json, jsonParseLinter } from '@codemirror/lang-json';
import { EditorSelection, EditorState } from '@codemirror/state'; import { indentUnit, syntaxTree } from '@codemirror/language';
import {
Diagnostic,
lintGutter,
linter
} from '@codemirror/lint';
import {
EditorSelection,
EditorState,
Extension
} from '@codemirror/state';
import { oneDark } from '@codemirror/theme-one-dark'; import { oneDark } from '@codemirror/theme-one-dark';
import { EditorView, keymap } from '@codemirror/view'; import { EditorView, keymap } from '@codemirror/view';
import { basicSetup } from 'codemirror'; import { basicSetup } from 'codemirror';
const THEME_JSON_EDITOR_THEME = EditorView.theme({ import { formatPastedJsonText } from './theme-json-format.logic';
type ThemeCodeEditorLanguage = 'json' | 'css';
const ERROR_SQUIGGLE_BACKGROUND =
'linear-gradient(45deg, transparent 65%, #fb7185 65%, #fb7185 80%, transparent 80%), '
+ 'linear-gradient(135deg, transparent 65%, #fb7185 65%, #fb7185 80%, transparent 80%)';
const THEME_JSON_EDITOR_THEME = EditorView.theme(
{
'&': { '&': {
height: '100%', height: '100%',
backgroundColor: 'transparent', backgroundColor: 'transparent',
@@ -78,8 +96,64 @@ const THEME_JSON_EDITOR_THEME = EditorView.theme({
'.cm-tooltip-autocomplete ul li[aria-selected]': { '.cm-tooltip-autocomplete ul li[aria-selected]': {
backgroundColor: 'rgb(96 165 250 / 0.18)', backgroundColor: 'rgb(96 165 250 / 0.18)',
color: '#f8fafc' color: '#f8fafc'
},
'.cm-diagnosticRange-error': {
backgroundImage: ERROR_SQUIGGLE_BACKGROUND,
backgroundPosition: '0 100%',
backgroundRepeat: 'repeat-x',
backgroundSize: '8px 3px',
paddingBottom: '2px'
},
'.cm-lintRange-error': {
backgroundImage: ERROR_SQUIGGLE_BACKGROUND,
backgroundPosition: '0 100%',
backgroundRepeat: 'repeat-x',
backgroundSize: '8px 3px',
paddingBottom: '2px'
},
'.cm-lintPoint-error:after': {
borderBottomColor: '#fb7185'
},
'.cm-diagnostic': {
fontFamily: 'Inter, system-ui, sans-serif'
},
'.cm-diagnostic-error': {
borderLeftColor: '#fb7185'
}
},
{ dark: true }
);
function getLanguageExtensions(language: ThemeCodeEditorLanguage): Extension[] {
if (language === 'css') {
return [css(), linter(cssSyntaxLinter, { delay: 250 })];
}
return [json(), linter(jsonParseLinter(), { delay: 250 })];
}
function cssSyntaxLinter(view: EditorView): Diagnostic[] {
const diagnostics: Diagnostic[] = [];
const documentLength = view.state.doc.length;
syntaxTree(view.state).iterate({
enter: (node) => {
if (!node.type.isError) {
return;
}
diagnostics.push({
from: node.from,
to: Math.max(node.to, Math.min(node.from + 1, documentLength)),
severity: 'error',
source: 'CSS',
message: 'CSS syntax error.'
});
}
});
return diagnostics;
} }
}, { dark: true });
@Component({ @Component({
selector: 'app-theme-json-code-editor', selector: 'app-theme-json-code-editor',
@@ -91,9 +165,10 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
readonly editorHostRef = viewChild<ElementRef<HTMLDivElement>>('editorHostRef'); readonly editorHostRef = viewChild<ElementRef<HTMLDivElement>>('editorHostRef');
readonly value = input.required<string>(); readonly value = input.required<string>();
readonly fullscreen = input(false); readonly fullscreen = input(false);
readonly language = input<ThemeCodeEditorLanguage>('json');
readonly valueChange = output<string>(); readonly valueChange = output<string>();
readonly editorMinHeight = computed(() => this.fullscreen() ? 'max(34rem, calc(100vh - 15rem))' : '28rem'); readonly editorMinHeight = computed(() => (this.fullscreen() ? 'max(34rem, calc(100vh - 15rem))' : '28rem'));
private readonly zone = inject(NgZone); private readonly zone = inject(NgZone);
@@ -173,6 +248,8 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
private createEditor(host: HTMLDivElement): void { private createEditor(host: HTMLDivElement): void {
this.zone.runOutsideAngular(() => { this.zone.runOutsideAngular(() => {
const language = this.language();
this.editorView = new EditorView({ this.editorView = new EditorView({
state: EditorState.create({ state: EditorState.create({
doc: this.value(), doc: this.value(),
@@ -180,7 +257,8 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
basicSetup, basicSetup,
keymap.of([indentWithTab]), keymap.of([indentWithTab]),
indentUnit.of(' '), indentUnit.of(' '),
json(), ...getLanguageExtensions(language),
lintGutter(),
oneDark, oneDark,
THEME_JSON_EDITOR_THEME, THEME_JSON_EDITOR_THEME,
EditorState.tabSize.of(2), EditorState.tabSize.of(2),
@@ -188,7 +266,25 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
spellcheck: 'false', spellcheck: 'false',
autocapitalize: 'off', autocapitalize: 'off',
autocorrect: 'off', autocorrect: 'off',
'aria-label': 'Theme JSON editor' 'aria-label': language === 'css' ? 'Theme CSS editor' : 'Theme JSON editor'
}),
EditorView.domEventHandlers({
paste: (event, view) => {
if (language !== 'json') {
return false;
}
const pastedText = event.clipboardData?.getData('application/json') || event.clipboardData?.getData('text/plain') || '';
const formattedText = formatPastedJsonText(pastedText);
if (!formattedText) {
return false;
}
event.preventDefault();
view.dispatch(view.state.replaceSelection(formattedText));
return true;
}
}), }),
EditorView.updateListener.of((update) => { EditorView.updateListener.of((update) => {
if (!update.docChanged || this.isApplyingExternalValue) { if (!update.docChanged || this.isApplyingExternalValue) {

View File

@@ -0,0 +1,24 @@
import { formatPastedJsonText } from './theme-json-format.logic';
describe('theme JSON paste formatting', () => {
it('formats pasted JSON with two-space indentation', () => {
expect(formatPastedJsonText('{"meta":{"name":"Paste","version":"1"},"tokens":{"colors":{"background":"1 2% 3%"}}}')).toBe([
'{',
' "meta": {',
' "name": "Paste",',
' "version": "1"',
' },',
' "tokens": {',
' "colors": {',
' "background": "1 2% 3%"',
' }',
' }',
'}'
].join('\n'));
});
it('leaves non-JSON paste content to the editor default', () => {
expect(formatPastedJsonText('backgroundColor: red')).toBeNull();
expect(formatPastedJsonText('')).toBeNull();
});
});

View File

@@ -0,0 +1,13 @@
export function formatPastedJsonText(text: string): string | null {
const trimmedText = text.trim();
if (!trimmedText) {
return null;
}
try {
return JSON.stringify(JSON.parse(trimmedText) as unknown, null, 2);
} catch {
return null;
}
}

View File

@@ -26,6 +26,13 @@
> >
Format JSON Format JSON
</button> </button>
<button
type="button"
(click)="jumpToCss()"
class="inline-flex items-center rounded-md border border-border bg-secondary px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
>
Open CSS
</button>
<button <button
type="button" type="button"
(click)="copyLlmThemeGuide()" (click)="copyLlmThemeGuide()"
@@ -33,13 +40,35 @@
> >
Copy LLM Guide Copy LLM Guide
</button> </button>
<button
type="button"
(click)="themeFileInput.click()"
class="inline-flex items-center rounded-md border border-border bg-secondary px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
>
Import File
</button>
<button
type="button"
(click)="exportThemeFile()"
[disabled]="!draftIsValid()"
class="inline-flex items-center rounded-md border border-border bg-secondary px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-60"
>
Export File
</button>
<input
#themeFileInput
type="file"
accept=".json,.css,application/json,text/css"
class="hidden"
(change)="importThemeFile($event)"
/>
<button <button
type="button" type="button"
(click)="applyDraft()" (click)="applyDraft()"
[disabled]="!draftIsValid()" [disabled]="!draftIsValid()"
class="inline-flex items-center rounded-md bg-primary px-4 py-1.5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60" class="inline-flex items-center rounded-md bg-primary px-4 py-1.5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
> >
Apply Draft {{ activeEditorTab() === 'cssOnly' ? 'Apply CSS Theme' : 'Apply Draft' }}
</button> </button>
<button <button
type="button" type="button"
@@ -59,24 +88,8 @@
<div class="theme-settings__hero-grid mt-4"> <div class="theme-settings__hero-grid mt-4">
<div class="theme-settings__hero-stat"> <div class="theme-settings__hero-stat">
<div class="theme-settings__workspace-selector theme-settings__workspace-selector--compact"> <span class="theme-settings__hero-label">Workspace</span>
<label <strong class="theme-settings__hero-value">{{ activeWorkspaceInfo().label }}</strong>
for="theme-studio-workspace-select"
class="theme-settings__workspace-selector-label"
>
Workspace
</label>
<select
id="theme-studio-workspace-select"
class="theme-settings__workspace-select"
[value]="activeWorkspace()"
(change)="onWorkspaceSelect($event)"
>
@for (workspace of workspaceTabs; track workspace.key) {
<option [value]="workspace.key">{{ workspace.label }}</option>
}
</select>
</div>
</div> </div>
<div class="theme-settings__hero-stat"> <div class="theme-settings__hero-stat">
<span class="theme-settings__hero-label">Regions</span> <span class="theme-settings__hero-label">Regions</span>
@@ -110,6 +123,43 @@
</ul> </ul>
</div> </div>
} }
<nav
class="theme-settings__workspace-tabs mt-4"
aria-label="Theme Studio workspace"
>
@for (workspace of workspaceTabs; track workspace.key) {
<button
type="button"
(click)="setWorkspace(workspace.key)"
class="theme-settings__workspace-tab"
[class.theme-settings__workspace-tab--active]="activeWorkspace() === workspace.key"
[attr.aria-current]="activeWorkspace() === workspace.key ? 'page' : null"
>
<span class="theme-settings__workspace-tab-label">{{ workspace.label }}</span>
<span class="theme-settings__workspace-tab-description">{{ workspace.description }}</span>
</button>
}
</nav>
<div class="theme-settings__workspace-selector theme-settings__workspace-selector--mobile mt-4">
<label
for="theme-studio-workspace-select"
class="theme-settings__workspace-selector-label"
>
Workspace
</label>
<select
id="theme-studio-workspace-select"
class="theme-settings__workspace-select"
[value]="activeWorkspace()"
(change)="onWorkspaceSelect($event)"
>
@for (workspace of workspaceTabs; track workspace.key) {
<option [value]="workspace.key">{{ workspace.label }}</option>
}
</select>
</div>
</section> </section>
<div class="theme-settings__workspace min-h-0 flex-1"> <div class="theme-settings__workspace min-h-0 flex-1">
@@ -222,7 +272,7 @@
</section> </section>
} }
<section class="theme-studio-card p-3.5"> <section class="theme-studio-card theme-settings__explorer-card p-3.5">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<p class="text-sm font-semibold text-foreground">Explorer</p> <p class="text-sm font-semibold text-foreground">Explorer</p>
<span class="rounded-full bg-secondary px-2.5 py-1 text-[11px] font-medium text-muted-foreground"> <span class="rounded-full bg-secondary px-2.5 py-1 text-[11px] font-medium text-muted-foreground">
@@ -240,7 +290,7 @@
/> />
</div> </div>
<div class="theme-settings__entry-list mt-4"> <div class="theme-settings__entry-list theme-settings__explorer-list mt-4">
@for (entry of filteredEntries(); track entry.key) { @for (entry of filteredEntries(); track entry.key) {
<button <button
type="button" type="button"
@@ -318,23 +368,70 @@
@if (activeWorkspace() === 'editor') { @if (activeWorkspace() === 'editor') {
<section class="theme-studio-card theme-settings__editor-card p-4"> <section class="theme-studio-card theme-settings__editor-card p-4">
<div class="flex flex-wrap items-start justify-between gap-4 border-b border-border pb-3"> <div class="flex flex-wrap items-start justify-between gap-4 border-b border-border pb-3">
<p class="text-sm font-semibold text-foreground">Theme JSON</p> <div class="min-w-0">
<p class="text-sm font-semibold text-foreground">
{{ activeEditorTab() === 'cssOnly' ? 'CSS-Only Theme' : 'Theme JSON' }}
</p>
@if (activeEditorTab() === 'cssOnly') {
<p class="mt-1 text-xs leading-5 text-muted-foreground">CSS here is applied over the built-in default JSON theme.</p>
}
</div>
<div class="flex flex-wrap gap-2 text-[11px] font-medium text-muted-foreground"> <div class="flex flex-wrap gap-2 text-[11px] font-medium text-muted-foreground">
<span class="rounded-md bg-secondary px-2.5 py-1">{{ draftLineCount() }} lines</span> <span class="rounded-md bg-secondary px-2.5 py-1"
<span class="rounded-md bg-secondary px-2.5 py-1">{{ draftCharacterCount() }} chars</span> >{{ activeEditorTab() === 'cssOnly' ? cssOnlyText().split('\n').length : draftLineCount() }} lines</span
>
<span class="rounded-md bg-secondary px-2.5 py-1"
>{{ activeEditorTab() === 'cssOnly' ? cssOnlyText().length : draftCharacterCount() }} chars</span
>
@if (activeEditorTab() === 'json') {
<span class="rounded-md bg-secondary px-2.5 py-1">{{ draftErrorCount() }} errors</span> <span class="rounded-md bg-secondary px-2.5 py-1">{{ draftErrorCount() }} errors</span>
}
<span class="rounded-md bg-slate-900 px-2.5 py-1 text-slate-200">IDE editor</span> <span class="rounded-md bg-slate-900 px-2.5 py-1 text-slate-200">IDE editor</span>
</div> </div>
</div> </div>
<div class="mt-3 flex flex-wrap gap-2">
<button
type="button"
(click)="setEditorTab('json')"
class="rounded-md border border-border bg-secondary px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
[class.border-primary/40]="activeEditorTab() === 'json'"
[class.bg-primary/10]="activeEditorTab() === 'json'"
[attr.aria-current]="activeEditorTab() === 'json' ? 'page' : null"
>
JSON Theme
</button>
<button
type="button"
(click)="setEditorTab('cssOnly')"
class="rounded-md border border-border bg-secondary px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
[class.border-primary/40]="activeEditorTab() === 'cssOnly'"
[class.bg-primary/10]="activeEditorTab() === 'cssOnly'"
[attr.aria-current]="activeEditorTab() === 'cssOnly' ? 'page' : null"
>
CSS Only
</button>
</div>
<div class="theme-settings__editor-panel pt-3"> <div class="theme-settings__editor-panel pt-3">
@if (activeEditorTab() === 'json') {
<app-theme-json-code-editor <app-theme-json-code-editor
#jsonEditorRef #jsonEditorRef
[value]="draftText()" [value]="draftText()"
[fullscreen]="isFullscreen()" [fullscreen]="isFullscreen()"
language="json"
(valueChange)="onDraftEditorValueChange($event)" (valueChange)="onDraftEditorValueChange($event)"
/> />
} @else {
<app-theme-json-code-editor
#jsonEditorRef
[value]="cssOnlyText()"
[fullscreen]="isFullscreen()"
language="css"
(valueChange)="onCssOnlyEditorValueChange($event)"
/>
}
</div> </div>
</section> </section>
} }
@@ -370,7 +467,7 @@
<section class="theme-studio-card p-4"> <section class="theme-studio-card p-4">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<p class="text-sm font-semibold text-foreground">Schema Hints</p> <p class="text-sm font-semibold text-foreground">Editable Attributes</p>
<button <button
type="button" type="button"
@@ -468,7 +565,7 @@
<div class="mt-5"> <div class="mt-5">
<app-theme-grid-editor <app-theme-grid-editor
[container]="selectedContainer() === 'appShell' ? layoutContainers[0] : layoutContainers[1]" [container]="selectedLayoutContainer()"
[items]="selectedContainerItems()" [items]="selectedContainerItems()"
[selectedKey]="selectedElementKey()" [selectedKey]="selectedElementKey()"
[disabled]="!draftIsValid()" [disabled]="!draftIsValid()"

View File

@@ -18,6 +18,54 @@
gap: 0.35rem; gap: 0.35rem;
} }
.theme-settings__workspace-selector--mobile {
display: none;
}
.theme-settings__workspace-tabs {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
}
.theme-settings__workspace-tab {
min-height: 5rem;
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
background: hsl(var(--secondary) / 0.22);
padding: 0.8rem 0.9rem;
text-align: left;
transition:
border-color 160ms ease,
background-color 160ms ease,
box-shadow 160ms ease;
}
.theme-settings__workspace-tab:hover {
background: hsl(var(--secondary) / 0.42);
}
.theme-settings__workspace-tab--active {
border-color: hsl(var(--primary) / 0.45);
background: hsl(var(--primary) / 0.1);
box-shadow: inset 0 0 0 1px hsl(var(--primary) / 0.1);
}
.theme-settings__workspace-tab-label {
display: block;
font-size: 0.85rem;
font-weight: 700;
color: hsl(var(--foreground));
}
.theme-settings__workspace-tab-description {
display: block;
margin-top: 0.35rem;
font-size: 0.74rem;
line-height: 1.45;
color: hsl(var(--muted-foreground));
}
.theme-settings__workspace-selector-label { .theme-settings__workspace-selector-label {
font-size: 0.69rem; font-size: 0.69rem;
font-weight: 700; font-weight: 700;
@@ -52,6 +100,29 @@
font-size: 0.84rem; font-size: 0.84rem;
} }
.theme-settings__workspace {
display: grid;
align-items: stretch;
grid-template-columns: minmax(17rem, 22rem) minmax(0, 1fr);
gap: 0.75rem;
}
.theme-settings__sidebar {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
gap: 0.75rem;
}
.theme-settings__main {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
gap: 0.75rem;
}
.theme-settings__editor-card { .theme-settings__editor-card {
display: flex; display: flex;
min-height: 0; min-height: 0;
@@ -92,6 +163,22 @@
background: hsl(var(--primary) / 0.08); background: hsl(var(--primary) / 0.08);
} }
.theme-settings__explorer-card {
display: flex;
flex: 1 1 auto;
height: 100%;
min-height: 0;
flex-direction: column;
}
.theme-settings__explorer-list {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding-right: 0.25rem;
}
.theme-json-editor-panel__header { .theme-json-editor-panel__header {
min-width: 0; min-width: 0;
} }
@@ -114,3 +201,22 @@
min-height: 0; min-height: 0;
flex: 1 1 auto; flex: 1 1 auto;
} }
@media (max-width: 780px) {
.theme-settings__workspace-tabs {
display: none;
}
.theme-settings__workspace-selector--mobile {
display: flex;
}
.theme-settings__workspace {
display: flex;
flex-direction: column;
}
.theme-settings__explorer-card {
max-height: min(28rem, calc(100vh - 10rem));
}
}

View File

@@ -9,6 +9,7 @@ import {
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { SettingsModalService } from '../../../../../core/services/settings-modal.service'; import { SettingsModalService } from '../../../../../core/services/settings-modal.service';
import { getElectronApi } from '../../../../../core/platform/electron/get-electron-api';
import { import {
ThemeContainerKey, ThemeContainerKey,
ThemeElementStyleProperty, ThemeElementStyleProperty,
@@ -29,7 +30,8 @@ import { THEME_LLM_GUIDE } from '../../../domain/constants/theme-llm-guide.const
import { ThemeGridEditorComponent } from '../theme-grid-editor/theme-grid-editor.component'; import { ThemeGridEditorComponent } from '../theme-grid-editor/theme-grid-editor.component';
import { ThemeJsonCodeEditorComponent } from '../theme-json-code-editor/theme-json-code-editor.component'; import { ThemeJsonCodeEditorComponent } from '../theme-json-code-editor/theme-json-code-editor.component';
type JumpSection = 'elements' | 'layout' | 'animations'; type JumpSection = 'elements' | 'layout' | 'animations' | 'css';
type ThemeEditorTab = 'json' | 'cssOnly';
type ThemeStudioWorkspace = 'editor' | 'inspector' | 'layout'; type ThemeStudioWorkspace = 'editor' | 'inspector' | 'layout';
@Component({ @Component({
@@ -59,6 +61,7 @@ export class ThemeSettingsComponent {
readonly statusMessage = this.theme.statusMessage; readonly statusMessage = this.theme.statusMessage;
readonly isDraftDirty = this.theme.isDraftDirty; readonly isDraftDirty = this.theme.isDraftDirty;
readonly isFullscreen = this.modal.themeStudioFullscreen; readonly isFullscreen = this.modal.themeStudioFullscreen;
readonly activeTheme = this.theme.activeTheme;
readonly draftTheme = this.theme.draftTheme; readonly draftTheme = this.theme.draftTheme;
readonly THEME_ANIMATION_FIELDS = THEME_ANIMATION_FIELD_HINTS; readonly THEME_ANIMATION_FIELDS = THEME_ANIMATION_FIELD_HINTS;
readonly animationKeys = this.theme.knownAnimationClasses; readonly animationKeys = this.theme.knownAnimationClasses;
@@ -83,6 +86,8 @@ export class ThemeSettingsComponent {
]; ];
readonly mountedKeyCounts = computed(() => this.registry.mountedKeyCounts()); readonly mountedKeyCounts = computed(() => this.registry.mountedKeyCounts());
readonly activeWorkspace = signal<ThemeStudioWorkspace>('editor'); readonly activeWorkspace = signal<ThemeStudioWorkspace>('editor');
readonly activeEditorTab = signal<ThemeEditorTab>('json');
readonly cssOnlyText = signal('');
readonly explorerQuery = signal(''); readonly explorerQuery = signal('');
readonly selectedContainer = signal<ThemeContainerKey>('roomLayout'); readonly selectedContainer = signal<ThemeContainerKey>('roomLayout');
@@ -103,9 +108,15 @@ export class ThemeSettingsComponent {
].filter((value): value is string => value !== null); ].filter((value): value is string => value !== null);
}); });
readonly selectedContainerItems = computed(() => this.layoutSync.itemsForContainer(this.selectedContainer())); readonly selectedContainerItems = computed(() => this.layoutSync.itemsForContainer(this.selectedContainer()));
readonly selectedLayoutContainer = computed(() => {
return this.layoutContainers.find((container) => container.key === this.selectedContainer()) ?? this.layoutContainers[0];
});
readonly selectedElementGrid = computed(() => { readonly selectedElementGrid = computed(() => {
return this.selectedContainerItems().find((item) => item.key === this.selectedElementKey()) ?? null; return this.selectedContainerItems().find((item) => item.key === this.selectedElementKey()) ?? null;
}); });
readonly activeWorkspaceInfo = computed(() => {
return this.workspaceTabs.find((workspace) => workspace.key === this.activeWorkspace()) ?? this.workspaceTabs[0];
});
readonly visiblePropertyHints = computed(() => { readonly visiblePropertyHints = computed(() => {
const selected = this.selectedElement(); const selected = this.selectedElement();
@@ -156,6 +167,8 @@ export class ThemeSettingsComponent {
private llmGuideCopyTimeoutId: ReturnType<typeof setTimeout> | null = null; private llmGuideCopyTimeoutId: ReturnType<typeof setTimeout> | null = null;
constructor() { constructor() {
this.syncCssOnlyTextFromTheme();
if (this.savedThemesAvailable()) { if (this.savedThemesAvailable()) {
void this.themeLibrary.refresh(); void this.themeLibrary.refresh();
} }
@@ -167,8 +180,16 @@ export class ThemeSettingsComponent {
return; return;
} }
this.activeWorkspace.set('inspector'); this.openPickedElementInJson(pickedKey);
this.selectThemeEntry(pickedKey, 'elements'); this.picker.clearSelection();
});
effect(() => {
if (this.activeEditorTab() !== 'json') {
return;
}
this.cssOnlyText.set(this.draftTheme().css);
}); });
effect(() => { effect(() => {
@@ -186,10 +207,35 @@ export class ThemeSettingsComponent {
this.theme.updateDraftText(value); this.theme.updateDraftText(value);
} }
onCssOnlyEditorValueChange(value: string): void {
this.cssOnlyText.set(value);
}
applyDraft(): void { applyDraft(): void {
if (this.activeEditorTab() === 'cssOnly') {
this.applyCssOnlyTheme();
return;
}
this.theme.applyDraft(); this.theme.applyDraft();
} }
applyCssOnlyTheme(): void {
this.theme.applyCssOnlyTheme(this.cssOnlyText());
}
setEditorTab(tab: ThemeEditorTab): void {
this.activeEditorTab.set(tab);
if (tab === 'json') {
this.focusEditor();
return;
}
this.syncCssOnlyTextFromTheme();
this.focusEditor();
}
setWorkspace(workspace: ThemeStudioWorkspace): void { setWorkspace(workspace: ThemeStudioWorkspace): void {
this.activeWorkspace.set(workspace); this.activeWorkspace.set(workspace);
@@ -202,6 +248,7 @@ export class ThemeSettingsComponent {
} }
if (workspace === 'editor') { if (workspace === 'editor') {
this.activeEditorTab.set('json');
this.focusEditor(); this.focusEditor();
} }
} }
@@ -223,12 +270,36 @@ export class ThemeSettingsComponent {
this.focusEditor(); this.focusEditor();
} }
async exportThemeFile(): Promise<void> {
const exportText = this.getExportThemeText();
if (!exportText) {
return;
}
const fileName = `${this.sanitizeThemeFileName(this.draftTheme().meta.name)}.json`;
const saved = await this.saveTextAsFile(fileName, exportText);
this.theme.announceStatus(saved ? `${fileName} exported.` : 'Theme export cancelled.');
}
importThemeFile(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0] ?? null;
input.value = '';
if (!file) {
return;
}
void this.loadThemeFile(file);
}
async copyLlmThemeGuide(): Promise<void> { async copyLlmThemeGuide(): Promise<void> {
const copied = await this.copyTextToClipboard(THEME_LLM_GUIDE); const copied = await this.copyTextToClipboard(THEME_LLM_GUIDE);
this.setLlmGuideCopyMessage(copied this.setLlmGuideCopyMessage(copied ? 'LLM guide copied.' : 'Manual copy opened.');
? 'LLM guide copied.'
: 'Manual copy opened.');
} }
startPicker(): void { startPicker(): void {
@@ -285,9 +356,10 @@ export class ThemeSettingsComponent {
restoreDefaultTheme(): void { restoreDefaultTheme(): void {
this.theme.resetToDefault('button'); this.theme.resetToDefault('button');
this.activeWorkspace.set('editor'); this.activeWorkspace.set('editor');
this.activeEditorTab.set('json');
this.selectedContainer.set('roomLayout'); this.selectedContainer.set('roomLayout');
this.selectedElementKey.set('chatRoomMainPanel'); this.selectedElementKey.set('chatRoomMainPanel');
this.focusJsonAnchor('elements', 'chatRoomMainPanel'); this.focusEditor();
} }
selectThemeEntry(key: string, section: JumpSection = 'elements'): void { selectThemeEntry(key: string, section: JumpSection = 'elements'): void {
@@ -299,7 +371,7 @@ export class ThemeSettingsComponent {
if (section === 'layout') { if (section === 'layout') {
this.activeWorkspace.set('layout'); this.activeWorkspace.set('layout');
} else if (section === 'animations') { } else if (section === 'animations' || section === 'css') {
this.activeWorkspace.set('editor'); this.activeWorkspace.set('editor');
} else { } else {
this.activeWorkspace.set('inspector'); this.activeWorkspace.set('inspector');
@@ -331,6 +403,9 @@ export class ThemeSettingsComponent {
const value = getSuggestedFieldDefault(property, this.animationKeys()); const value = getSuggestedFieldDefault(property, this.animationKeys());
this.theme.setElementStyle(this.selectedElementKey(), property, value); this.theme.setElementStyle(this.selectedElementKey(), property, value);
this.activeWorkspace.set('editor');
this.activeEditorTab.set('json');
this.focusJsonProperty('elements', this.selectedElementKey(), property);
} }
addStarterAnimation(): void { addStarterAnimation(): void {
@@ -355,37 +430,200 @@ export class ThemeSettingsComponent {
jumpToLayout(): void { jumpToLayout(): void {
this.activeWorkspace.set('editor'); this.activeWorkspace.set('editor');
this.activeEditorTab.set('json');
this.focusJsonAnchor('layout', this.selectedElementKey()); this.focusJsonAnchor('layout', this.selectedElementKey());
} }
jumpToStyles(): void { jumpToStyles(): void {
this.activeWorkspace.set('editor'); this.activeWorkspace.set('editor');
this.activeEditorTab.set('json');
this.focusJsonAnchor('elements', this.selectedElementKey()); this.focusJsonAnchor('elements', this.selectedElementKey());
} }
jumpToAnimation(animationKey: string): void { jumpToAnimation(animationKey: string): void {
this.activeWorkspace.set('editor'); this.activeWorkspace.set('editor');
this.activeEditorTab.set('json');
this.focusJsonAnchor('animations', animationKey); this.focusJsonAnchor('animations', animationKey);
} }
jumpToCss(): void {
this.activeWorkspace.set('editor');
this.activeEditorTab.set('cssOnly');
this.syncCssOnlyTextFromTheme();
this.focusEditor();
}
isMounted(entry: ThemeRegistryEntry): boolean { isMounted(entry: ThemeRegistryEntry): boolean {
return (this.mountedKeyCounts()[entry.key] ?? 0) > 0; return (this.mountedKeyCounts()[entry.key] ?? 0) > 0;
} }
private focusEditor(): void { private focusEditor(): void {
this.withEditorReady((editor) => {
editor.focus();
});
}
private withEditorReady(action: (editor: ThemeJsonCodeEditorComponent) => void, attempts = 8): void {
queueMicrotask(() => { queueMicrotask(() => {
this.editorRef()?.focus(); const editor = this.editorRef();
if (editor) {
action(editor);
return;
}
if (attempts <= 0) {
return;
}
setTimeout(() => {
this.withEditorReady(action, attempts - 1);
});
});
}
private syncCssOnlyTextFromTheme(): void {
this.cssOnlyText.set(this.activeTheme().css || this.draftTheme().css);
}
private getExportThemeText(): string | null {
if (this.activeEditorTab() === 'cssOnly') {
return this.theme.buildDraftTextWithCss(this.cssOnlyText());
}
if (!this.draftIsValid()) {
this.theme.announceStatus('Fix JSON errors before exporting the theme.');
return null;
}
return this.draftText();
}
private async loadThemeFile(file: File): Promise<void> {
try {
const text = await file.text();
if (file.name.toLowerCase().endsWith('.css')) {
this.activeWorkspace.set('editor');
this.activeEditorTab.set('cssOnly');
this.cssOnlyText.set(text);
this.theme.announceStatus(`${file.name} loaded into the CSS editor.`);
this.focusEditor();
return;
}
const loaded = this.theme.loadThemeText(text, 'draft', `${file.name} imported into the draft editor.`, 'imported theme file');
if (!loaded) {
return;
}
this.activeWorkspace.set('editor');
this.activeEditorTab.set('json');
this.syncCssOnlyTextFromTheme();
this.focusEditor();
} catch {
this.theme.announceStatus(`Unable to import ${file.name}.`);
}
}
private async saveTextAsFile(fileName: string, text: string): Promise<boolean> {
const electronApi = getElectronApi();
if (electronApi) {
const result = await electronApi.saveFileAs(fileName, this.encodeBase64(text));
return result.saved;
}
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
const link = document.createElement('a');
link.href = url;
link.download = fileName;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
return true;
}
private encodeBase64(value: string): string {
const bytes = new TextEncoder().encode(value);
let binary = '';
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
private sanitizeThemeFileName(value: string): string {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return normalized.length > 0 ? normalized : 'theme';
}
private openPickedElementInJson(key: string): void {
const definition = this.registry.getDefinition(key);
if (!definition) {
return;
}
this.selectedElementKey.set(key);
if (definition.container) {
this.selectedContainer.set(definition.container);
}
if (this.draftIsValid()) {
this.theme.ensureElementEntry(key);
}
this.activeWorkspace.set('editor');
this.activeEditorTab.set('json');
this.focusJsonElementEntry(key);
}
private focusJsonElementEntry(key: string): void {
this.withEditorReady((editor) => {
let text = this.draftText();
let anchorIndex = this.findAnchorIndex(text, 'elements', key);
if (anchorIndex === -1 && this.draftIsValid()) {
this.theme.ensureElementEntry(key);
text = this.draftText();
anchorIndex = this.findAnchorIndex(text, 'elements', key);
}
if (anchorIndex === -1) {
editor.focus();
return;
}
const colonIndex = text.indexOf(':', anchorIndex);
const objectStart = colonIndex === -1 ? -1 : text.indexOf('{', colonIndex);
if (objectStart === -1) {
editor.focusRange(anchorIndex, Math.min(anchorIndex + key.length + 2, text.length));
return;
}
editor.focusRange(objectStart + 1, objectStart + 1);
}); });
} }
private focusJsonAnchor(section: JumpSection, key: string): void { private focusJsonAnchor(section: JumpSection, key: string): void {
queueMicrotask(() => { this.withEditorReady((editor) => {
const editor = this.editorRef();
if (!editor) {
return;
}
let text = this.draftText(); let text = this.draftText();
let anchorIndex = this.findAnchorIndex(text, section, key); let anchorIndex = this.findAnchorIndex(text, section, key);
@@ -413,6 +651,94 @@ export class ThemeSettingsComponent {
}); });
} }
private focusJsonProperty(section: JumpSection, key: string, property: string): void {
this.withEditorReady((editor) => {
const text = this.draftText();
const keyIndex = this.findAnchorIndex(text, section, key);
if (keyIndex === -1) {
editor.focus();
return;
}
const propertyIndex = text.indexOf(`"${property}"`, keyIndex);
if (propertyIndex === -1) {
editor.focusRange(keyIndex, Math.min(keyIndex + key.length + 2, text.length));
return;
}
const valueRange = this.findJsonPropertyValueRange(text, propertyIndex);
editor.focusRange(valueRange.start, valueRange.end);
});
}
private findJsonPropertyValueRange(text: string, propertyIndex: number): { start: number; end: number } {
const colonIndex = text.indexOf(':', propertyIndex);
if (colonIndex === -1) {
return {
start: propertyIndex,
end: propertyIndex
};
}
let valueStart = colonIndex + 1;
while (valueStart < text.length && /\s/.test(text[valueStart])) {
valueStart += 1;
}
if (text[valueStart] === '"') {
const stringContentStart = valueStart + 1;
let stringContentEnd = stringContentStart;
while (stringContentEnd < text.length) {
if (text[stringContentEnd] === '"' && text[stringContentEnd - 1] !== '\\') {
break;
}
stringContentEnd += 1;
}
return {
start: stringContentStart,
end: stringContentEnd
};
}
let valueEnd = valueStart;
while (valueEnd < text.length && ![
',',
'\n',
'}'
].includes(text[valueEnd])) {
valueEnd += 1;
}
return {
start: valueStart,
end: Math.max(valueStart, valueEnd)
};
}
private focusJsonSection(section: JumpSection): void {
this.withEditorReady((editor) => {
const text = this.draftText();
const anchorIndex = text.indexOf(`"${section}"`);
if (anchorIndex === -1) {
editor.focus();
return;
}
editor.focusRange(anchorIndex, Math.min(anchorIndex + section.length + 2, text.length));
});
}
private async copyTextToClipboard(value: string): Promise<boolean> { private async copyTextToClipboard(value: string): Promise<boolean> {
if (navigator.clipboard?.writeText) { if (navigator.clipboard?.writeText) {
try { try {

View File

@@ -12,6 +12,7 @@ import { ExternalLinkService } from '../../../core/platform';
import { ElementPickerService } from '../application/services/element-picker.service'; import { ElementPickerService } from '../application/services/element-picker.service';
import { ThemeRegistryService } from '../application/services/theme-registry.service'; import { ThemeRegistryService } from '../application/services/theme-registry.service';
import { ThemeService } from '../application/services/theme.service'; import { ThemeService } from '../application/services/theme.service';
import { applyThemeStyleDeclaration } from './theme-style-application.logic';
function looksLikeImageReference(value: string): boolean { function looksLikeImageReference(value: string): boolean {
return value.startsWith('url(') return value.startsWith('url(')
@@ -96,8 +97,7 @@ export class ThemeNodeDirective implements OnDestroy {
this.clearAppliedStyles(); this.clearAppliedStyles();
for (const [styleKey, styleValue] of Object.entries(styles)) { for (const [styleKey, styleValue] of Object.entries(styles)) {
this.host.nativeElement.style.setProperty(styleKey, styleValue); this.appliedStyleKeys.add(applyThemeStyleDeclaration(this.host.nativeElement, styleKey, styleValue));
this.appliedStyleKeys.add(styleKey);
} }
} }

View File

@@ -0,0 +1,56 @@
import {
applyThemeStyleDeclaration,
toCssStylePropertyName
} from './theme-style-application.logic';
describe('theme style application', () => {
it('applies camelCase theme properties as real CSS declarations', () => {
const host = createHost();
expect(applyThemeStyleDeclaration(host, 'backgroundImage', 'url("/theme.png")')).toBe('background-image');
expect(applyThemeStyleDeclaration(host, 'borderRadius', '12px')).toBe('border-radius');
expect(applyThemeStyleDeclaration(host, 'boxShadow', '0 4px 20px rgba(0, 0, 0, 0.25)')).toBe('box-shadow');
expect(host.style.backgroundImage).toContain('/theme.png');
expect(host.style.borderRadius).toBe('12px');
expect(host.style.boxShadow).toBe('0 4px 20px rgba(0, 0, 0, 0.25)');
});
it('keeps CSS custom properties intact', () => {
const host = createHost();
expect(toCssStylePropertyName('--theme-effect-glass-blur')).toBe('--theme-effect-glass-blur');
applyThemeStyleDeclaration(host, '--theme-effect-glass-blur', 'blur(18px)');
expect(host.style.getPropertyValue('--theme-effect-glass-blur')).toBe('blur(18px)');
});
});
function createHost(): HTMLElement {
const values = new Map<string, string>();
const style = {
backgroundImage: '',
borderRadius: '',
boxShadow: '',
setProperty(propertyName: string, value: string) {
values.set(propertyName, value);
if (propertyName === 'background-image') {
this.backgroundImage = value;
}
if (propertyName === 'border-radius') {
this.borderRadius = value;
}
if (propertyName === 'box-shadow') {
this.boxShadow = value;
}
},
getPropertyValue(propertyName: string) {
return values.get(propertyName) ?? '';
}
};
return { style } as unknown as HTMLElement;
}

View File

@@ -0,0 +1,14 @@
export function toCssStylePropertyName(propertyName: string): string {
if (propertyName.startsWith('--')) {
return propertyName;
}
return propertyName.replace(/([A-Z])/g, '-$1').toLowerCase();
}
export function applyThemeStyleDeclaration(host: HTMLElement, propertyName: string, value: string): string {
const cssPropertyName = toCssStylePropertyName(propertyName);
host.style.setProperty(cssPropertyName, value);
return cssPropertyName;
}

View File

@@ -1,4 +1,7 @@
<div class="flex flex-col rounded-md border border-border bg-background px-3 py-2.5"> <div
appThemeNode="voiceControlsPanel"
class="flex flex-col rounded-md border border-border bg-background px-3 py-2.5"
>
<!-- Connection Error Banner --> <!-- Connection Error Banner -->
@if (showConnectionError()) { @if (showConnectionError()) {
<div class="mb-3 flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/10 p-2"> <div class="mb-3 flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/10 p-2">
@@ -15,7 +18,10 @@
} }
<!-- User Info --> <!-- User Info -->
<div class="relative flex items-center gap-3"> <div
appThemeNode="voiceControlsUserRow"
class="relative flex items-center gap-3"
>
<button <button
type="button" type="button"
class="flex items-center gap-3 flex-1 min-w-0 rounded-md px-1 py-0.5 hover:bg-secondary/60 transition-colors cursor-pointer" class="flex items-center gap-3 flex-1 min-w-0 rounded-md px-1 py-0.5 hover:bg-secondary/60 transition-colors cursor-pointer"
@@ -73,7 +79,10 @@
[attr.aria-hidden]="isConnected() ? null : 'true'" [attr.aria-hidden]="isConnected() ? null : 'true'"
> >
<div class="overflow-hidden"> <div class="overflow-hidden">
<div class="flex items-center justify-center gap-2"> <div
appThemeNode="voiceControlsButtons"
class="flex items-center justify-center gap-2"
>
<!-- Mute Toggle --> <!-- Mute Toggle -->
<button <button
type="button" type="button"

View File

@@ -28,6 +28,7 @@ import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../
import { VoiceActivityService, VoiceConnectionFacade } from '../../../../domains/voice-connection'; import { VoiceActivityService, VoiceConnectionFacade } from '../../../../domains/voice-connection';
import { PlaybackOptions, VoicePlaybackService } from '../../../../domains/voice-connection'; import { PlaybackOptions, VoicePlaybackService } from '../../../../domains/voice-connection';
import { ScreenShareFacade, ScreenShareQuality } from '../../../../domains/screen-share'; import { ScreenShareFacade, ScreenShareQuality } from '../../../../domains/screen-share';
import { ThemeNodeDirective } from '../../../theme';
import { UsersActions } from '../../../../store/users/users.actions'; import { UsersActions } from '../../../../store/users/users.actions';
import { selectCurrentUser } from '../../../../store/users/users.selectors'; import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors'; import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
@@ -52,7 +53,8 @@ interface AudioDevice {
NgIcon, NgIcon,
DebugConsoleComponent, DebugConsoleComponent,
ScreenShareQualityDialogComponent, ScreenShareQualityDialogComponent,
UserAvatarComponent UserAvatarComponent,
ThemeNodeDirective
], ],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
@@ -145,17 +147,11 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
const devices = await navigator.mediaDevices.enumerateDevices(); const devices = await navigator.mediaDevices.enumerateDevices();
this.inputDevices.set( this.inputDevices.set(
devices devices.filter((device) => device.kind === 'audioinput').map((device) => ({ deviceId: device.deviceId, label: device.label }))
.filter((device) => device.kind === 'audioinput')
.map((device) => ({ deviceId: device.deviceId,
label: device.label }))
); );
this.outputDevices.set( this.outputDevices.set(
devices devices.filter((device) => device.kind === 'audiooutput').map((device) => ({ deviceId: device.deviceId, label: device.label }))
.filter((device) => device.kind === 'audiooutput')
.map((device) => ({ deviceId: device.deviceId,
label: device.label }))
); );
} catch (_error) {} } catch (_error) {}
} }
@@ -238,9 +234,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
this.webrtcService.clearConnectionError(); this.webrtcService.clearConnectionError();
this.saveSettings(); this.saveSettings();
} catch (error) { } catch (error) {
const message = error instanceof Error const message = error instanceof Error ? error.message : 'Failed to connect voice session.';
? error.message
: 'Failed to connect voice session.';
this.webrtcService.reportConnectionError(message); this.webrtcService.reportConnectionError(message);
} }

View File

@@ -1,6 +1,9 @@
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/cyclomatic-complexity --> <!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/cyclomatic-complexity -->
<aside class="flex h-full min-h-0 flex-col bg-card"> <aside class="flex h-full min-h-0 flex-col bg-card">
<div class="border-b border-border px-3 py-3"> <div
appThemeNode="roomPanelHeader"
class="border-b border-border px-3 py-3"
>
@if (panelMode() === 'channels') { @if (panelMode() === 'channels') {
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="grid h-9 w-9 place-items-center rounded-md bg-secondary text-sm font-semibold text-foreground"> <div class="grid h-9 w-9 place-items-center rounded-md bg-secondary text-sm font-semibold text-foreground">
@@ -33,7 +36,10 @@
@if (panelMode() === 'channels') { @if (panelMode() === 'channels') {
<div class="flex-1 overflow-auto"> <div class="flex-1 overflow-auto">
<!-- Text Channels --> <!-- Text Channels -->
<section class="px-2 py-3"> <section
appThemeNode="roomTextChannelsSection"
class="px-2 py-3"
>
<div class="mb-2 flex items-center justify-between px-1"> <div class="mb-2 flex items-center justify-between px-1">
<h4 class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">Text Channels</h4> <h4 class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">Text Channels</h4>
@if (canManageChannels()) { @if (canManageChannels()) {
@@ -52,6 +58,7 @@
<div class="space-y-1"> <div class="space-y-1">
@for (ch of textChannels(); track ch.id) { @for (ch of textChannels(); track ch.id) {
<button <button
appThemeNode="roomTextChannelItem"
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors" class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors"
[class.bg-secondary]="activeChannelId() === ch.id" [class.bg-secondary]="activeChannelId() === ch.id"
[class.text-foreground]="activeChannelId() === ch.id" [class.text-foreground]="activeChannelId() === ch.id"
@@ -94,7 +101,10 @@
</section> </section>
<!-- Voice Channels --> <!-- Voice Channels -->
<section class="border-t border-border px-2 py-3"> <section
appThemeNode="roomVoiceChannelsSection"
class="border-t border-border px-2 py-3"
>
<div class="mb-2 flex items-center justify-between px-1"> <div class="mb-2 flex items-center justify-between px-1">
<h4 class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">Voice Channels</h4> <h4 class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">Voice Channels</h4>
@if (canManageChannels()) { @if (canManageChannels()) {
@@ -125,6 +135,7 @@
(drop)="onVoiceChannelDrop($event, ch.id)" (drop)="onVoiceChannelDrop($event, ch.id)"
> >
<button <button
appThemeNode="roomVoiceChannelItem"
class="flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-secondary/60" class="flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-secondary/60"
(click)="joinVoice(ch.id)" (click)="joinVoice(ch.id)"
(contextmenu)="openChannelContextMenu($event, ch)" (contextmenu)="openChannelContextMenu($event, ch)"
@@ -174,6 +185,7 @@
> >
@for (u of voiceUsersInRoom(ch.id); track u.id) { @for (u of voiceUsersInRoom(ch.id); track u.id) {
<div <div
appThemeNode="roomVoiceUserItem"
class="flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-secondary/50" class="flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-secondary/50"
[class.cursor-pointer]="canDragVoiceUser(u)" [class.cursor-pointer]="canDragVoiceUser(u)"
[class.opacity-60]="draggedVoiceUserId() === (u.id || u.oderId)" [class.opacity-60]="draggedVoiceUserId() === (u.id || u.oderId)"

View File

@@ -37,6 +37,7 @@ import { MessagesActions } from '../../../store/messages/messages.actions';
import { RealtimeSessionFacade } from '../../../core/realtime'; import { RealtimeSessionFacade } from '../../../core/realtime';
import { ScreenShareFacade } from '../../../domains/screen-share'; import { ScreenShareFacade } from '../../../domains/screen-share';
import { NotificationsFacade } from '../../../domains/notifications'; import { NotificationsFacade } from '../../../domains/notifications';
import { ThemeNodeDirective } from '../../../domains/theme';
import { import {
VoiceActivityService, VoiceActivityService,
VoiceConnectionFacade, VoiceConnectionFacade,
@@ -82,7 +83,8 @@ type PanelMode = 'channels' | 'users';
ContextMenuComponent, ContextMenuComponent,
UserVolumeMenuComponent, UserVolumeMenuComponent,
UserAvatarComponent, UserAvatarComponent,
ConfirmDialogComponent ConfirmDialogComponent,
ThemeNodeDirective
], ],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
@@ -142,10 +144,8 @@ export class RoomsSidePanelComponent {
const memberIdentifiers = this.roomMemberIdentifiers(); const memberIdentifiers = this.roomMemberIdentifiers();
const roomId = this.currentRoom()?.id; const roomId = this.currentRoom()?.id;
return this.onlineUsers().filter((user) => return this.onlineUsers().filter(
!this.isCurrentUserIdentity(user) (user) => !this.isCurrentUserIdentity(user) && this.matchesIdentifiers(memberIdentifiers, user) && this.isUserPresentInRoom(user, roomId)
&& this.matchesIdentifiers(memberIdentifiers, user)
&& this.isUserPresentInRoom(user, roomId)
); );
}); });
offlineRoomMembers = computed(() => { offlineRoomMembers = computed(() => {
@@ -661,9 +661,7 @@ export class RoomsSidePanelComponent {
} }
private handleVoiceJoinFailure(error: unknown): void { private handleVoiceJoinFailure(error: unknown): void {
const message = error instanceof Error const message = error instanceof Error ? error.message : 'Failed to join voice channel.';
? error.message
: 'Failed to join voice channel.';
this.voiceConnection.reportConnectionError(message); this.voiceConnection.reportConnectionError(message);
} }

View File

@@ -1,6 +1,7 @@
<nav class="relative flex h-full w-full flex-col items-center gap-2 border-r border-border bg-secondary/35 px-2 py-3"> <nav class="relative flex h-full w-full flex-col items-center gap-2 border-r border-border bg-secondary/35 px-2 py-3">
<!-- Create button --> <!-- Create button -->
<button <button
appThemeNode="serversRailCreateButton"
type="button" type="button"
class="flex h-10 w-10 items-center justify-center rounded-md bg-primary text-primary-foreground transition-colors hover:bg-primary/90" class="flex h-10 w-10 items-center justify-center rounded-md bg-primary text-primary-foreground transition-colors hover:bg-primary/90"
title="Create Server" title="Create Server"
@@ -17,7 +18,10 @@
} }
<!-- Saved servers icons --> <!-- Saved servers icons -->
<div class="no-scrollbar mt-2 flex w-full flex-1 flex-col items-center gap-2 overflow-y-auto pt-0.5"> <div
appThemeNode="serversRailList"
class="no-scrollbar mt-2 flex w-full flex-1 flex-col items-center gap-2 overflow-y-auto pt-0.5"
>
@for (room of visibleSavedRooms(); track room.id) { @for (room of visibleSavedRooms(); track room.id) {
<div class="group/server relative flex w-full justify-center"> <div class="group/server relative flex w-full justify-center">
<span <span
@@ -27,6 +31,7 @@
></span> ></span>
<button <button
appThemeNode="serversRailItem"
type="button" type="button"
class="relative z-10 flex h-10 w-10 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent transition-[border-radius,box-shadow,background-color] duration-100 hover:rounded-lg hover:bg-card" class="relative z-10 flex h-10 w-10 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent transition-[border-radius,box-shadow,background-color] duration-100 hover:rounded-lg hover:bg-card"
[ngClass]="isSelectedRoom(room) ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10' : 'rounded-xl bg-card'" [ngClass]="isSelectedRoom(room) ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10' : 'rounded-xl bg-card'"

View File

@@ -36,6 +36,7 @@ import { RoomsActions } from '../../../store/rooms/rooms.actions';
import { DatabaseService } from '../../../infrastructure/persistence'; import { DatabaseService } from '../../../infrastructure/persistence';
import { NotificationsFacade } from '../../../domains/notifications'; import { NotificationsFacade } from '../../../domains/notifications';
import { type ServerInfo, ServerDirectoryFacade } from '../../../domains/server-directory'; import { type ServerInfo, ServerDirectoryFacade } from '../../../domains/server-directory';
import { ThemeNodeDirective } from '../../../domains/theme';
import { hasRoomBanForUser } from '../../../domains/access-control'; import { hasRoomBanForUser } from '../../../domains/access-control';
import { import {
ConfirmDialogComponent, ConfirmDialogComponent,
@@ -54,6 +55,7 @@ import {
ContextMenuComponent, ContextMenuComponent,
LeaveServerDialogComponent, LeaveServerDialogComponent,
NgOptimizedImage, NgOptimizedImage,
ThemeNodeDirective,
UserBarComponent UserBarComponent
], ],
viewProviders: [provideIcons({ lucidePlus })], viewProviders: [provideIcons({ lucidePlus })],
@@ -221,8 +223,7 @@ export class ServersRailComponent {
return; return;
this.joinPasswordError.set(null); this.joinPasswordError.set(null);
this.savedRoomJoinRequests.next({ room, this.savedRoomJoinRequests.next({ room, password: this.joinPassword() });
password: this.joinPassword() });
} }
isRoomMarkedBanned(room: Room): boolean { isRoomMarkedBanned(room: Room): boolean {
@@ -264,10 +265,12 @@ export class ServersRailComponent {
const isCurrentRoom = this.currentRoom()?.id === ctx.id; const isCurrentRoom = this.currentRoom()?.id === ctx.id;
this.store.dispatch(RoomsActions.forgetRoom({ this.store.dispatch(
RoomsActions.forgetRoom({
roomId: ctx.id, roomId: ctx.id,
nextOwnerKey: result.nextOwnerKey nextOwnerKey: result.nextOwnerKey
})); })
);
if (isCurrentRoom) { if (isCurrentRoom) {
this.router.navigate(['/search']); this.router.navigate(['/search']);
@@ -354,8 +357,7 @@ export class ServersRailComponent {
this.prepareVoiceContext(room); this.prepareVoiceContext(room);
this.closePasswordDialog(); this.closePasswordDialog();
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false })); this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
this.store.dispatch(RoomsActions.viewServer({ room, this.store.dispatch(RoomsActions.viewServer({ room, skipBanCheck: true }));
skipBanCheck: true }));
} }
private requestJoinInBackground(room: Room, password?: string) { private requestJoinInBackground(room: Room, password?: string) {
@@ -377,13 +379,17 @@ export class ServersRailComponent {
return EMPTY; return EMPTY;
} }
return this.serverDirectory.requestJoin({ return this.serverDirectory
.requestJoin(
{
roomId: room.id, roomId: room.id,
userId: currentUserId, userId: currentUserId,
userPublicKey: currentUser?.oderId || currentUserId, userPublicKey: currentUser?.oderId || currentUserId,
displayName: currentUser?.displayName || 'Anonymous', displayName: currentUser?.displayName || 'Anonymous',
password: password?.trim() || undefined password: password?.trim() || undefined
}, joinTarget.selector) },
joinTarget.selector
)
.pipe( .pipe(
tap((response) => { tap((response) => {
this.closePasswordDialog(); this.closePasswordDialog();
@@ -447,22 +453,22 @@ export class ServersRailComponent {
const errorCode = serverError?.error?.errorCode; const errorCode = serverError?.error?.errorCode;
const status = serverError?.status; const status = serverError?.status;
return errorCode === 'SERVER_NOT_FOUND' return errorCode === 'SERVER_NOT_FOUND' || status === 0 || status === 404 || (typeof status === 'number' && status >= 500);
|| status === 0
|| status === 404
|| (typeof status === 'number' && status >= 500);
} }
private toRoomRefreshChanges(room: Room, server: ServerInfo, signalingUrl?: string): Partial<Room> { private toRoomRefreshChanges(room: Room, server: ServerInfo, signalingUrl?: string): Partial<Room> {
const resolvedSource = this.serverDirectory.normaliseRoomSignalSource({ const resolvedSource = this.serverDirectory.normaliseRoomSignalSource(
{
sourceId: server.sourceId ?? room.sourceId, sourceId: server.sourceId ?? room.sourceId,
sourceName: server.sourceName ?? room.sourceName, sourceName: server.sourceName ?? room.sourceName,
sourceUrl: server.sourceUrl ?? room.sourceUrl, sourceUrl: server.sourceUrl ?? room.sourceUrl,
signalingUrl, signalingUrl,
fallbackName: server.sourceName ?? room.sourceName ?? room.name fallbackName: server.sourceName ?? room.sourceName ?? room.name
}, { },
{
ensureEndpoint: true ensureEndpoint: true
}); }
);
return { return {
name: server.name, name: server.name,
@@ -472,15 +478,10 @@ export class ServersRailComponent {
userCount: server.userCount, userCount: server.userCount,
maxUsers: server.maxUsers, maxUsers: server.maxUsers,
hasPassword: hasPassword:
typeof server.hasPassword === 'boolean' typeof server.hasPassword === 'boolean' ? server.hasPassword : typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password,
? server.hasPassword
: (typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password),
isPrivate: server.isPrivate, isPrivate: server.isPrivate,
createdAt: server.createdAt, createdAt: server.createdAt,
channels: channels: Array.isArray(server.channels) && server.channels.length > 0 ? server.channels : room.channels,
Array.isArray(server.channels) && server.channels.length > 0
? server.channels
: room.channels,
...resolvedSource ...resolvedSource
}; };
} }
@@ -489,26 +490,33 @@ export class ServersRailComponent {
room: Room; room: Room;
selector: ReturnType<ServerDirectoryFacade['buildRoomSignalSelector']>; selector: ReturnType<ServerDirectoryFacade['buildRoomSignalSelector']>;
}> { }> {
let resolvedRoom = this.applyResolvedRoomSource(room, this.serverDirectory.normaliseRoomSignalSource({ let resolvedRoom = this.applyResolvedRoomSource(
room,
this.serverDirectory.normaliseRoomSignalSource(
{
sourceId: room.sourceId, sourceId: room.sourceId,
sourceName: room.sourceName, sourceName: room.sourceName,
sourceUrl: room.sourceUrl, sourceUrl: room.sourceUrl,
fallbackName: room.sourceName ?? room.name fallbackName: room.sourceName ?? room.name
}, { },
{
ensureEndpoint: !!room.sourceUrl ensureEndpoint: !!room.sourceUrl
})); }
let selector = this.serverDirectory.buildRoomSignalSelector({ )
);
let selector = this.serverDirectory.buildRoomSignalSelector(
{
sourceId: resolvedRoom.sourceId, sourceId: resolvedRoom.sourceId,
sourceName: resolvedRoom.sourceName, sourceName: resolvedRoom.sourceName,
sourceUrl: resolvedRoom.sourceUrl, sourceUrl: resolvedRoom.sourceUrl,
fallbackName: resolvedRoom.sourceName ?? resolvedRoom.name fallbackName: resolvedRoom.sourceName ?? resolvedRoom.name
}, { },
{
ensureEndpoint: !!resolvedRoom.sourceUrl ensureEndpoint: !!resolvedRoom.sourceUrl
}); }
);
const authoritativeServer = selector const authoritativeServer = selector ? await firstValueFrom(this.serverDirectory.getServer(room.id, selector)) : null;
? await firstValueFrom(this.serverDirectory.getServer(room.id, selector))
: null;
if (!authoritativeServer) { if (!authoritativeServer) {
return { return {
@@ -517,24 +525,30 @@ export class ServersRailComponent {
}; };
} }
const authoritativeSource = this.serverDirectory.normaliseRoomSignalSource({ const authoritativeSource = this.serverDirectory.normaliseRoomSignalSource(
{
sourceId: authoritativeServer.sourceId ?? resolvedRoom.sourceId, sourceId: authoritativeServer.sourceId ?? resolvedRoom.sourceId,
sourceName: authoritativeServer.sourceName ?? resolvedRoom.sourceName, sourceName: authoritativeServer.sourceName ?? resolvedRoom.sourceName,
sourceUrl: authoritativeServer.sourceUrl ?? resolvedRoom.sourceUrl, sourceUrl: authoritativeServer.sourceUrl ?? resolvedRoom.sourceUrl,
fallbackName: authoritativeServer.sourceName ?? resolvedRoom.sourceName ?? resolvedRoom.name fallbackName: authoritativeServer.sourceName ?? resolvedRoom.sourceName ?? resolvedRoom.name
}, { },
{
ensureEndpoint: !!(authoritativeServer.sourceUrl ?? resolvedRoom.sourceUrl) ensureEndpoint: !!(authoritativeServer.sourceUrl ?? resolvedRoom.sourceUrl)
}); }
);
resolvedRoom = this.applyResolvedRoomSource(resolvedRoom, authoritativeSource); resolvedRoom = this.applyResolvedRoomSource(resolvedRoom, authoritativeSource);
selector = this.serverDirectory.buildRoomSignalSelector({ selector = this.serverDirectory.buildRoomSignalSelector(
{
sourceId: resolvedRoom.sourceId, sourceId: resolvedRoom.sourceId,
sourceName: resolvedRoom.sourceName, sourceName: resolvedRoom.sourceName,
sourceUrl: resolvedRoom.sourceUrl, sourceUrl: resolvedRoom.sourceUrl,
fallbackName: resolvedRoom.sourceName ?? resolvedRoom.name fallbackName: resolvedRoom.sourceName ?? resolvedRoom.name
}, { },
{
ensureEndpoint: !!resolvedRoom.sourceUrl ensureEndpoint: !!resolvedRoom.sourceUrl
}); }
);
return { return {
room: resolvedRoom, room: resolvedRoom,
@@ -550,22 +564,20 @@ export class ServersRailComponent {
sourceUrl: source.sourceUrl sourceUrl: source.sourceUrl
}; };
if ( if (room.sourceId === nextRoom.sourceId && room.sourceName === nextRoom.sourceName && room.sourceUrl === nextRoom.sourceUrl) {
room.sourceId === nextRoom.sourceId
&& room.sourceName === nextRoom.sourceName
&& room.sourceUrl === nextRoom.sourceUrl
) {
return room; return room;
} }
this.store.dispatch(RoomsActions.updateRoom({ this.store.dispatch(
RoomsActions.updateRoom({
roomId: room.id, roomId: room.id,
changes: { changes: {
sourceId: nextRoom.sourceId, sourceId: nextRoom.sourceId,
sourceName: nextRoom.sourceName, sourceName: nextRoom.sourceName,
sourceUrl: nextRoom.sourceUrl sourceUrl: nextRoom.sourceUrl
} }
})); })
);
return nextRoom; return nextRoom;
} }

View File

@@ -16,6 +16,7 @@
<!-- Modal --> <!-- Modal -->
<div class="fixed inset-0 z-[91] flex items-center justify-center p-4 pointer-events-none"> <div class="fixed inset-0 z-[91] flex items-center justify-center p-4 pointer-events-none">
<div <div
appThemeNode="settingsModalSurface"
class="pointer-events-auto relative flex w-full max-w-5xl overflow-hidden rounded-lg border border-border bg-card shadow-lg transition-all duration-200" class="pointer-events-auto relative flex w-full max-w-5xl overflow-hidden rounded-lg border border-border bg-card shadow-lg transition-all duration-200"
style="height: min(720px, 88vh)" style="height: min(720px, 88vh)"
[class.scale-100]="animating()" [class.scale-100]="animating()"
@@ -30,7 +31,10 @@
tabindex="-1" tabindex="-1"
> >
<!-- Side Navigation --> <!-- Side Navigation -->
<nav class="flex w-56 flex-shrink-0 flex-col border-r border-border bg-card"> <nav
appThemeNode="settingsModalNav"
class="flex w-56 flex-shrink-0 flex-col border-r border-border bg-card"
>
<div class="border-b border-border px-3 py-3"> <div class="border-b border-border px-3 py-3">
<h2 class="text-lg font-semibold text-foreground">Settings</h2> <h2 class="text-lg font-semibold text-foreground">Settings</h2>
</div> </div>
@@ -116,7 +120,10 @@
<!-- Content --> <!-- Content -->
<div class="flex-1 flex flex-col min-w-0"> <div class="flex-1 flex flex-col min-w-0">
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between border-b border-border px-5 py-3 flex-shrink-0"> <div
appThemeNode="settingsModalHeader"
class="flex items-center justify-between border-b border-border px-5 py-3 flex-shrink-0"
>
<h3 class="text-lg font-semibold text-foreground"> <h3 class="text-lg font-semibold text-foreground">
@switch (activePage()) { @switch (activePage()) {
@case ('general') { @case ('general') {
@@ -169,7 +176,10 @@
</div> </div>
<!-- Scrollable Content Area --> <!-- Scrollable Content Area -->
<div class="flex-1 overflow-y-auto bg-background px-4 py-4 sm:px-5 sm:py-4"> <div
appThemeNode="settingsModalContent"
class="flex-1 overflow-y-auto bg-background px-4 py-4 sm:px-5 sm:py-4"
>
@switch (activePage()) { @switch (activePage()) {
@case ('general') { @case ('general') {
<app-general-settings /> <app-general-settings />

View File

@@ -45,7 +45,11 @@ import { PermissionsSettingsComponent } from './permissions-settings/permissions
import { DebuggingSettingsComponent } from './debugging-settings/debugging-settings.component'; import { DebuggingSettingsComponent } from './debugging-settings/debugging-settings.component';
import { UpdatesSettingsComponent } from './updates-settings/updates-settings.component'; import { UpdatesSettingsComponent } from './updates-settings/updates-settings.component';
import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-licenses'; import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-licenses';
import { ThemeLibraryService, ThemeService } from '../../../domains/theme'; import {
ThemeLibraryService,
ThemeNodeDirective,
ThemeService
} from '../../../domains/theme';
@Component({ @Component({
selector: 'app-settings-modal', selector: 'app-settings-modal',
@@ -63,7 +67,8 @@ import { ThemeLibraryService, ThemeService } from '../../../domains/theme';
ServerSettingsComponent, ServerSettingsComponent,
MembersSettingsComponent, MembersSettingsComponent,
BansSettingsComponent, BansSettingsComponent,
PermissionsSettingsComponent PermissionsSettingsComponent,
ThemeNodeDirective
], ],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
@@ -109,41 +114,19 @@ export class SettingsModalComponent {
selectedSavedTheme = this.themeLibrary.selectedEntry; selectedSavedTheme = this.themeLibrary.selectedEntry;
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [ readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
{ id: 'general', { id: 'general', label: 'General', icon: 'lucideSettings' },
label: 'General', { id: 'theme', label: 'Theme Studio', icon: 'lucidePalette' },
icon: 'lucideSettings' }, { id: 'network', label: 'Network', icon: 'lucideGlobe' },
{ id: 'theme', { id: 'notifications', label: 'Notifications', icon: 'lucideBell' },
label: 'Theme Studio', { id: 'voice', label: 'Voice & Audio', icon: 'lucideAudioLines' },
icon: 'lucidePalette' }, { id: 'updates', label: 'Updates', icon: 'lucideDownload' },
{ id: 'network', { id: 'debugging', label: 'Debugging', icon: 'lucideBug' }
label: 'Network',
icon: 'lucideGlobe' },
{ id: 'notifications',
label: 'Notifications',
icon: 'lucideBell' },
{ id: 'voice',
label: 'Voice & Audio',
icon: 'lucideAudioLines' },
{ id: 'updates',
label: 'Updates',
icon: 'lucideDownload' },
{ id: 'debugging',
label: 'Debugging',
icon: 'lucideBug' }
]; ];
readonly serverPages: { id: SettingsPage; label: string; icon: string }[] = [ readonly serverPages: { id: SettingsPage; label: string; icon: string }[] = [
{ id: 'server', { id: 'server', label: 'Server', icon: 'lucideSettings' },
label: 'Server', { id: 'members', label: 'Members', icon: 'lucideUsers' },
icon: 'lucideSettings' }, { id: 'bans', label: 'Bans', icon: 'lucideBan' },
{ id: 'members', { id: 'permissions', label: 'Permissions', icon: 'lucideShield' }
label: 'Members',
icon: 'lucideUsers' },
{ id: 'bans',
label: 'Bans',
icon: 'lucideBan' },
{ id: 'permissions',
label: 'Permissions',
icon: 'lucideShield' }
]; ];
manageableRooms = computed<Room[]>(() => { manageableRooms = computed<Room[]>(() => {
@@ -153,16 +136,18 @@ export class SettingsModalComponent {
return []; return [];
return this.savedRooms().filter((room) => { return this.savedRooms().filter((room) => {
const viewedRoom = this.currentRoom()?.id === room.id ? this.currentRoom() ?? room : room; const viewedRoom = this.currentRoom()?.id === room.id ? (this.currentRoom() ?? room) : room;
const role = resolveLegacyRole(viewedRoom, user); const role = resolveLegacyRole(viewedRoom, user);
return role === 'host' return (
|| resolveRoomPermission(viewedRoom, user, 'manageServer') role === 'host' ||
|| resolveRoomPermission(viewedRoom, user, 'manageRoles') resolveRoomPermission(viewedRoom, user, 'manageServer') ||
|| resolveRoomPermission(viewedRoom, user, 'manageChannels') resolveRoomPermission(viewedRoom, user, 'manageRoles') ||
|| resolveRoomPermission(viewedRoom, user, 'manageBans') resolveRoomPermission(viewedRoom, user, 'manageChannels') ||
|| resolveRoomPermission(viewedRoom, user, 'kickMembers') resolveRoomPermission(viewedRoom, user, 'manageBans') ||
|| resolveRoomPermission(viewedRoom, user, 'banMembers'); resolveRoomPermission(viewedRoom, user, 'kickMembers') ||
resolveRoomPermission(viewedRoom, user, 'banMembers')
);
}); });
}); });
@@ -187,21 +172,23 @@ export class SettingsModalComponent {
if (!server || !user) if (!server || !user)
return null; return null;
return resolveLegacyRole(this.currentRoom()?.id === server.id ? this.currentRoom() ?? server : server, user); return resolveLegacyRole(this.currentRoom()?.id === server.id ? (this.currentRoom() ?? server) : server, user);
}); });
canAccessSelectedServer = computed(() => { canAccessSelectedServer = computed(() => {
const server = this.selectedServer(); const server = this.selectedServer();
const user = this.currentUser(); const user = this.currentUser();
return !!server && !!user && ( return (
resolveLegacyRole(server, user) === 'host' !!server &&
|| resolveRoomPermission(server, user, 'manageServer') !!user &&
|| resolveRoomPermission(server, user, 'manageRoles') (resolveLegacyRole(server, user) === 'host' ||
|| resolveRoomPermission(server, user, 'manageChannels') resolveRoomPermission(server, user, 'manageServer') ||
|| resolveRoomPermission(server, user, 'manageBans') resolveRoomPermission(server, user, 'manageRoles') ||
|| resolveRoomPermission(server, user, 'kickMembers') resolveRoomPermission(server, user, 'manageChannels') ||
|| resolveRoomPermission(server, user, 'banMembers') resolveRoomPermission(server, user, 'manageBans') ||
resolveRoomPermission(server, user, 'kickMembers') ||
resolveRoomPermission(server, user, 'banMembers'))
); );
}); });
@@ -209,11 +196,13 @@ export class SettingsModalComponent {
const server = this.selectedServer(); const server = this.selectedServer();
const user = this.currentUser(); const user = this.currentUser();
return !!server && !!user && ( return (
resolveLegacyRole(server, user) === 'host' !!server &&
|| resolveRoomPermission(server, user, 'manageRoles') !!user &&
|| resolveRoomPermission(server, user, 'kickMembers') (resolveLegacyRole(server, user) === 'host' ||
|| resolveRoomPermission(server, user, 'banMembers') resolveRoomPermission(server, user, 'manageRoles') ||
resolveRoomPermission(server, user, 'kickMembers') ||
resolveRoomPermission(server, user, 'banMembers'))
); );
}); });
@@ -221,20 +210,19 @@ export class SettingsModalComponent {
const server = this.selectedServer(); const server = this.selectedServer();
const user = this.currentUser(); const user = this.currentUser();
return !!server && !!user && ( return !!server && !!user && (resolveLegacyRole(server, user) === 'host' || resolveRoomPermission(server, user, 'manageBans'));
resolveLegacyRole(server, user) === 'host'
|| resolveRoomPermission(server, user, 'manageBans')
);
}); });
canManageSelectedPermissions = computed(() => { canManageSelectedPermissions = computed(() => {
const server = this.selectedServer(); const server = this.selectedServer();
const user = this.currentUser(); const user = this.currentUser();
return !!server && !!user && ( return (
resolveLegacyRole(server, user) === 'host' !!server &&
|| resolveRoomPermission(server, user, 'manageRoles') !!user &&
|| resolveRoomPermission(server, user, 'manageServer') (resolveLegacyRole(server, user) === 'host' ||
resolveRoomPermission(server, user, 'manageRoles') ||
resolveRoomPermission(server, user, 'manageServer'))
); );
}); });
@@ -259,9 +247,8 @@ export class SettingsModalComponent {
const hasSelected = !!selectedId && rooms.some((room) => room.id === selectedId); const hasSelected = !!selectedId && rooms.some((room) => room.id === selectedId);
if (!hasSelected) { if (!hasSelected) {
const fallbackId = [targetId, currentRoomId].find((candidateId) => const fallbackId =
!!candidateId && rooms.some((room) => room.id === candidateId) [targetId, currentRoomId].find((candidateId) => !!candidateId && rooms.some((room) => room.id === candidateId)) ?? rooms[0]?.id ?? null;
) ?? rooms[0]?.id ?? null;
this.selectedServerId.set(fallbackId); this.selectedServerId.set(fallbackId);
} }

View File

@@ -11,6 +11,7 @@
<!-- Dialog --> <!-- Dialog -->
<div <div
appThemeNode="confirmDialogSurface"
class="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-lg" class="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-lg"
[class]="widthClass()" [class]="widthClass()"
> >

View File

@@ -4,10 +4,12 @@ import {
output, output,
HostListener HostListener
} from '@angular/core'; } from '@angular/core';
import { ThemeNodeDirective } from '../../../domains/theme';
@Component({ @Component({
selector: 'app-confirm-dialog', selector: 'app-confirm-dialog',
standalone: true, standalone: true,
imports: [ThemeNodeDirective],
templateUrl: './confirm-dialog.component.html', templateUrl: './confirm-dialog.component.html',
host: { host: {
style: 'display: contents;' style: 'display: contents;'

View File

@@ -12,6 +12,7 @@
<!-- Positioned menu panel --> <!-- Positioned menu panel -->
<div <div
#panel #panel
appThemeNode="contextMenuSurface"
class="fixed z-50 bg-card border border-border rounded-lg shadow-lg py-1" class="fixed z-50 bg-card border border-border rounded-lg shadow-lg py-1"
[class]="widthPx() ? '' : width()" [class]="widthPx() ? '' : width()"
[style.left.px]="clampedX()" [style.left.px]="clampedX()"

View File

@@ -9,10 +9,12 @@ import {
AfterViewInit, AfterViewInit,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { ThemeNodeDirective } from '../../../domains/theme';
@Component({ @Component({
selector: 'app-context-menu', selector: 'app-context-menu',
standalone: true, standalone: true,
imports: [ThemeNodeDirective],
templateUrl: './context-menu.component.html', templateUrl: './context-menu.component.html',
styleUrl: './context-menu.component.scss' styleUrl: './context-menu.component.scss'
}) })

View File

@@ -1,4 +1,5 @@
<div <div
appThemeNode="profileCardSurface"
class="w-72 rounded-lg border border-border bg-card shadow-xl" class="w-72 rounded-lg border border-border bg-card shadow-xl"
style="animation: profile-card-in 120ms cubic-bezier(0.2, 0, 0, 1) both" style="animation: profile-card-in 120ms cubic-bezier(0.2, 0, 0, 1) both"
> >
@@ -8,7 +9,10 @@
@let statusColor = currentStatusColor(); @let statusColor = currentStatusColor();
@let statusLabel = currentStatusLabel(); @let statusLabel = currentStatusLabel();
<div class="h-20 rounded-t-lg bg-gradient-to-r from-primary/30 to-primary/10"></div> <div
appThemeNode="profileCardBanner"
class="h-20 rounded-t-lg bg-gradient-to-r from-primary/30 to-primary/10"
></div>
<div class="relative px-4"> <div class="relative px-4">
<div class="-mt-8"> <div class="-mt-8">
@@ -36,7 +40,10 @@
</div> </div>
</div> </div>
<div class="px-4 pb-3 pt-2.5"> <div
appThemeNode="profileCardBody"
class="px-4 pb-3 pt-2.5"
>
@if (isEditable) { @if (isEditable) {
<div class="space-y-2"> <div class="space-y-2">
<div> <div>

View File

@@ -21,6 +21,7 @@ import {
} from '../../../domains/profile-avatar'; } from '../../../domains/profile-avatar';
import { UsersActions } from '../../../store/users/users.actions'; import { UsersActions } from '../../../store/users/users.actions';
import { selectUsersEntities } from '../../../store/users/users.selectors'; import { selectUsersEntities } from '../../../store/users/users.selectors';
import { ThemeNodeDirective } from '../../../domains/theme';
@Component({ @Component({
selector: 'app-profile-card', selector: 'app-profile-card',
@@ -28,15 +29,14 @@ import { selectUsersEntities } from '../../../store/users/users.selectors';
imports: [ imports: [
CommonModule, CommonModule,
NgIcon, NgIcon,
UserAvatarComponent UserAvatarComponent,
ThemeNodeDirective
], ],
viewProviders: [provideIcons({ lucideCheck, lucideChevronDown })], viewProviders: [provideIcons({ lucideCheck, lucideChevronDown })],
templateUrl: './profile-card.component.html' templateUrl: './profile-card.component.html'
}) })
export class ProfileCardComponent { export class ProfileCardComponent {
readonly user = signal<User>({ id: '', oderId: '', username: '', displayName: '', status: 'offline', role: 'member', joinedAt: 0 }); readonly user = signal<User>({ id: '', oderId: '', username: '', displayName: '', status: 'offline', role: 'member', joinedAt: 0 });
private readonly store = inject(Store);
private readonly users = this.store.selectSignal(selectUsersEntities);
readonly displayedUser = computed(() => { readonly displayedUser = computed(() => {
const snapshot = this.user(); const snapshot = this.user();
const entities = this.users(); const entities = this.users();
@@ -60,10 +60,13 @@ export class ProfileCardComponent {
{ value: 'offline', label: 'Invisible', color: 'bg-gray-500' } { value: 'offline', label: 'Invisible', color: 'bg-gray-500' }
]; ];
private readonly store = inject(Store);
private readonly users = this.store.selectSignal(selectUsersEntities);
private readonly userStatus = inject(UserStatusService); private readonly userStatus = inject(UserStatusService);
private readonly profileAvatar = inject(ProfileAvatarFacade); private readonly profileAvatar = inject(ProfileAvatarFacade);
private readonly profileAvatarEditor = inject(ProfileAvatarEditorService); private readonly profileAvatarEditor = inject(ProfileAvatarEditorService);
private readonly syncProfileDrafts = effect(() => { private readonly syncProfileDrafts = effect(
() => {
const user = this.displayedUser(); const user = this.displayedUser();
const editingField = this.editingField(); const editingField = this.editingField();
@@ -74,28 +77,41 @@ export class ProfileCardComponent {
if (editingField !== 'description') { if (editingField !== 'description') {
this.descriptionDraft.set(user.description || ''); this.descriptionDraft.set(user.description || '');
} }
},
}, { allowSignalWrites: true }); { allowSignalWrites: true }
);
currentStatusColor(): string { currentStatusColor(): string {
switch (this.displayedUser().status) { switch (this.displayedUser().status) {
case 'online': return 'bg-green-500'; case 'online':
case 'away': return 'bg-yellow-500'; return 'bg-green-500';
case 'busy': return 'bg-red-500'; case 'away':
case 'offline': return 'bg-gray-500'; return 'bg-yellow-500';
case 'disconnected': return 'bg-gray-500'; case 'busy':
default: return 'bg-green-500'; return 'bg-red-500';
case 'offline':
return 'bg-gray-500';
case 'disconnected':
return 'bg-gray-500';
default:
return 'bg-green-500';
} }
} }
currentStatusLabel(): string { currentStatusLabel(): string {
switch (this.displayedUser().status) { switch (this.displayedUser().status) {
case 'online': return 'Online'; case 'online':
case 'away': return 'Away'; return 'Online';
case 'busy': return 'Do Not Disturb'; case 'away':
case 'offline': return 'Invisible'; return 'Away';
case 'disconnected': return 'Offline'; case 'busy':
default: return 'Online'; return 'Do Not Disturb';
case 'offline':
return 'Invisible';
case 'disconnected':
return 'Offline';
default:
return 'Online';
} }
} }
@@ -111,9 +127,7 @@ export class ProfileCardComponent {
isStatusOptionSelected(status: UserStatus | null): boolean { isStatusOptionSelected(status: UserStatus | null): boolean {
const currentStatus = this.displayedUser().status; const currentStatus = this.displayedUser().status;
return status === null return status === null ? currentStatus === 'online' : currentStatus === status;
? currentStatus === 'online'
: currentStatus === status;
} }
onDisplayNameInput(event: Event): void { onDisplayNameInput(event: Event): void {
@@ -223,10 +237,7 @@ export class ProfileCardComponent {
const user = this.displayedUser(); const user = this.displayedUser();
const description = this.normalizeDescription(this.descriptionDraft()); const description = this.normalizeDescription(this.descriptionDraft());
if ( if (displayName === this.normalizeDisplayName(user.displayName) && description === this.normalizeDescription(user.description)) {
displayName === this.normalizeDisplayName(user.displayName)
&& description === this.normalizeDescription(user.description)
) {
return; return;
} }

View File

@@ -11,6 +11,7 @@
<div class="fixed inset-0 z-[111] flex items-center justify-center p-4 pointer-events-none"> <div class="fixed inset-0 z-[111] flex items-center justify-center p-4 pointer-events-none">
<section <section
appThemeNode="screenShareSourcePicker"
class="pointer-events-auto w-full max-w-6xl rounded-2xl border border-border bg-card shadow-2xl" class="pointer-events-auto w-full max-w-6xl rounded-2xl border border-border bg-card shadow-2xl"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
(keydown.enter)="$event.stopPropagation()" (keydown.enter)="$event.stopPropagation()"
@@ -124,6 +125,7 @@
@for (source of filteredSources(); track trackSource($index, source)) { @for (source of filteredSources(); track trackSource($index, source)) {
<button <button
#sourceButton #sourceButton
appThemeNode="screenShareSourceCard"
type="button" type="button"
class="rounded-xl border px-4 py-4 text-left transition-colors screen-share-source-picker__source" class="rounded-xl border px-4 py-4 text-left transition-colors screen-share-source-picker__source"
[attr.aria-pressed]="selectedSourceId() === source.id" [attr.aria-pressed]="selectedSourceId() === source.id"

View File

@@ -14,11 +14,16 @@ import {
ScreenShareSourceOption, ScreenShareSourceOption,
ScreenShareSourcePickerService ScreenShareSourcePickerService
} from '../../../domains/screen-share'; } from '../../../domains/screen-share';
import { ThemeNodeDirective } from '../../../domains/theme';
@Component({ @Component({
selector: 'app-screen-share-source-picker', selector: 'app-screen-share-source-picker',
standalone: true, standalone: true,
imports: [CommonModule, NgOptimizedImage], imports: [
CommonModule,
NgOptimizedImage,
ThemeNodeDirective
],
templateUrl: './screen-share-source-picker.component.html', templateUrl: './screen-share-source-picker.component.html',
styleUrl: './screen-share-source-picker.component.scss', styleUrl: './screen-share-source-picker.component.scss',
host: { host: {
@@ -32,9 +37,7 @@ export class ScreenShareSourcePickerComponent {
readonly screenSources = computed(() => this.sources().filter((source) => source.kind === 'screen')); readonly screenSources = computed(() => this.sources().filter((source) => source.kind === 'screen'));
readonly windowSources = computed(() => this.sources().filter((source) => source.kind === 'window')); readonly windowSources = computed(() => this.sources().filter((source) => source.kind === 'window'));
readonly filteredSources = computed(() => { readonly filteredSources = computed(() => {
return this.activeTab() === 'screen' return this.activeTab() === 'screen' ? this.screenSources() : this.windowSources();
? this.screenSources()
: this.windowSources();
}); });
readonly hasOpenRequest = computed(() => !!this.request()); readonly hasOpenRequest = computed(() => !!this.request());
readonly activeTab = signal<ScreenShareSourceKind>('screen'); readonly activeTab = signal<ScreenShareSourceKind>('screen');
@@ -46,9 +49,7 @@ export class ScreenShareSourcePickerComponent {
constructor() { constructor() {
effect(() => { effect(() => {
const request = this.request(); const request = this.request();
const defaultTab: ScreenShareSourceKind = request?.sources.some((source) => source.kind === 'screen') const defaultTab: ScreenShareSourceKind = request?.sources.some((source) => source.kind === 'screen') ? 'screen' : 'window';
? 'screen'
: 'window';
this.activeTab.set(defaultTab); this.activeTab.set(defaultTab);
this.includeSystemAudio.set(request?.includeSystemAudio ?? false); this.includeSystemAudio.set(request?.includeSystemAudio ?? false);
@@ -68,9 +69,8 @@ export class ScreenShareSourcePickerComponent {
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
const activeSourceId = this.selectedSourceId(); const activeSourceId = this.selectedSourceId();
const targetButton = this.sourceButtons().find( const targetButton =
(button) => button.nativeElement.dataset['sourceId'] === activeSourceId this.sourceButtons().find((button) => button.nativeElement.dataset['sourceId'] === activeSourceId) ?? this.sourceButtons()[0];
) ?? this.sourceButtons()[0];
targetButton?.nativeElement.focus(); targetButton?.nativeElement.focus();
}); });
@@ -125,8 +125,6 @@ export class ScreenShareSourcePickerComponent {
} }
private getTabSources(tab: ScreenShareSourceKind): ScreenShareSourceOption[] { private getTabSources(tab: ScreenShareSourceKind): ScreenShareSourceOption[] {
return tab === 'screen' return tab === 'screen' ? this.screenSources() : this.windowSources();
? this.screenSources()
: this.windowSources();
} }
} }