feat: Rename to Toju and add translation
Some checks failed
Deploy Web Apps / deploy (push) Successful in 5m52s
Build Android APK / build-android-apk (push) Failing after 23m15s
Queue Release Build / prepare (push) Successful in 1m42s
Queue Release Build / build-linux (push) Failing after 9m33s
Queue Release Build / build-windows (push) Successful in 26m5s
Queue Release Build / finalize (push) Has been skipped

This commit is contained in:
2026-06-05 17:13:03 +02:00
parent 8ecfc9a1fe
commit ee293d7daf
301 changed files with 8247 additions and 2218 deletions

View File

@@ -9,6 +9,7 @@ import {
createDefaultThemeDocument
} from '../../domain/logic/theme-defaults.logic';
import { validateThemeDocument } from '../../domain/logic/theme-validation.logic';
import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../../core/i18n/app-i18n.testing';
import { ThemeService } from './theme.service';
describe('ThemeService theme application', () => {
@@ -20,12 +21,14 @@ describe('ThemeService theme application', () => {
installLocalStorageMock();
styleElements = [];
injector = createEnvironmentInjector([
...provideAppI18nForTests(),
ThemeService,
{
provide: DOCUMENT,
useValue: createDocumentStub(styleElements)
}
]);
initializeAppI18nForTests(injector);
service = injector.get(ThemeService);
service.initialize();
@@ -200,7 +203,7 @@ describe('ThemeService theme application', () => {
expect(loaded).toBe(true);
expect(service.activeThemeName()).toBe('Complete Theme Fixture');
expect(service.getTextOverride('titleBar')).toBe('MetoYou Lab');
expect(service.getTextOverride('titleBar')).toBe('Toju Lab');
expect(service.getIcon('titleBar')).toBe('MT');
expect(service.getLink('titleBar')).toBe('https://example.com/theme');
expect(service.getAnimationClass('titleBar')).toBe('themePulse');
@@ -532,7 +535,7 @@ function createCompleteThemeDocument() {
boxShadow: '0 18px 50px rgba(0, 0, 0, 0.35)',
backdropFilter: 'var(--theme-effect-glass-blur)',
icon: 'MT',
textOverride: 'MetoYou Lab',
textOverride: 'Toju Lab',
link: 'https://example.com/theme',
animationClass: 'themePulse'
};

View File

@@ -24,6 +24,7 @@ import {
import { createAnimationStarterDefinition } from '../../domain/logic/theme-schema.logic';
import { findThemeLayoutContainer } from '../../domain/logic/theme-registry.logic';
import { validateThemeDocument } from '../../domain/logic/theme-validation.logic';
import { AppI18nService } from '../../../../core/i18n';
import {
loadThemeStorageSnapshot,
saveActiveThemeText,
@@ -141,6 +142,7 @@ export class ThemeService {
readonly builtInPresets = BUILT_IN_THEME_PRESETS;
private readonly documentRef = inject(DOCUMENT);
private readonly appI18n = inject(AppI18nService);
private readonly activeThemeInternal = signal<ThemeDocument>(createDefaultThemeDocument());
private readonly activeThemeTextInternal = signal(DEFAULT_THEME_JSON);
@@ -239,7 +241,7 @@ export class ThemeService {
formatDraft(): void {
if (!this.draftIsValidInternal()) {
this.setStatusMessage('Fix JSON errors before formatting the theme draft.');
this.setStatusMessage(this.appI18n.instant('theme.status.fixJsonBeforeFormat'));
return;
}
@@ -247,18 +249,18 @@ export class ThemeService {
this.draftTextInternal.set(formatted);
saveDraftThemeText(formatted);
this.setStatusMessage('Theme draft formatted.');
this.setStatusMessage(this.appI18n.instant('theme.status.draftFormatted'));
}
applyDraft(): boolean {
if (!this.draftIsValidInternal()) {
this.setStatusMessage('The current draft has validation errors. The previous working theme is still active.');
this.setStatusMessage(this.appI18n.instant('theme.status.draftValidationErrors'));
return false;
}
const formatted = stringifyTheme(this.draftThemeInternal());
this.commitTheme(this.draftThemeInternal(), formatted, 'Theme applied.');
this.commitTheme(this.draftThemeInternal(), formatted, this.appI18n.instant('theme.status.themeApplied'));
return true;
}
@@ -277,7 +279,7 @@ export class ThemeService {
const formatted = stringifyTheme(theme);
this.commitTheme(theme, formatted, 'CSS applied over the JSON theme.');
this.commitTheme(theme, formatted, this.appI18n.instant('theme.status.cssApplied'));
return true;
}
@@ -285,7 +287,7 @@ export class ThemeService {
const result = this.parseAndValidateTheme(text, sourceLabel);
if (!result.valid || !result.value) {
this.setStatusMessage(result.errors[0] ?? `The ${sourceLabel} could not be loaded.`);
this.setStatusMessage(result.errors[0] ?? this.appI18n.instant('theme.status.couldNotLoad', { source: sourceLabel }));
return false;
}
@@ -324,14 +326,16 @@ export class ThemeService {
saveDraftThemeText(defaultText);
this.syncAnimationStylesheet();
this.syncCssStylesheet();
this.setStatusMessage(reason === 'shortcut' ? 'Theme reset to the default preset by shortcut.' : 'Theme reset to the default preset.');
this.setStatusMessage(reason === 'shortcut'
? this.appI18n.instant('theme.status.resetByShortcut')
: this.appI18n.instant('theme.status.resetByButton'));
}
applyBuiltInPreset(name: string): boolean {
const preset = this.builtInPresets.find((builtInPreset) => builtInPreset.theme.meta.name === name || builtInPreset.key === name);
if (!preset) {
this.setStatusMessage('Built-in theme preset not found.');
this.setStatusMessage(this.appI18n.instant('theme.status.presetNotFound'));
return false;
}
@@ -339,11 +343,11 @@ export class ThemeService {
const result = validateThemeDocument(theme);
if (!result.valid || !result.value) {
this.setStatusMessage(result.errors[0] ?? 'Built-in theme preset could not be applied.');
this.setStatusMessage(result.errors[0] ?? this.appI18n.instant('theme.status.presetCouldNotApply'));
return false;
}
this.commitTheme(result.value, stringifyTheme(result.value), `${result.value.meta.name} preset applied.`);
this.commitTheme(result.value, stringifyTheme(result.value), this.appI18n.instant('theme.status.presetApplied', { name: result.value.meta.name }));
return true;
}
@@ -361,7 +365,7 @@ export class ThemeService {
ensureElementEntry(key: string): void {
if (!this.draftIsValidInternal()) {
this.setStatusMessage('Fix JSON errors before using the structured theme tools.');
this.setStatusMessage(this.appI18n.instant('theme.status.fixJsonBeforeTools'));
return;
}
@@ -378,7 +382,7 @@ export class ThemeService {
const result = validateThemeDocument(draftJson);
if (!result.valid || !result.value) {
this.setStatusMessage('The structured change could not be validated.');
this.setStatusMessage(this.appI18n.instant('theme.status.structuredChangeInvalid'));
return;
}
@@ -389,7 +393,7 @@ export class ThemeService {
this.draftIsValidInternal.set(true);
this.draftErrorsInternal.set([]);
saveDraftThemeText(formatted);
this.setStatusMessage(`Prepared ${key} in the theme draft.`);
this.setStatusMessage(this.appI18n.instant('theme.status.preparedElement', { key }));
}
ensureLayoutEntry(key: string): void {
@@ -400,7 +404,7 @@ export class ThemeService {
draft.layout[key] = draft.layout[key] ?? defaults[key];
},
false,
`Prepared ${key} layout in the theme draft.`
this.appI18n.instant('theme.status.preparedLayout', { key })
);
}
@@ -413,7 +417,7 @@ export class ThemeService {
};
},
applyImmediately,
`${key} updated.`
this.appI18n.instant('theme.status.elementUpdated', { key })
);
}
@@ -423,7 +427,7 @@ export class ThemeService {
draft.animations[key] = definition;
},
applyImmediately,
`Animation ${key} updated.`
this.appI18n.instant('theme.status.animationUpdated', { key })
);
}
@@ -512,7 +516,7 @@ export class ThemeService {
updateStructuredDraft(mutator: (draft: ThemeDocument) => void, applyImmediately: boolean, successMessage: string): void {
if (!this.draftIsValidInternal()) {
this.setStatusMessage('Fix JSON errors before using the structured theme tools.');
this.setStatusMessage(this.appI18n.instant('theme.status.fixJsonBeforeTools'));
return;
}
@@ -523,7 +527,7 @@ export class ThemeService {
const result = validateThemeDocument(nextDraft);
if (!result.valid || !result.value) {
this.setStatusMessage('The structured change could not be validated.');
this.setStatusMessage(this.appI18n.instant('theme.status.structuredChangeInvalid'));
return;
}
@@ -559,7 +563,7 @@ export class ThemeService {
private composeDraftThemeWithCss(css: string): ThemeDocument | null {
if (!this.draftIsValidInternal()) {
this.setStatusMessage('Fix JSON errors before applying CSS over the theme draft.');
this.setStatusMessage(this.appI18n.instant('theme.status.fixJsonBeforeCss'));
return null;
}
@@ -570,7 +574,7 @@ export class ThemeService {
const result = validateThemeDocument(theme);
if (!result.valid || !result.value) {
this.setStatusMessage(result.errors[0] ?? 'The CSS-only theme could not be applied.');
this.setStatusMessage(result.errors[0] ?? this.appI18n.instant('theme.status.cssOnlyCouldNotApply'));
return null;
}

View File

@@ -27,6 +27,7 @@ import {
import { oneDark } from '@codemirror/theme-one-dark';
import { EditorView, keymap } from '@codemirror/view';
import { basicSetup } from 'codemirror';
import { AppI18nService } from '../../../../../core/i18n';
import { formatPastedJsonText } from './theme-json-format.logic';
@@ -171,6 +172,7 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
readonly editorMinHeight = computed(() => (this.fullscreen() ? 'max(34rem, calc(100vh - 15rem))' : '28rem'));
private readonly zone = inject(NgZone);
private readonly appI18n = inject(AppI18nService);
private editorView: EditorView | null = null;
private isApplyingExternalValue = false;
@@ -266,7 +268,9 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
spellcheck: 'false',
autocapitalize: 'off',
autocorrect: 'off',
'aria-label': language === 'css' ? 'Theme CSS editor' : 'Theme JSON editor'
'aria-label': language === 'css'
? this.appI18n.instant('theme.editor.cssAria')
: this.appI18n.instant('theme.editor.jsonAria')
}),
EditorView.domEventHandlers({
paste: (event, view) => {

View File

@@ -7,7 +7,7 @@
<section class="theme-studio-card p-4">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-primary">Theme Studio</p>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-primary">{{ 'theme.studio.badge' | translate }}</p>
<h4 class="mt-1 text-xl font-semibold text-foreground">{{ draftTheme().meta.name }}</h4>
</div>
@@ -17,35 +17,35 @@
(click)="startPicker()"
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"
>
Pick UI Element
{{ 'theme.studio.pickElement' | translate }}
</button>
<button
type="button"
(click)="formatDraft()"
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"
>
Format JSON
{{ 'theme.studio.formatJson' | translate }}
</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
{{ 'theme.studio.openCss' | translate }}
</button>
<button
type="button"
(click)="copyLlmThemeGuide()"
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"
>
Copy LLM Guide
{{ 'theme.studio.copyLlmGuide' | translate }}
</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
{{ 'theme.studio.importFile' | translate }}
</button>
<button
type="button"
@@ -53,7 +53,7 @@
[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
{{ 'theme.studio.exportFile' | translate }}
</button>
<input
#themeFileInput
@@ -68,14 +68,14 @@
[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"
>
{{ activeEditorTab() === 'cssOnly' ? 'Apply CSS Theme' : 'Apply Draft' }}
{{ (activeEditorTab() === 'cssOnly' ? 'theme.studio.applyCssTheme' : 'theme.studio.applyDraft') | translate }}
</button>
<button
type="button"
(click)="restoreDefaultTheme()"
class="inline-flex items-center rounded-md border border-destructive/25 bg-destructive/10 px-3 py-1.5 text-sm font-medium text-destructive transition-colors hover:bg-destructive/15"
>
Restore Default
{{ 'theme.studio.restoreDefault' | translate }}
</button>
</div>
</div>
@@ -88,21 +88,21 @@
<div class="theme-settings__hero-grid mt-4">
<div class="theme-settings__hero-stat">
<span class="theme-settings__hero-label">Workspace</span>
<span class="theme-settings__hero-label">{{ 'theme.studio.workspace' | translate }}</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>
<span class="theme-settings__hero-label">{{ 'theme.studio.regions' | translate }}</span>
<strong class="theme-settings__hero-value">{{ mountedEntryCount() }}</strong>
</div>
<div class="theme-settings__hero-stat">
<span class="theme-settings__hero-label">Draft</span>
<span class="theme-settings__hero-label">{{ 'theme.studio.draft' | translate }}</span>
<strong
class="theme-settings__hero-value"
[class.text-amber-700]="isDraftDirty()"
[class.text-emerald-700]="!isDraftDirty()"
>
{{ isDraftDirty() ? 'Unsaved changes' : 'In sync' }}
{{ (isDraftDirty() ? 'theme.studio.unsavedChanges' : 'theme.studio.inSync') | translate }}
</strong>
</div>
</div>
@@ -115,7 +115,7 @@
@if (!draftIsValid()) {
<div class="mt-3 rounded-lg border border-destructive/30 bg-destructive/10 p-4">
<p class="text-sm font-semibold text-destructive">The draft is invalid. The last working theme is still active.</p>
<p class="text-sm font-semibold text-destructive">{{ 'theme.studio.invalidDraft' | translate }}</p>
<ul class="mt-2 space-y-1 text-sm text-destructive/90">
@for (error of draftErrors(); track error) {
<li>{{ error }}</li>
@@ -126,9 +126,9 @@
<nav
class="theme-settings__workspace-tabs mt-4"
aria-label="Theme Studio workspace"
[attr.aria-label]="'theme.studio.workspaceAria' | translate"
>
@for (workspace of workspaceTabs; track workspace.key) {
@for (workspace of workspaceTabs(); track workspace.key) {
<button
type="button"
(click)="setWorkspace(workspace.key)"
@@ -147,7 +147,7 @@
for="theme-studio-workspace-select"
class="theme-settings__workspace-selector-label"
>
Workspace
{{ 'theme.studio.workspace' | translate }}
</label>
<select
id="theme-studio-workspace-select"
@@ -155,7 +155,7 @@
[value]="activeWorkspace()"
(change)="onWorkspaceSelect($event)"
>
@for (workspace of workspaceTabs; track workspace.key) {
@for (workspace of workspaceTabs(); track workspace.key) {
<option [value]="workspace.key">{{ workspace.label }}</option>
}
</select>
@@ -166,9 +166,9 @@
<aside class="theme-settings__sidebar">
<section class="theme-studio-card p-3.5">
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-semibold text-foreground">Preset Themes</p>
<p class="text-sm font-semibold text-foreground">{{ 'theme.studio.presetThemes' | translate }}</p>
<span class="rounded-full bg-secondary px-2.5 py-1 text-[11px] font-medium text-muted-foreground">
{{ builtInPresets.length }} built in
{{ 'theme.studio.builtInCount' | translate: { count: builtInPresets.length } }}
</span>
</div>
@@ -182,17 +182,19 @@
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-semibold text-foreground">{{ preset.theme.meta.name }}</p>
<p class="text-sm font-semibold text-foreground">{{ presetDisplayName(preset.key, preset.theme.meta.name) }}</p>
<p class="mt-1 font-mono text-[11px] text-muted-foreground">{{ preset.key }}</p>
</div>
@if (preset.theme.meta.name === 'Toju Default Dark 11') {
<span class="rounded-full bg-primary/12 px-2 py-0.5 text-[10px] font-medium text-primary">Default</span>
@if (isDefaultPresetName(preset.theme.meta.name)) {
<span class="rounded-full bg-primary/12 px-2 py-0.5 text-[10px] font-medium text-primary">{{
'theme.studio.defaultBadge' | translate
}}</span>
}
</div>
@if (preset.theme.meta.description) {
<p class="mt-2 text-xs leading-5 text-muted-foreground">{{ preset.theme.meta.description }}</p>
<p class="mt-2 text-xs leading-5 text-muted-foreground">{{ presetDescription(preset.key, preset.theme.meta.description) }}</p>
}
</button>
}
@@ -202,9 +204,11 @@
@if (savedThemesAvailable()) {
<section class="theme-studio-card p-3.5">
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-semibold text-foreground">Saved Themes</p>
<p class="text-sm font-semibold text-foreground">{{ 'theme.studio.savedThemes' | translate }}</p>
<span class="rounded-full bg-secondary px-2.5 py-1 text-[11px] font-medium text-muted-foreground">
{{ savedThemesBusy() ? 'Syncing' : savedThemes().length + ' saved' }}
{{
savedThemesBusy() ? ('theme.studio.syncing' | translate) : ('theme.studio.savedCount' | translate: { count: savedThemes().length })
}}
</span>
</div>
@@ -215,7 +219,7 @@
[disabled]="!draftIsValid() || savedThemesBusy()"
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 disabled:cursor-not-allowed disabled:opacity-60"
>
Save New
{{ 'theme.studio.saveNew' | translate }}
</button>
<button
type="button"
@@ -223,7 +227,7 @@
[disabled]="!draftIsValid() || !selectedSavedTheme() || savedThemesBusy()"
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 disabled:cursor-not-allowed disabled:opacity-60"
>
Save Selected
{{ 'theme.studio.saveSelected' | translate }}
</button>
<button
type="button"
@@ -231,7 +235,7 @@
[disabled]="!selectedSavedTheme()?.isValid || (savedThemesBusy() && savedThemes().length === 0)"
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 disabled:cursor-not-allowed disabled:opacity-60"
>
Use
{{ 'theme.studio.use' | translate }}
</button>
<button
type="button"
@@ -239,7 +243,7 @@
[disabled]="!selectedSavedTheme()?.isValid || (savedThemesBusy() && savedThemes().length === 0)"
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 disabled:cursor-not-allowed disabled:opacity-60"
>
Edit
{{ 'theme.studio.edit' | translate }}
</button>
<button
type="button"
@@ -247,7 +251,7 @@
[disabled]="!selectedSavedTheme() || (savedThemesBusy() && savedThemes().length === 0)"
class="rounded-md border border-destructive/25 bg-destructive/10 px-3 py-1.5 text-xs font-medium text-destructive transition-colors hover:bg-destructive/15 disabled:cursor-not-allowed disabled:opacity-60"
>
Remove
{{ 'theme.studio.remove' | translate }}
</button>
<button
type="button"
@@ -255,7 +259,7 @@
[disabled]="savedThemesBusy() && savedThemes().length === 0"
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 disabled:cursor-not-allowed disabled:opacity-60"
>
Refresh
{{ 'theme.studio.refresh' | translate }}
</button>
</div>
@@ -275,9 +279,13 @@
</div>
@if (savedTheme.isValid) {
<span class="rounded-full bg-emerald-500/12 px-2 py-0.5 text-[10px] font-medium text-emerald-700">Ready</span>
<span class="rounded-full bg-emerald-500/12 px-2 py-0.5 text-[10px] font-medium text-emerald-700">{{
'theme.studio.ready' | translate
}}</span>
} @else {
<span class="rounded-full bg-destructive/10 px-2 py-0.5 text-[10px] font-medium text-destructive">Invalid</span>
<span class="rounded-full bg-destructive/10 px-2 py-0.5 text-[10px] font-medium text-destructive">{{
'theme.studio.invalid' | translate
}}</span>
}
</div>
@@ -297,7 +305,7 @@
</div>
} @else {
<div class="mt-4 rounded-lg border border-dashed border-border bg-secondary/10 px-4 py-5 text-sm text-muted-foreground">
Save the current draft to create your first reusable Electron theme.
{{ 'theme.studio.emptySavedThemes' | translate }}
</div>
}
@@ -309,9 +317,9 @@
<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>
<p class="text-sm font-semibold text-foreground">{{ 'theme.studio.explorer' | translate }}</p>
<span class="rounded-full bg-secondary px-2.5 py-1 text-[11px] font-medium text-muted-foreground">
{{ filteredEntries().length }} shown
{{ 'theme.studio.explorerShown' | translate: { count: filteredEntries().length } }}
</span>
</div>
@@ -320,13 +328,13 @@
type="text"
[value]="explorerQuery()"
(input)="onExplorerQueryInput($event)"
placeholder="Search theme keys"
[placeholder]="'theme.studio.searchKeysPlaceholder' | translate"
class="theme-settings__search-input mt-2 w-full"
/>
</div>
<div class="theme-settings__entry-list theme-settings__explorer-list mt-4">
@for (entry of filteredEntries(); track entry.key) {
@for (entry of localizedFilteredEntries(); track entry.key) {
<button
type="button"
(click)="selectThemeEntry(entry.key)"
@@ -336,7 +344,9 @@
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-semibold text-foreground">{{ entry.label }}</span>
@if (isMounted(entry)) {
<span class="rounded-full bg-emerald-500/12 px-2 py-0.5 text-[10px] font-medium text-emerald-700">Mounted</span>
<span class="rounded-full bg-emerald-500/12 px-2 py-0.5 text-[10px] font-medium text-emerald-700">{{
'theme.studio.mounted' | translate
}}</span>
}
</div>
<span class="mt-1 block font-mono text-[11px] text-muted-foreground">{{ entry.key }}</span>
@@ -344,7 +354,7 @@
</button>
} @empty {
<div class="rounded-lg border border-dashed border-border bg-secondary/10 px-4 py-5 text-sm text-muted-foreground">
No registered theme keys match this filter.
{{ 'theme.studio.noKeysMatch' | translate }}
</div>
}
</div>
@@ -355,17 +365,17 @@
<section class="theme-studio-card p-3.5">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
@if (selectedElement()) {
@if (localizedSelectedElement(); as element) {
<div class="flex flex-wrap items-center gap-2">
<span class="text-base font-semibold text-foreground">{{ selectedElement()!.label }}</span>
<span class="rounded-full bg-secondary px-2 py-0.5 font-mono text-[11px] text-muted-foreground">{{ selectedElement()!.key }}</span>
<span class="text-base font-semibold text-foreground">{{ element.label }}</span>
<span class="rounded-full bg-secondary px-2 py-0.5 font-mono text-[11px] text-muted-foreground">{{ element.key }}</span>
@if (selectedElement()!.container) {
<span class="rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground">{{
selectedElement()!.container
}}</span>
}
</div>
<p class="mt-2 text-sm leading-6 text-muted-foreground">{{ selectedElement()!.description }}</p>
<p class="mt-2 text-sm leading-6 text-muted-foreground">{{ element.description }}</p>
}
</div>
@@ -376,7 +386,7 @@
(click)="jumpToStyles()"
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"
>
Open styles in JSON
{{ 'theme.studio.openStylesJson' | translate }}
</button>
@if (selectedElement()!.layoutEditable) {
<button
@@ -384,7 +394,7 @@
(click)="jumpToLayout()"
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"
>
Open layout in JSON
{{ 'theme.studio.openLayoutJson' | translate }}
</button>
}
</div>
@@ -405,24 +415,24 @@
<div class="flex flex-wrap items-start justify-between gap-4 border-b border-border pb-3">
<div class="min-w-0">
<p class="text-sm font-semibold text-foreground">
{{ activeEditorTab() === 'cssOnly' ? 'CSS-Only Theme' : 'Theme JSON' }}
{{ (activeEditorTab() === 'cssOnly' ? 'theme.studio.cssOnlyTheme' : 'theme.studio.themeJson') | translate }}
</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>
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ 'theme.studio.cssOnlyHint' | translate }}</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"
>{{ 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
>
<span class="rounded-md bg-secondary px-2.5 py-1">{{
'theme.studio.lines' | translate: { count: activeEditorTab() === 'cssOnly' ? cssOnlyText().split('\n').length : draftLineCount() }
}}</span>
<span class="rounded-md bg-secondary px-2.5 py-1">{{
'theme.studio.chars' | translate: { count: activeEditorTab() === 'cssOnly' ? cssOnlyText().length : draftCharacterCount() }
}}</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">{{ 'theme.studio.errors' | translate: { count: draftErrorCount() } }}</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">{{ 'theme.studio.ideEditor' | translate }}</span>
</div>
</div>
@@ -435,7 +445,7 @@
[class.bg-primary/10]="activeEditorTab() === 'json'"
[attr.aria-current]="activeEditorTab() === 'json' ? 'page' : null"
>
JSON Theme
{{ 'theme.studio.jsonTheme' | translate }}
</button>
<button
type="button"
@@ -445,7 +455,7 @@
[class.bg-primary/10]="activeEditorTab() === 'cssOnly'"
[attr.aria-current]="activeEditorTab() === 'cssOnly' ? 'page' : null"
>
CSS Only
{{ 'theme.studio.cssOnly' | translate }}
</button>
</div>
@@ -475,34 +485,36 @@
<div class="space-y-4">
<section class="theme-studio-card p-4">
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-semibold text-foreground">Selection</p>
<p class="text-sm font-semibold text-foreground">{{ 'theme.studio.selection' | translate }}</p>
<button
type="button"
(click)="startPicker()"
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"
>
Pick live element
{{ 'theme.studio.pickLiveElement' | translate }}
</button>
</div>
@if (selectedElement()) {
@if (localizedSelectedElement(); as element) {
<div class="mt-4 rounded-lg border border-border/80 bg-secondary/20 p-4">
<div class="flex items-center gap-2">
<p class="text-base font-semibold text-foreground">{{ selectedElement()!.label }}</p>
<span class="rounded-full bg-secondary px-2 py-0.5 font-mono text-[11px] text-muted-foreground">{{ selectedElement()!.key }}</span>
<p class="text-base font-semibold text-foreground">{{ element.label }}</p>
<span class="rounded-full bg-secondary px-2 py-0.5 font-mono text-[11px] text-muted-foreground">{{ element.key }}</span>
@if (isMounted(selectedElement()!)) {
<span class="rounded-full bg-emerald-500/12 px-2 py-0.5 text-[11px] font-medium text-emerald-700">Mounted now</span>
<span class="rounded-full bg-emerald-500/12 px-2 py-0.5 text-[11px] font-medium text-emerald-700">{{
'theme.studio.mountedNow' | translate
}}</span>
}
</div>
<p class="mt-2 text-sm leading-6 text-muted-foreground">{{ selectedElement()!.description }}</p>
<p class="mt-2 text-sm leading-6 text-muted-foreground">{{ element.description }}</p>
</div>
}
</section>
<section class="theme-studio-card p-4">
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-semibold text-foreground">Editable Attributes</p>
<p class="text-sm font-semibold text-foreground">{{ 'theme.studio.editableAttributes' | translate }}</p>
<button
type="button"
@@ -510,7 +522,7 @@
[disabled]="!draftIsValid()"
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 disabled:cursor-not-allowed disabled:opacity-60"
>
Add fade animation
{{ 'theme.studio.addFadeAnimation' | translate }}
</button>
</div>
@@ -538,7 +550,7 @@
</section>
<section class="theme-studio-card p-4">
<p class="text-sm font-semibold text-foreground">Animation Keys</p>
<p class="text-sm font-semibold text-foreground">{{ 'theme.studio.animationKeys' | translate }}</p>
@if (animationKeys().length > 0) {
<div class="mt-4 flex flex-wrap gap-2">
@@ -554,7 +566,7 @@
</div>
} @else {
<div class="mt-4 rounded-lg border border-dashed border-border bg-secondary/10 px-4 py-5 text-sm text-muted-foreground">
No custom animation keys yet.
{{ 'theme.studio.noAnimationKeys' | translate }}
</div>
}
@@ -572,7 +584,7 @@
@if (activeWorkspace() === 'layout') {
<section class="theme-studio-card p-4">
<div class="flex flex-wrap items-start justify-between gap-4">
<p class="text-sm font-semibold text-foreground">Layout Grid</p>
<p class="text-sm font-semibold text-foreground">{{ 'theme.studio.layoutGrid' | translate }}</p>
<div class="flex flex-wrap gap-2">
@for (container of layoutContainers; track container.key) {
@@ -583,7 +595,7 @@
[class.bg-primary/10]="selectedContainer() === container.key"
[class.border-primary/40]="selectedContainer() === container.key"
>
{{ container.label }}
{{ layoutContainerLabel(container.key) }}
</button>
}
@@ -593,7 +605,7 @@
[disabled]="!draftIsValid()"
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 disabled:cursor-not-allowed disabled:opacity-60"
>
Reset Container
{{ 'theme.studio.resetContainer' | translate }}
</button>
</div>
</div>

View File

@@ -9,6 +9,7 @@ import {
import { CommonModule } from '@angular/common';
import { SettingsModalService } from '../../../../../core/services/settings-modal.service';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../../core/i18n';
import { getElectronApi } from '../../../../../core/platform/electron/get-electron-api';
import {
ThemeContainerKey,
@@ -40,7 +41,8 @@ type ThemeStudioWorkspace = 'editor' | 'inspector' | 'layout';
imports: [
CommonModule,
ThemeGridEditorComponent,
ThemeJsonCodeEditorComponent
ThemeJsonCodeEditorComponent,
...APP_TRANSLATE_IMPORTS
],
templateUrl: './theme-settings.component.html',
styleUrl: './theme-settings.component.scss'
@@ -52,6 +54,7 @@ export class ThemeSettingsComponent {
readonly registry = inject(ThemeRegistryService);
readonly picker = inject(ElementPickerService);
readonly layoutSync = inject(LayoutSyncService);
private readonly appI18n = inject(AppI18nService);
readonly editorRef = viewChild<ThemeJsonCodeEditorComponent>('jsonEditorRef');
@@ -68,23 +71,23 @@ export class ThemeSettingsComponent {
readonly animationKeys = this.theme.knownAnimationClasses;
readonly layoutContainers = this.layoutSync.containers();
readonly themeEntries = this.registry.entries();
readonly workspaceTabs: readonly { key: ThemeStudioWorkspace; label: string; description: string }[] = [
readonly workspaceTabs = computed(() => [
{
key: 'editor',
label: 'JSON Editor',
description: 'Edit the raw theme document in a fixed-contrast code view.'
key: 'editor' as const,
label: this.appI18n.instant('theme.studio.workspaces.editor.label'),
description: this.appI18n.instant('theme.studio.workspaces.editor.description')
},
{
key: 'inspector',
label: 'Element Inspector',
description: 'Browse themeable regions, supported overrides, and starter values.'
key: 'inspector' as const,
label: this.appI18n.instant('theme.studio.workspaces.inspector.label'),
description: this.appI18n.instant('theme.studio.workspaces.inspector.description')
},
{
key: 'layout',
label: 'Layout Studio',
description: 'Move shells around the grid without hunting through JSON.'
key: 'layout' as const,
label: this.appI18n.instant('theme.studio.workspaces.layout.label'),
description: this.appI18n.instant('theme.studio.workspaces.layout.description')
}
];
]);
readonly mountedKeyCounts = computed(() => this.registry.mountedKeyCounts());
readonly activeWorkspace = signal<ThemeStudioWorkspace>('editor');
readonly activeEditorTab = signal<ThemeEditorTab>('json');
@@ -102,10 +105,10 @@ export class ThemeSettingsComponent {
}
return [
selected.layoutEditable ? 'Layout editable' : null,
selected.supportsTextOverride ? 'Text override' : null,
selected.supportsLink ? 'Safe external link' : null,
selected.supportsIcon ? 'Icon slot' : null
selected.layoutEditable ? this.appI18n.instant('theme.studio.capabilities.layoutEditable') : null,
selected.supportsTextOverride ? this.appI18n.instant('theme.studio.capabilities.textOverride') : null,
selected.supportsLink ? this.appI18n.instant('theme.studio.capabilities.safeExternalLink') : null,
selected.supportsIcon ? this.appI18n.instant('theme.studio.capabilities.iconSlot') : null
].filter((value): value is string => value !== null);
});
readonly selectedContainerItems = computed(() => this.layoutSync.itemsForContainer(this.selectedContainer()));
@@ -116,7 +119,15 @@ export class ThemeSettingsComponent {
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];
return this.workspaceTabs().find((workspace) => workspace.key === this.activeWorkspace()) ?? this.workspaceTabs()[0];
});
readonly localizedFilteredEntries = computed(() =>
this.filteredEntries().map((entry) => this.localizeRegistryEntry(entry))
);
readonly localizedSelectedElement = computed(() => {
const selected = this.selectedElement();
return selected ? this.localizeRegistryEntry(selected) : null;
});
readonly visiblePropertyHints = computed(() => {
const selected = this.selectedElement();
@@ -260,6 +271,10 @@ export class ThemeSettingsComponent {
this.setWorkspace(select.value as ThemeStudioWorkspace);
}
layoutContainerLabel(key: string): string {
return this.appI18n.instant(`theme.registry.${key}.label`);
}
onExplorerQueryInput(event: Event): void {
const input = event.target as HTMLInputElement;
@@ -281,7 +296,9 @@ export class ThemeSettingsComponent {
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.');
this.theme.announceStatus(saved
? this.appI18n.instant('theme.studio.exported', { fileName })
: this.appI18n.instant('theme.studio.exportCancelled'));
}
importThemeFile(event: Event): void {
@@ -300,7 +317,9 @@ export class ThemeSettingsComponent {
async copyLlmThemeGuide(): Promise<void> {
const copied = await this.copyTextToClipboard(THEME_LLM_GUIDE);
this.setLlmGuideCopyMessage(copied ? 'LLM guide copied.' : 'Manual copy opened.');
this.setLlmGuideCopyMessage(copied
? this.appI18n.instant('theme.studio.llmGuideCopied')
: this.appI18n.instant('theme.studio.llmGuideManualCopy'));
}
startPicker(): void {
@@ -501,7 +520,7 @@ export class ThemeSettingsComponent {
}
if (!this.draftIsValid()) {
this.theme.announceStatus('Fix JSON errors before exporting the theme.');
this.theme.announceStatus(this.appI18n.instant('theme.studio.fixJsonBeforeExport'));
return null;
}
@@ -532,7 +551,7 @@ export class ThemeSettingsComponent {
this.syncCssOnlyTextFromTheme();
this.focusEditor();
} catch {
this.theme.announceStatus(`Unable to import ${file.name}.`);
this.theme.announceStatus(this.appI18n.instant('theme.status.importFailed', { fileName: file.name }));
}
}
@@ -773,13 +792,13 @@ export class ThemeSettingsComponent {
return true;
}
} catch {
window.prompt('Copy this LLM theme guide', value);
window.prompt(this.appI18n.instant('theme.studio.llmGuidePrompt'), value);
return false;
} finally {
document.body.removeChild(textarea);
}
window.prompt('Copy this LLM theme guide', value);
window.prompt(this.appI18n.instant('theme.studio.llmGuidePrompt'), value);
return false;
}
@@ -796,6 +815,34 @@ export class ThemeSettingsComponent {
}, 4000);
}
presetDisplayName(presetKey: string, fallbackName: string): string {
const localized = this.appI18n.instant(`theme.presets.${presetKey}.name`);
return localized === `theme.presets.${presetKey}.name` ? fallbackName : localized;
}
presetDescription(presetKey: string, fallbackDescription?: string): string | undefined {
if (!fallbackDescription) {
return undefined;
}
const localized = this.appI18n.instant(`theme.presets.${presetKey}.description`);
return localized === `theme.presets.${presetKey}.description` ? fallbackDescription : localized;
}
isDefaultPresetName(name: string): boolean {
return name === this.appI18n.instant('theme.presets.toju-default-dark-11.name');
}
private localizeRegistryEntry(entry: ThemeRegistryEntry): ThemeRegistryEntry {
return {
...entry,
label: this.appI18n.instant(`theme.registry.${entry.key}.label`),
description: this.appI18n.instant(`theme.registry.${entry.key}.description`)
};
}
private findAnchorIndex(text: string, section: JumpSection, key: string): number {
const sectionAnchor = `"${section}": {`;
const sectionIndex = text.indexOf(sectionAnchor);

View File

@@ -3,11 +3,11 @@
<div class="pointer-events-auto max-w-xl rounded-lg border border-border bg-card px-4 py-3 shadow-lg backdrop-blur">
<div class="flex flex-wrap items-center gap-3">
<div class="min-w-0 flex-1">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-primary">Theme Picker Active</p>
<p class="mt-1 text-sm text-foreground">Click a highlighted area to inspect its theme key.</p>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-primary">{{ 'theme.pickerOverlay.activeBadge' | translate }}</p>
<p class="mt-1 text-sm text-foreground">{{ 'theme.pickerOverlay.instruction' | translate }}</p>
<p class="mt-1 text-xs text-muted-foreground">
Hovering:
<span class="font-medium text-foreground">{{ hoveredEntry()?.label || 'Move over a themeable region' }}</span>
{{ 'theme.pickerOverlay.hovering' | translate }}
<span class="font-medium text-foreground">{{ hoveredEntry()?.label || ('theme.pickerOverlay.hoverFallback' | translate) }}</span>
@if (hoveredEntry()) {
<span class="ml-1 rounded-md bg-secondary px-2 py-0.5 font-mono text-[11px] text-foreground/80">
{{ hoveredEntry()!.key }}
@@ -21,7 +21,7 @@
(click)="cancel()"
class="inline-flex items-center rounded-md border border-border bg-secondary px-3 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
>
Cancel
{{ 'common.cancel' | translate }}
</button>
</div>
</div>

View File

@@ -6,12 +6,16 @@ import {
import { CommonModule } from '@angular/common';
import { ElementPickerService } from '../../application/services/element-picker.service';
import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { ThemeRegistryService } from '../../application/services/theme-registry.service';
@Component({
selector: 'app-theme-picker-overlay',
standalone: true,
imports: [CommonModule],
imports: [
CommonModule,
...APP_TRANSLATE_IMPORTS
],
templateUrl: './theme-picker-overlay.component.html'
})
export class ThemePickerOverlayComponent {