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

View File

@@ -15,11 +15,18 @@
class="theme-grid-editor__frame relative overflow-hidden rounded-lg border border-border/80"
[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) {
<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--disabled]="disabled()"
[ngStyle]="itemStyle(item)"

View File

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

View File

@@ -45,8 +45,11 @@ export class ThemeGridEditorComponent {
readonly canvasRef = viewChild.required<ElementRef<HTMLElement>>('canvasRef');
readonly frameStyle = computed(() => ({
'--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 dragState: DragState | null = null;
@@ -57,11 +60,8 @@ export class ThemeGridEditorComponent {
return;
}
const canvasRect = this.canvasRef().nativeElement.getBoundingClientRect();
const columnWidth = canvasRect.width / this.container().columns;
const rowHeight = canvasRect.height / this.container().rows;
const deltaColumns = Math.round((event.clientX - this.dragState.startClientX) / columnWidth);
const deltaRows = Math.round((event.clientY - this.dragState.startClientY) / rowHeight);
const deltaColumns = this.gridDelta('columns', this.dragState.startClientX, event.clientX);
const deltaRows = this.gridDelta('rows', this.dragState.startClientY, event.clientY);
const nextGrid = { ...this.dragState.startGrid };
if (this.dragState.mode === 'move') {
@@ -90,13 +90,9 @@ export class ThemeGridEditorComponent {
}
itemStyle(item: ThemeGridEditorItem): Record<string, string> {
const { columns, rows } = this.container();
return {
left: `${(item.grid.x / columns) * 100}%`,
top: `${(item.grid.y / rows) * 100}%`,
width: `${(item.grid.w / columns) * 100}%`,
height: `${(item.grid.h / rows) * 100}%`
gridColumn: `${item.grid.x + 1} / span ${item.grid.w}`,
gridRow: `${item.grid.y + 1} / span ${item.grid.h}`
};
}
@@ -132,4 +128,61 @@ export class ThemeGridEditorComponent {
private clamp(value: number, minimum: number, maximum: number): number {
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,75 +11,149 @@ import {
viewChild
} from '@angular/core';
import { indentWithTab } from '@codemirror/commands';
import { json } from '@codemirror/lang-json';
import { indentUnit } from '@codemirror/language';
import { EditorSelection, EditorState } from '@codemirror/state';
import { css } from '@codemirror/lang-css';
import { json, jsonParseLinter } from '@codemirror/lang-json';
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 { EditorView, keymap } from '@codemirror/view';
import { basicSetup } from 'codemirror';
const THEME_JSON_EDITOR_THEME = EditorView.theme({
'&': {
height: '100%',
backgroundColor: 'transparent',
color: '#e7eef9'
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%',
backgroundColor: 'transparent',
color: '#e7eef9'
},
'&.cm-focused': {
outline: 'none'
},
'.cm-scroller': {
overflow: 'auto',
fontFamily: "'IBM Plex Mono', 'JetBrains Mono', 'Fira Code', monospace",
lineHeight: '1.55'
},
'.cm-content': {
minHeight: '100%',
padding: '1rem 0',
caretColor: '#f8fafc'
},
'.cm-line': {
padding: '0 1rem 0 0.5rem'
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: '#f8fafc'
},
'.cm-gutters': {
minHeight: '100%',
borderRight: '1px solid #2f405c',
backgroundColor: '#172033',
color: '#7b8aa5'
},
'.cm-activeLine': {
backgroundColor: 'rgb(148 163 184 / 0.08)'
},
'.cm-activeLineGutter': {
backgroundColor: 'rgb(148 163 184 / 0.12)',
color: '#d7e2f2'
},
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground, ::selection': {
backgroundColor: 'rgb(96 165 250 / 0.22)'
},
'.cm-panels': {
backgroundColor: '#111827',
color: '#e5eefc',
borderBottom: '1px solid #2f405c'
},
'.cm-searchMatch': {
backgroundColor: 'rgb(250 204 21 / 0.18)',
outline: '1px solid rgb(250 204 21 / 0.32)'
},
'.cm-searchMatch.cm-searchMatch-selected': {
backgroundColor: 'rgb(250 204 21 / 0.28)'
},
'.cm-tooltip': {
border: '1px solid #314158',
backgroundColor: '#111827'
},
'.cm-tooltip-autocomplete ul li[aria-selected]': {
backgroundColor: 'rgb(96 165 250 / 0.18)',
color: '#f8fafc'
},
'.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'
}
},
'&.cm-focused': {
outline: 'none'
},
'.cm-scroller': {
overflow: 'auto',
fontFamily: "'IBM Plex Mono', 'JetBrains Mono', 'Fira Code', monospace",
lineHeight: '1.55'
},
'.cm-content': {
minHeight: '100%',
padding: '1rem 0',
caretColor: '#f8fafc'
},
'.cm-line': {
padding: '0 1rem 0 0.5rem'
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: '#f8fafc'
},
'.cm-gutters': {
minHeight: '100%',
borderRight: '1px solid #2f405c',
backgroundColor: '#172033',
color: '#7b8aa5'
},
'.cm-activeLine': {
backgroundColor: 'rgb(148 163 184 / 0.08)'
},
'.cm-activeLineGutter': {
backgroundColor: 'rgb(148 163 184 / 0.12)',
color: '#d7e2f2'
},
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground, ::selection': {
backgroundColor: 'rgb(96 165 250 / 0.22)'
},
'.cm-panels': {
backgroundColor: '#111827',
color: '#e5eefc',
borderBottom: '1px solid #2f405c'
},
'.cm-searchMatch': {
backgroundColor: 'rgb(250 204 21 / 0.18)',
outline: '1px solid rgb(250 204 21 / 0.32)'
},
'.cm-searchMatch.cm-searchMatch-selected': {
backgroundColor: 'rgb(250 204 21 / 0.28)'
},
'.cm-tooltip': {
border: '1px solid #314158',
backgroundColor: '#111827'
},
'.cm-tooltip-autocomplete ul li[aria-selected]': {
backgroundColor: 'rgb(96 165 250 / 0.18)',
color: '#f8fafc'
{ dark: true }
);
function getLanguageExtensions(language: ThemeCodeEditorLanguage): Extension[] {
if (language === 'css') {
return [css(), linter(cssSyntaxLinter, { delay: 250 })];
}
}, { dark: true });
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;
}
@Component({
selector: 'app-theme-json-code-editor',
@@ -91,9 +165,10 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
readonly editorHostRef = viewChild<ElementRef<HTMLDivElement>>('editorHostRef');
readonly value = input.required<string>();
readonly fullscreen = input(false);
readonly language = input<ThemeCodeEditorLanguage>('json');
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);
@@ -173,6 +248,8 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
private createEditor(host: HTMLDivElement): void {
this.zone.runOutsideAngular(() => {
const language = this.language();
this.editorView = new EditorView({
state: EditorState.create({
doc: this.value(),
@@ -180,7 +257,8 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
basicSetup,
keymap.of([indentWithTab]),
indentUnit.of(' '),
json(),
...getLanguageExtensions(language),
lintGutter(),
oneDark,
THEME_JSON_EDITOR_THEME,
EditorState.tabSize.of(2),
@@ -188,7 +266,25 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
spellcheck: 'false',
autocapitalize: '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) => {
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
</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
type="button"
(click)="copyLlmThemeGuide()"
@@ -33,13 +40,35 @@
>
Copy LLM Guide
</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
type="button"
(click)="applyDraft()"
[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"
>
Apply Draft
{{ activeEditorTab() === 'cssOnly' ? 'Apply CSS Theme' : 'Apply Draft' }}
</button>
<button
type="button"
@@ -59,24 +88,8 @@
<div class="theme-settings__hero-grid mt-4">
<div class="theme-settings__hero-stat">
<div class="theme-settings__workspace-selector theme-settings__workspace-selector--compact">
<label
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>
<span class="theme-settings__hero-label">Workspace</span>
<strong class="theme-settings__hero-value">{{ activeWorkspaceInfo().label }}</strong>
</div>
<div class="theme-settings__hero-stat">
<span class="theme-settings__hero-label">Regions</span>
@@ -110,6 +123,43 @@
</ul>
</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>
<div class="theme-settings__workspace min-h-0 flex-1">
@@ -222,7 +272,7 @@
</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">
<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">
@@ -240,7 +290,7 @@
/>
</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) {
<button
type="button"
@@ -318,23 +368,70 @@
@if (activeWorkspace() === 'editor') {
<section class="theme-studio-card theme-settings__editor-card p-4">
<div class="flex flex-wrap items-start justify-between gap-4 border-b border-border pb-3">
<p class="text-sm font-semibold text-foreground">Theme JSON</p>
<div class="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">
<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">{{ draftCharacterCount() }} chars</span>
<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"
>{{ 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-slate-900 px-2.5 py-1 text-slate-200">IDE editor</span>
</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">
<app-theme-json-code-editor
#jsonEditorRef
[value]="draftText()"
[fullscreen]="isFullscreen()"
(valueChange)="onDraftEditorValueChange($event)"
/>
@if (activeEditorTab() === 'json') {
<app-theme-json-code-editor
#jsonEditorRef
[value]="draftText()"
[fullscreen]="isFullscreen()"
language="json"
(valueChange)="onDraftEditorValueChange($event)"
/>
} @else {
<app-theme-json-code-editor
#jsonEditorRef
[value]="cssOnlyText()"
[fullscreen]="isFullscreen()"
language="css"
(valueChange)="onCssOnlyEditorValueChange($event)"
/>
}
</div>
</section>
}
@@ -370,7 +467,7 @@
<section class="theme-studio-card p-4">
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-semibold text-foreground">Schema Hints</p>
<p class="text-sm font-semibold text-foreground">Editable Attributes</p>
<button
type="button"
@@ -468,7 +565,7 @@
<div class="mt-5">
<app-theme-grid-editor
[container]="selectedContainer() === 'appShell' ? layoutContainers[0] : layoutContainers[1]"
[container]="selectedLayoutContainer()"
[items]="selectedContainerItems()"
[selectedKey]="selectedElementKey()"
[disabled]="!draftIsValid()"

View File

@@ -18,6 +18,54 @@
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 {
font-size: 0.69rem;
font-weight: 700;
@@ -52,6 +100,29 @@
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 {
display: flex;
min-height: 0;
@@ -92,6 +163,22 @@
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 {
min-width: 0;
}
@@ -114,3 +201,22 @@
min-height: 0;
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 { SettingsModalService } from '../../../../../core/services/settings-modal.service';
import { getElectronApi } from '../../../../../core/platform/electron/get-electron-api';
import {
ThemeContainerKey,
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 { 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';
@Component({
@@ -59,6 +61,7 @@ export class ThemeSettingsComponent {
readonly statusMessage = this.theme.statusMessage;
readonly isDraftDirty = this.theme.isDraftDirty;
readonly isFullscreen = this.modal.themeStudioFullscreen;
readonly activeTheme = this.theme.activeTheme;
readonly draftTheme = this.theme.draftTheme;
readonly THEME_ANIMATION_FIELDS = THEME_ANIMATION_FIELD_HINTS;
readonly animationKeys = this.theme.knownAnimationClasses;
@@ -83,6 +86,8 @@ export class ThemeSettingsComponent {
];
readonly mountedKeyCounts = computed(() => this.registry.mountedKeyCounts());
readonly activeWorkspace = signal<ThemeStudioWorkspace>('editor');
readonly activeEditorTab = signal<ThemeEditorTab>('json');
readonly cssOnlyText = signal('');
readonly explorerQuery = signal('');
readonly selectedContainer = signal<ThemeContainerKey>('roomLayout');
@@ -103,9 +108,15 @@ export class ThemeSettingsComponent {
].filter((value): value is string => value !== null);
});
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(() => {
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(() => {
const selected = this.selectedElement();
@@ -156,6 +167,8 @@ export class ThemeSettingsComponent {
private llmGuideCopyTimeoutId: ReturnType<typeof setTimeout> | null = null;
constructor() {
this.syncCssOnlyTextFromTheme();
if (this.savedThemesAvailable()) {
void this.themeLibrary.refresh();
}
@@ -167,8 +180,16 @@ export class ThemeSettingsComponent {
return;
}
this.activeWorkspace.set('inspector');
this.selectThemeEntry(pickedKey, 'elements');
this.openPickedElementInJson(pickedKey);
this.picker.clearSelection();
});
effect(() => {
if (this.activeEditorTab() !== 'json') {
return;
}
this.cssOnlyText.set(this.draftTheme().css);
});
effect(() => {
@@ -186,10 +207,35 @@ export class ThemeSettingsComponent {
this.theme.updateDraftText(value);
}
onCssOnlyEditorValueChange(value: string): void {
this.cssOnlyText.set(value);
}
applyDraft(): void {
if (this.activeEditorTab() === 'cssOnly') {
this.applyCssOnlyTheme();
return;
}
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 {
this.activeWorkspace.set(workspace);
@@ -202,6 +248,7 @@ export class ThemeSettingsComponent {
}
if (workspace === 'editor') {
this.activeEditorTab.set('json');
this.focusEditor();
}
}
@@ -223,12 +270,36 @@ export class ThemeSettingsComponent {
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> {
const copied = await this.copyTextToClipboard(THEME_LLM_GUIDE);
this.setLlmGuideCopyMessage(copied
? 'LLM guide copied.'
: 'Manual copy opened.');
this.setLlmGuideCopyMessage(copied ? 'LLM guide copied.' : 'Manual copy opened.');
}
startPicker(): void {
@@ -285,9 +356,10 @@ export class ThemeSettingsComponent {
restoreDefaultTheme(): void {
this.theme.resetToDefault('button');
this.activeWorkspace.set('editor');
this.activeEditorTab.set('json');
this.selectedContainer.set('roomLayout');
this.selectedElementKey.set('chatRoomMainPanel');
this.focusJsonAnchor('elements', 'chatRoomMainPanel');
this.focusEditor();
}
selectThemeEntry(key: string, section: JumpSection = 'elements'): void {
@@ -299,7 +371,7 @@ export class ThemeSettingsComponent {
if (section === 'layout') {
this.activeWorkspace.set('layout');
} else if (section === 'animations') {
} else if (section === 'animations' || section === 'css') {
this.activeWorkspace.set('editor');
} else {
this.activeWorkspace.set('inspector');
@@ -331,6 +403,9 @@ export class ThemeSettingsComponent {
const value = getSuggestedFieldDefault(property, this.animationKeys());
this.theme.setElementStyle(this.selectedElementKey(), property, value);
this.activeWorkspace.set('editor');
this.activeEditorTab.set('json');
this.focusJsonProperty('elements', this.selectedElementKey(), property);
}
addStarterAnimation(): void {
@@ -355,37 +430,200 @@ export class ThemeSettingsComponent {
jumpToLayout(): void {
this.activeWorkspace.set('editor');
this.activeEditorTab.set('json');
this.focusJsonAnchor('layout', this.selectedElementKey());
}
jumpToStyles(): void {
this.activeWorkspace.set('editor');
this.activeEditorTab.set('json');
this.focusJsonAnchor('elements', this.selectedElementKey());
}
jumpToAnimation(animationKey: string): void {
this.activeWorkspace.set('editor');
this.activeEditorTab.set('json');
this.focusJsonAnchor('animations', animationKey);
}
jumpToCss(): void {
this.activeWorkspace.set('editor');
this.activeEditorTab.set('cssOnly');
this.syncCssOnlyTextFromTheme();
this.focusEditor();
}
isMounted(entry: ThemeRegistryEntry): boolean {
return (this.mountedKeyCounts()[entry.key] ?? 0) > 0;
}
private focusEditor(): void {
this.withEditorReady((editor) => {
editor.focus();
});
}
private withEditorReady(action: (editor: ThemeJsonCodeEditorComponent) => void, attempts = 8): void {
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 {
queueMicrotask(() => {
const editor = this.editorRef();
if (!editor) {
return;
}
this.withEditorReady((editor) => {
let text = this.draftText();
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> {
if (navigator.clipboard?.writeText) {
try {

View File

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

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