From a685b891e413728e142e734bf9f31b2cc8cea40a Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:10:16 -0400 Subject: [PATCH] changes (theres too many) --- src/main/index.ts | 27 ++- src/main/profileLibrary.ts | 3 - src/main/themeLibrary.ts | 112 +++++----- src/preload/index.ts | 1 + src/renderer/components/BottomBar.tsx | 73 ++++++- src/renderer/env.d.ts | 1 + src/renderer/stores/profileDraft.ts | 2 - src/renderer/stores/settingsStore.ts | 46 +--- src/renderer/stores/themeStore.ts | 4 +- src/renderer/styles/globals.css | 30 ++- src/renderer/visualizers/Oscilloscope.ts | 11 +- src/renderer/visualizers/Vectorscope.ts | 8 +- src/shared/profileState.ts | 15 -- src/shared/themeState.ts | 258 +++++++++++++---------- src/types/profile.ts | 3 +- src/types/theme.ts | 4 - test/profile-library.test.ts | 78 ++++--- test/renderer-helpers.test.ts | 190 ++++++++++++++--- test/theme-library.test.ts | 139 ++++++++---- 19 files changed, 663 insertions(+), 342 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 679d40c..6fa0dd7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -87,7 +87,6 @@ function getProfileLibrary(): FileBackedProfileLibrary { profileLibrary = new FileBackedProfileLibrary( join(app.getPath('documents'), 'Prism Profiles'), join(app.getPath('userData'), 'profile-state.json'), - async () => getThemeLibrary().getActiveThemeId(), ) } @@ -193,6 +192,23 @@ function getErrorMessage(error: unknown, fallback: string): string { : fallback } +function normalizeExternalHttpUrl(raw: string): string | null { + if (typeof raw !== 'string' || !raw.trim()) { + return null + } + + try { + const parsed = new URL(raw.trim()) + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + return parsed.toString() + } + } catch { + return null + } + + return null +} + async function processPendingProfileOpenPaths(): Promise { if (pendingProfileOpenPaths.length === 0) return if (!mainRendererReady || !mainWindow || mainWindow.isDestroyed()) return @@ -1252,6 +1268,15 @@ function setupIPC(): void { return migration }) + ipcMain.handle('shell:open-external', async (_event, rawUrl: string) => { + const url = normalizeExternalHttpUrl(rawUrl) + if (!url) { + throw new Error('Invalid external URL.') + } + + await shell.openExternal(url) + }) + ipcMain.on('profile-menu:open', (event, rawRequest: unknown) => { const request = normalizeProfileMenuRequest(rawRequest) if (!request) return diff --git a/src/main/profileLibrary.ts b/src/main/profileLibrary.ts index 4a3aef1..7afd4ca 100644 --- a/src/main/profileLibrary.ts +++ b/src/main/profileLibrary.ts @@ -45,7 +45,6 @@ export class FileBackedProfileLibrary { constructor( private readonly profilesDir: string, private readonly localStatePath: string, - private readonly resolveDefaultThemeId?: () => Promise, ) {} getProfilesDirectory(): string { @@ -271,9 +270,7 @@ export class FileBackedProfileLibrary { let entries = await this.readManagedEntries(localState) if (!entries.some((entry) => entry.id === DEFAULT_PROFILE_ID)) { - const defaultThemeId = await this.resolveDefaultThemeId?.() ?? null const defaultProfile = createDefaultProfile(DEFAULT_PROFILE_NAME) - defaultProfile.themeId = defaultThemeId const defaultPath = await this.writeManagedProfile(entries, DEFAULT_PROFILE_ID, defaultProfile) entries = await this.readManagedEntries({ ...localState, diff --git a/src/main/themeLibrary.ts b/src/main/themeLibrary.ts index ff8e221..a95a5ba 100644 --- a/src/main/themeLibrary.ts +++ b/src/main/themeLibrary.ts @@ -1,8 +1,6 @@ import { access, mkdir, readFile, readdir, unlink, writeFile } from 'node:fs/promises' import { basename, dirname, extname, isAbsolute, join, relative, resolve } from 'node:path' -import { randomUUID } from 'node:crypto' import { - DEFAULT_THEME_ID, DEFAULT_THEME_NAME, LEGACY_THEME_MIGRATION_VERSION, type LegacyThemeMigrationPayload, @@ -17,8 +15,11 @@ import { createEmptyThemeLocalState, createMigratedAccentTheme, createTemplateThemeFile, + extractLegacyThemeFileId, getDefaultThemeIdForLocalState, normalizeLegacyThemePayload, + resolveLegacyThemeFileId, + normalizeTheme, normalizeThemeLocalState, parseThemeFileContent, resolveLegacyThemeToPresetId, @@ -32,6 +33,7 @@ interface ManagedThemeEntry { id: string path: string theme: PrismTheme + legacyId: string | null } export class FileBackedThemeLibrary { @@ -67,23 +69,24 @@ export class FileBackedThemeLibrary { async importThemeFromPath(sourcePath: string): Promise { const { entries, localState } = await this.loadLibrary() const resolvedSourcePath = resolve(sourcePath) - const theme = await this.readThemeFile(resolvedSourcePath) - const existingEntry = entries.find((entry) => entry.id === theme.id) ?? null + const { theme } = await this.readThemeFile(resolvedSourcePath) + const existingEntry = entries.find((entry) => entry.id === theme.name) ?? null const insideManagedDirectory = this.isPathInsideDirectory(resolvedSourcePath, this.themesDir) const currentPath = existingEntry?.path ?? (insideManagedDirectory ? resolvedSourcePath : undefined) - const targetPath = await this.writeManagedTheme(entries, theme.id, theme, currentPath) + const targetPath = await this.writeManagedTheme(entries, theme.name, theme, currentPath) + const targetKey = basename(targetPath, THEME_EXTENSION) if (insideManagedDirectory && resolvedSourcePath !== targetPath) { await this.unlinkIfExists(resolvedSourcePath) } - localState.activeThemeId = theme.id + localState.activeThemeId = targetKey await this.writeLocalState(localState) return this.getSnapshot() } async renameTheme(id: string, name: string): Promise { - if (id === DEFAULT_THEME_ID) { + if (id === DEFAULT_THEME_NAME) { throw new Error('The default theme cannot be renamed.') } @@ -93,12 +96,17 @@ export class FileBackedThemeLibrary { ...entry.theme, name: name.trim() || entry.theme.name, } - await this.writeManagedTheme(entries, id, normalized, entry.path) + const targetPath = await this.writeManagedTheme(entries, id, normalized, entry.path) + const targetKey = basename(targetPath, THEME_EXTENSION) + if (localState.activeThemeId === id) { + localState.activeThemeId = targetKey + await this.writeLocalState(localState) + } return this.buildSnapshot(await this.readManagedEntries(), localState) } async deleteTheme(id: string): Promise { - if (id === DEFAULT_THEME_ID) { + if (id === DEFAULT_THEME_NAME) { throw new Error('The default theme cannot be deleted.') } @@ -106,7 +114,7 @@ export class FileBackedThemeLibrary { const entry = this.findEntry(entries, id) await unlink(entry.path) if (localState.activeThemeId === id) { - localState.activeThemeId = DEFAULT_THEME_ID + localState.activeThemeId = DEFAULT_THEME_NAME } await this.writeLocalState(localState) return this.getSnapshot() @@ -132,8 +140,8 @@ export class FileBackedThemeLibrary { if (normalizedPayload.customAccent) { const migratedTheme = createMigratedAccentTheme(normalizedPayload.customAccent) if (migratedTheme) { - await this.writeManagedTheme(entries, migratedTheme.id, migratedTheme) - nextActiveThemeId = migratedTheme.id + await this.writeManagedTheme(entries, migratedTheme.name, migratedTheme) + nextActiveThemeId = migratedTheme.name didMigrate = true } } else if (nextActiveThemeId) { @@ -162,7 +170,7 @@ export class FileBackedThemeLibrary { if (entries.length === 0) { for (const theme of createBundledThemes()) { - await this.writeManagedTheme(entries, theme.id, theme) + await this.writeManagedTheme(entries, theme.name, theme) } entries = await this.readManagedEntries() } @@ -171,10 +179,23 @@ export class FileBackedThemeLibrary { await this.ensureTemplateFile() + if (localState.activeThemeId && !entries.some((entry) => entry.id === localState.activeThemeId)) { + const presetName = resolveLegacyThemeFileId(localState.activeThemeId) + const migratedEntry = entries.find((entry) => entry.legacyId === localState.activeThemeId) + ?? (presetName ? entries.find((entry) => entry.id === presetName) ?? null : null) + if (migratedEntry) { + localState = { + ...localState, + activeThemeId: migratedEntry.id, + } + await this.writeLocalState(localState) + } + } + if (!localState.activeThemeId || !entries.some((entry) => entry.id === localState.activeThemeId)) { localState = { ...localState, - activeThemeId: entries.find((entry) => entry.id === DEFAULT_THEME_ID)?.id ?? entries[0]?.id ?? null, + activeThemeId: entries.find((entry) => entry.id === DEFAULT_THEME_NAME)?.id ?? entries[0]?.id ?? null, } await this.writeLocalState(localState) } @@ -189,16 +210,16 @@ export class FileBackedThemeLibrary { let nextEntries = entries for (const theme of createBundledThemes()) { - const existingEntry = nextEntries.find((entry) => entry.id === theme.id) ?? null + const existingEntry = nextEntries.find((entry) => entry.id === theme.name) ?? null const shouldWrite = !existingEntry || serializeThemeFile(existingEntry.theme) !== serializeThemeFile(theme) if (!shouldWrite) continue - await this.writeManagedTheme(nextEntries, theme.id, theme, existingEntry?.path) + await this.writeManagedTheme(nextEntries, theme.name, theme, existingEntry?.path) nextEntries = await this.readManagedEntries() } - if (!nextEntries.some((entry) => entry.id === DEFAULT_THEME_ID)) { - await this.writeManagedTheme(nextEntries, DEFAULT_THEME_ID, createDefaultTheme()) + if (!nextEntries.some((entry) => entry.id === DEFAULT_THEME_NAME)) { + await this.writeManagedTheme(nextEntries, DEFAULT_THEME_NAME, createDefaultTheme()) nextEntries = await this.readManagedEntries() } @@ -235,17 +256,18 @@ export class FileBackedThemeLibrary { for (const filePath of themePaths) { try { - const theme = await this.readThemeFile(filePath) - if (seenIds.has(theme.id)) { - console.warn(`Skipping duplicate theme id "${theme.id}" in ${basename(filePath)}.`) - continue - } - seenIds.add(theme.id) - entries.push({ - id: theme.id, - path: filePath, - theme, - }) + const { theme, legacyId } = await this.readThemeFile(filePath) + if (seenIds.has(theme.name)) { + console.warn(`Skipping duplicate theme key "${theme.name}" in ${basename(filePath)}.`) + continue + } + seenIds.add(theme.name) + entries.push({ + id: theme.name, + path: filePath, + theme, + legacyId, + }) } catch (error) { console.warn(`Skipping invalid theme file at ${filePath}:`, error) } @@ -254,13 +276,12 @@ export class FileBackedThemeLibrary { return entries } - private async readThemeFile(filePath: string): Promise { + private async readThemeFile(filePath: string): Promise<{ theme: PrismTheme; legacyId: string | null }> { const content = await readFile(filePath, 'utf8') - return parseThemeFileContent( - content, - this.buildFallbackThemeId(filePath), - basename(filePath, THEME_EXTENSION), - ) + return { + theme: parseThemeFileContent(content, basename(filePath, THEME_EXTENSION)), + legacyId: extractLegacyThemeFileId(content), + } } private async readLocalState(): Promise { @@ -283,11 +304,12 @@ export class FileBackedThemeLibrary { theme: PrismTheme, currentPath?: string, ): Promise { - const nextPath = await this.getManagedThemePath(entries, id, theme.name, currentPath) + const normalizedTheme = normalizeTheme(theme, theme.name) + const nextPath = await this.getManagedThemePath(entries, id, normalizedTheme.name, currentPath) const existingPath = currentPath ? resolve(currentPath) : null await mkdir(dirname(nextPath), { recursive: true }) - await writeFile(nextPath, serializeThemeFile(theme), 'utf8') + await writeFile(nextPath, serializeThemeFile(normalizedTheme), 'utf8') if (existingPath && existingPath !== nextPath) { await this.unlinkIfExists(existingPath) @@ -302,7 +324,7 @@ export class FileBackedThemeLibrary { name: string, currentPath?: string, ): Promise { - if (id === DEFAULT_THEME_ID) { + if (id === DEFAULT_THEME_NAME) { const defaultPath = resolve(join(this.themesDir, `${DEFAULT_THEME_NAME}${THEME_EXTENSION}`)) if (!currentPath || resolve(currentPath) === defaultPath) { return defaultPath @@ -332,8 +354,8 @@ export class FileBackedThemeLibrary { private sortEntries(entries: ManagedThemeEntry[]): ManagedThemeEntry[] { return [...entries].sort((left, right) => { - if (left.id === DEFAULT_THEME_ID) return -1 - if (right.id === DEFAULT_THEME_ID) return 1 + if (left.id === DEFAULT_THEME_NAME) return -1 + if (right.id === DEFAULT_THEME_NAME) return 1 return left.theme.name.localeCompare(right.theme.name) }) } @@ -348,7 +370,7 @@ export class FileBackedThemeLibrary { themes, activeThemeId: localState.activeThemeId && themes[localState.activeThemeId] ? localState.activeThemeId - : (themes[DEFAULT_THEME_ID] ? DEFAULT_THEME_ID : Object.keys(themes)[0] ?? null), + : (themes[DEFAULT_THEME_NAME] ? DEFAULT_THEME_NAME : Object.keys(themes)[0] ?? null), } } @@ -370,14 +392,6 @@ export class FileBackedThemeLibrary { return sanitized || 'Theme' } - private buildFallbackThemeId(filePath: string): string { - const stem = basename(filePath, THEME_EXTENSION) - .toLowerCase() - .replace(/[^a-z0-9]+/g, '_') - .replace(/^_+|_+$/g, '') - return stem ? `theme_${stem}` : `theme_${randomUUID().replace(/-/g, '')}` - } - private isPathInsideDirectory(candidatePath: string, directoryPath: string): boolean { const relativePath = relative(resolve(directoryPath), resolve(candidatePath)) return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath)) diff --git a/src/preload/index.ts b/src/preload/index.ts index c1ddf6b..711e50e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -74,6 +74,7 @@ contextBridge.exposeInMainWorld('electronAPI', { importThemeDialog: () => ipcRenderer.invoke('themes:import-dialog') as Promise, revealThemesFolder: () => ipcRenderer.invoke('themes:reveal-folder') as Promise, migrateLegacyTheme: (payload: LegacyThemeMigrationPayload) => ipcRenderer.invoke('themes:migrate-legacy', payload) as Promise, + openExternalUrl: (url: string) => ipcRenderer.invoke('shell:open-external', url) as Promise, expandSettings: (panelHeight: number) => ipcRenderer.send('window:expand-settings', panelHeight), collapseSettings: (panelHeight: number) => ipcRenderer.send('window:collapse-settings', panelHeight), setSettingsHeight: (panelHeight: number) => ipcRenderer.send('window:set-settings-height', panelHeight), diff --git a/src/renderer/components/BottomBar.tsx b/src/renderer/components/BottomBar.tsx index 4ca8cff..bbbe073 100644 --- a/src/renderer/components/BottomBar.tsx +++ b/src/renderer/components/BottomBar.tsx @@ -45,6 +45,43 @@ function getErrorMessage(error: unknown, fallback: string): string { : fallback } +type ThemeCreditSource = { + credit?: string + website?: string +} | null | undefined + +export function resolveThemeCreditDetails(theme: ThemeCreditSource): { + credit: string | null + url: string | null +} { + const credit = typeof theme?.credit === 'string' && theme.credit.trim() + ? theme.credit.trim() + : null + + if (!credit) { + return { credit: null, url: null } + } + + const website = typeof theme?.website === 'string' && theme.website.trim() + ? theme.website.trim() + : null + + if (!website) { + return { credit, url: null } + } + + try { + const parsed = new URL(website) + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + return { credit, url: parsed.toString() } + } + } catch { + // Invalid URLs fall back to plain credit text. + } + + return { credit, url: null } +} + export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): JSX.Element { const rootRef = useRef(null) const [astraBaseUrlInput, setAstraBaseUrlInput] = useState('') @@ -57,9 +94,9 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): const frameTarget = usePerformanceStore((s) => s.frameTarget) const dockedRenderFps = usePerformanceStore((s) => s.dockedRenderFps) const setFrameTarget = usePerformanceStore((s) => s.setFrameTarget) - const setThemeId = useSettingsStore((s) => s.setThemeId) const { themes, + activeTheme, activeThemeId, loadTheme, reloadThemes, @@ -164,10 +201,10 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): const trimPercent = Math.min(100, Math.max(0, ((inputGainDb + 12) / 24) * 100)) const roundedDockedRenderFps = Math.max(0, Math.round(dockedRenderFps)) const themeEntries = Object.entries(themes) + const themeCredit = resolveThemeCreditDetails(activeTheme) const handleThemeChange = async (value: string): Promise => { await loadTheme(value) - setThemeId(value) } const handleSaveAstraConfig = async (): Promise => { @@ -236,6 +273,18 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): } } + const handleOpenThemeWebsite = async (url: string): Promise => { + try { + await window.electronAPI.openExternalUrl(url) + } catch (error) { + showBanner({ + tone: 'error', + message: getErrorMessage(error, 'Could not open the theme website.'), + actions: [], + }) + } + } + const handleRailWheel = (event: WheelEvent): void => { const railElement = event.currentTarget const target = event.target @@ -300,7 +349,25 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
-
Theme
+
+
Theme
+ {themeCredit.credit ? ( + themeCredit.url ? ( + { + event.preventDefault() + void handleOpenThemeWebsite(themeCredit.url!) + }} + > + By {themeCredit.credit} + + ) : ( + By {themeCredit.credit} + ) + ) : null} +
Promise revealThemesFolder: () => Promise migrateLegacyTheme: (payload: LegacyThemeMigrationPayload) => Promise + openExternalUrl: (url: string) => Promise expandSettings: (panelHeight: number) => void collapseSettings: (panelHeight: number) => void setSettingsHeight: (panelHeight: number) => void diff --git a/src/renderer/stores/profileDraft.ts b/src/renderer/stores/profileDraft.ts index 91f8ab6..d647c27 100644 --- a/src/renderer/stores/profileDraft.ts +++ b/src/renderer/stores/profileDraft.ts @@ -9,7 +9,6 @@ import { } from '../../shared/profileState' export interface ProfileDraftSource { - themeId: string | null scopeOrder: ScopeKind[] hiddenScopes: Iterable widthWeights: Record @@ -24,7 +23,6 @@ export function buildProfileDraft( ): Profile { return normalizeProfile({ name, - themeId: source.themeId, scopeOrder: [...source.scopeOrder], hiddenScopes: Array.from(source.hiddenScopes), widthWeights: { ...source.widthWeights }, diff --git a/src/renderer/stores/settingsStore.ts b/src/renderer/stores/settingsStore.ts index ae5c74d..a780c31 100644 --- a/src/renderer/stores/settingsStore.ts +++ b/src/renderer/stores/settingsStore.ts @@ -18,7 +18,6 @@ import { normalizeScopePopouts, normalizeWidthWeights, } from '../../shared/profileState' -import { useThemeStore } from './themeStore' import { buildProfileDraft, profilesMatch } from './profileDraft' import { useUiStore } from './uiStore' @@ -30,7 +29,6 @@ const ACTIVE_PROFILE_KEY = 'prism:activeProfile' const PROFILE_GEOMETRY_SYNC_WINDOW_MS = 800 interface PersistedSettingsState { - themeId: string | null scopeOrder: ScopeKind[] hiddenScopes: ScopeKind[] widthWeights: Record @@ -40,7 +38,6 @@ interface PersistedSettingsState { } interface WorkingSettingsState { - themeId: string | null scopeOrder: ScopeKind[] hiddenScopes: Set widthWeights: Record @@ -58,7 +55,6 @@ interface SettingsState extends WorkingSettingsState { geometrySyncUntil: number initializeProfiles: () => Promise applyExternalProfileSnapshot: (snapshot: ProfileLibrarySnapshot) => void - setThemeId: (themeId: string | null) => void toggleScope: (kind: ScopeKind) => void moveDockedScope: (kind: ScopeKind, direction: 'left' | 'right') => void setScopeWidthWeight: (kind: ScopeKind, weight: number) => void @@ -122,7 +118,6 @@ function saveToStorage(state: WorkingSettingsState): void { try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ - themeId: state.themeId, scopeOrder: state.scopeOrder, hiddenScopes: Array.from(state.hiddenScopes), widthWeights: state.widthWeights, @@ -140,8 +135,7 @@ function persistWorkingState(state: WorkingSettingsState): void { } function hasPersistedWorkingState(state: Partial): boolean { - return 'themeId' in state - || 'scopeOrder' in state + return 'scopeOrder' in state || 'hiddenScopes' in state || 'widthWeights' in state || 'scopeSettings' in state @@ -179,24 +173,10 @@ function clearLegacyProfileStorage(): void { } } -function normalizeLoadedProfileForBaseline(profile: Profile, activeProfileId: string | null): Profile { - const normalizedProfile = normalizeProfile(profile, profile.name) - if (activeProfileId !== DEFAULT_PROFILE_ID || normalizedProfile.themeId) { - return normalizedProfile - } - - const activeThemeId = useThemeStore.getState().activeThemeId - return normalizeProfile({ - ...normalizedProfile, - themeId: activeThemeId, - }, normalizedProfile.name) -} - function createWorkingStateFromProfile(profile: Profile): WorkingSettingsState { const normalizedProfile = normalizeProfile(profile, profile.name) return { - themeId: normalizedProfile.themeId, scopeOrder: normalizeScopeOrder(normalizedProfile.scopeOrder), hiddenScopes: new Set(normalizeHiddenScopes(normalizedProfile.hiddenScopes)), widthWeights: normalizeWidthWeights(normalizedProfile.widthWeights), @@ -208,9 +188,6 @@ function createWorkingStateFromProfile(profile: Profile): WorkingSettingsState { function createWorkingStateFromPersistedState(state: Partial): WorkingSettingsState { return { - themeId: typeof state.themeId === 'string' && state.themeId.trim() - ? state.themeId.trim() - : null, scopeOrder: normalizeScopeOrder(state.scopeOrder), hiddenScopes: new Set(normalizeHiddenScopes(state.hiddenScopes)), widthWeights: normalizeWidthWeights(state.widthWeights), @@ -336,11 +313,6 @@ function applyLoadedProfileEffects(profile: Profile | null): void { return } - const { activeThemeId, themes, loadTheme } = useThemeStore.getState() - if (profile.themeId && profile.themeId !== activeThemeId && themes[profile.themeId]) { - void loadTheme(profile.themeId) - } - if (profile.windowBounds && canUseElectronAPI()) { window.electronAPI.setWindowBounds(profile.windowBounds) } @@ -388,7 +360,7 @@ function applyProfileSnapshot( ? snapshot.profiles[snapshot.activeProfileId] ?? null : null const baselineProfile = activeProfile - ? normalizeLoadedProfileForBaseline(activeProfile, snapshot.activeProfileId) + ? normalizeProfile(activeProfile, activeProfile.name) : null set((state) => { @@ -472,11 +444,6 @@ async function restoreSavedProfileBaseline( return } - const currentThemeId = useThemeStore.getState().activeThemeId - if (baseline.themeId && baseline.themeId !== currentThemeId && useThemeStore.getState().themes[baseline.themeId]) { - await useThemeStore.getState().loadTheme(baseline.themeId) - } - if (baseline.windowBounds && canUseElectronAPI()) { window.electronAPI.setWindowBounds(baseline.windowBounds) } @@ -550,11 +517,6 @@ export const useSettingsStore = create((set, get) => ({ window.electronAPI.setWindowBounds(boundsToApply) } syncCurrentMainWindowBounds(set) - const { themeId } = state - const { themes, loadTheme, activeThemeId } = useThemeStore.getState() - if (themeId && themeId !== activeThemeId && themes[themeId]) { - void loadTheme(themeId) - } } }, @@ -562,10 +524,6 @@ export const useSettingsStore = create((set, get) => ({ applyProfileSnapshot(set, snapshot, { loadActiveProfile: true }) }, - setThemeId: (themeId: string | null) => { - set((state) => commitWorkingState(state, { themeId })) - }, - toggleScope: (kind: ScopeKind) => { set((state) => { const next = new Set(state.hiddenScopes) diff --git a/src/renderer/stores/themeStore.ts b/src/renderer/stores/themeStore.ts index ffc4aaf..f469473 100644 --- a/src/renderer/stores/themeStore.ts +++ b/src/renderer/stores/themeStore.ts @@ -82,9 +82,9 @@ applyThemeToDOM(fallbackTheme) export const useThemeStore = create((set) => ({ themes: { - [fallbackTheme.id]: createDefaultTheme(), + [fallbackTheme.name]: createDefaultTheme(), }, - activeThemeId: fallbackTheme.id, + activeThemeId: fallbackTheme.name, activeTheme: fallbackTheme, accent: fallbackTheme.interface.accent, diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index ecdbc19..0335b97 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -1465,7 +1465,7 @@ select { } .bottom-bar__section--theme { - min-width: 228px; + min-width: 420px; } .bottom-bar__section--astra { @@ -1491,6 +1491,14 @@ select { min-width: 0; } +.bottom-bar__section-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + min-width: 0; +} + .bottom-bar__section-title { font-family: 'JetBrains Mono', monospace; letter-spacing: 0.12em; @@ -1521,6 +1529,26 @@ select { gap: 6px; } +.bottom-bar__theme-credit { + color: var(--text-tertiary); + font-size: 10px; + line-height: 1.4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: right; +} + +.bottom-bar__theme-credit--link { + color: var(--text-secondary); + text-decoration: none; + transition: color 140ms ease; +} + +.bottom-bar__theme-credit--link:hover { + color: var(--accent); +} + .bottom-bar__inline--performance { gap: 10px; } diff --git a/src/renderer/visualizers/Oscilloscope.ts b/src/renderer/visualizers/Oscilloscope.ts index 6c36a95..ad8a0fd 100644 --- a/src/renderer/visualizers/Oscilloscope.ts +++ b/src/renderer/visualizers/Oscilloscope.ts @@ -187,6 +187,10 @@ export class Oscilloscope { return this.renderBuffer } + private projectSampleY(sample: number, height: number): number { + return ((1 - sample) / 2) * height + } + private concatMonoChunks(chunks: Float32Array[]): Float32Array { if (chunks.length === 1) return chunks[0] @@ -266,14 +270,13 @@ export class Oscilloscope { const sliceWidth = width / sampleCount const centerY = height / 2 - const visualGain = 1.8 if (options.underfillEnabled) { ctx.beginPath() ctx.moveTo(0, centerY) for (let i = 0; i < sampleCount; i += 1) { const x = i * sliceWidth - const y = ((1 - renderData[i] * visualGain) / 2) * height + const y = this.projectSampleY(renderData[i], height) ctx.lineTo(x, y) } ctx.lineTo((sampleCount - 1) * sliceWidth, centerY) @@ -298,10 +301,10 @@ export class Oscilloscope { ctx.lineCap = 'round' ctx.lineJoin = 'round' ctx.beginPath() - ctx.moveTo(0, ((1 - renderData[0] * visualGain) / 2) * height) + ctx.moveTo(0, this.projectSampleY(renderData[0], height)) for (let i = 1; i < sampleCount; i += 1) { const x = i * sliceWidth - const y = ((1 - renderData[i] * visualGain) / 2) * height + const y = this.projectSampleY(renderData[i], height) ctx.lineTo(x, y) } ctx.stroke() diff --git a/src/renderer/visualizers/Vectorscope.ts b/src/renderer/visualizers/Vectorscope.ts index a19c177..7513943 100644 --- a/src/renderer/visualizers/Vectorscope.ts +++ b/src/renderer/visualizers/Vectorscope.ts @@ -186,18 +186,20 @@ export class Vectorscope { this.invalidate() } + private getProjectionScale(radius: number): number { + return radius + } + private drawFrame = (): void => { const { canvas, ctx, offscreenCanvas, offscreenCtx, options } = this const width = canvas.width const height = canvas.height if (width <= 0 || height <= 0) return - const isPolar = options.mode === 'polar-unipolar' || options.mode === 'polar-bipolar' - const visualGain = isPolar ? 1.2 : 1.5 const layout = getVectorscopeLayout(width, height, options.mode) const centerX = layout.centerX const centerY = layout.centerY - const scale = layout.radius * visualGain + const scale = this.getProjectionScale(layout.radius) if (offscreenCanvas.width !== width || offscreenCanvas.height !== height) { offscreenCanvas.width = width diff --git a/src/shared/profileState.ts b/src/shared/profileState.ts index c34d739..d9d11ab 100644 --- a/src/shared/profileState.ts +++ b/src/shared/profileState.ts @@ -156,7 +156,6 @@ export function normalizeProfileName(value: unknown, fallback = DEFAULT_PROFILE_ export function createDefaultProfile(name = DEFAULT_PROFILE_NAME): Profile { return { name, - themeId: null, scopeOrder: [...DEFAULT_SCOPE_ORDER], hiddenScopes: SCOPE_KINDS.filter((kind) => !DEFAULT_VISIBLE.includes(kind)), widthWeights: { ...DEFAULT_SCOPE_WIDTH_WEIGHTS }, @@ -172,9 +171,6 @@ export function normalizeProfile(raw: unknown, fallbackName = DEFAULT_PROFILE_NA return { name: normalizeProfileName(parsed.name, fallbackName), - themeId: typeof parsed.themeId === 'string' && parsed.themeId.trim() - ? parsed.themeId.trim() - : null, scopeOrder: normalizeScopeOrder(parsed.scopeOrder), hiddenScopes: normalizeHiddenScopes(parsed.hiddenScopes), widthWeights: normalizeWidthWeights(parsed.widthWeights), @@ -195,14 +191,6 @@ export function normalizeProfileFileScopePopouts(raw: unknown): PrismProfileFile }, {} as PrismProfileFileScopePopoutMap) } -function readProfileFileThemeId(file: Partial | PrismProfileFile): string | null { - if (!('themeId' in file)) return null - const { themeId } = file - return typeof themeId === 'string' && themeId.trim() - ? themeId.trim() - : null -} - export function normalizeProfileFile( raw: unknown, fallbackId: string, @@ -223,7 +211,6 @@ export function normalizeProfileFile( version: PROFILE_FILE_VERSION, id, name, - themeId: readProfileFileThemeId(parsed), scopeOrder: normalizeScopeOrder(parsed.scopeOrder), hiddenScopes: normalizeHiddenScopes(parsed.hiddenScopes), widthWeights: normalizeWidthWeights(parsed.widthWeights), @@ -240,7 +227,6 @@ export function profileToFileData(id: string, profile: Profile): PrismProfileFil version: PROFILE_FILE_VERSION, id, name: normalized.name, - themeId: normalized.themeId, scopeOrder: [...normalized.scopeOrder], hiddenScopes: [...normalized.hiddenScopes], widthWeights: { ...normalized.widthWeights }, @@ -333,7 +319,6 @@ export function profileFileToProfile( return { name: normalizeProfileName(file.name, DEFAULT_PROFILE_NAME), - themeId: readProfileFileThemeId(file), scopeOrder: normalizeScopeOrder(file.scopeOrder), hiddenScopes: normalizeHiddenScopes(file.hiddenScopes), widthWeights: normalizeWidthWeights(file.widthWeights), diff --git a/src/shared/themeState.ts b/src/shared/themeState.ts index 29a523e..5a1d01e 100644 --- a/src/shared/themeState.ts +++ b/src/shared/themeState.ts @@ -1,5 +1,4 @@ import { - DEFAULT_THEME_ID, DEFAULT_THEME_NAME, LEGACY_THEME_MIGRATION_VERSION, THEME_FILE_FORMAT, @@ -421,7 +420,6 @@ function blendText(primary: string, muted: string, amount: number): string { function createEmptyTheme(): PrismTheme { return { - id: DEFAULT_THEME_ID, name: DEFAULT_THEME_NAME, app: {}, controls: {}, @@ -473,7 +471,6 @@ function normalizeSectionTokens( export function createDefaultTheme(): PrismTheme { return normalizeTheme({ - id: DEFAULT_THEME_ID, name: DEFAULT_THEME_NAME, credit: 'Prism', app: { @@ -563,16 +560,15 @@ export function createDefaultTheme(): PrismTheme { statusOk: DEFAULT_SUCCESS, statusError: DEFAULT_DANGER, }, - }, DEFAULT_THEME_ID, DEFAULT_THEME_NAME) + }, DEFAULT_THEME_NAME) } function cloneTheme(theme: PrismTheme): PrismTheme { return JSON.parse(JSON.stringify(theme)) as PrismTheme } -function createPresetTheme(id: string, name: string, accent: string): PrismTheme { +function createPresetTheme(name: string, accent: string): PrismTheme { const base = cloneTheme(createDefaultTheme()) - base.id = id base.name = name base.app.accent = accent base.controls.surfaceActive = withAlpha(accent, 0.12) @@ -592,39 +588,33 @@ function createPresetTheme(id: string, name: string, accent: string): PrismTheme base.waveform.line = accent base.astra.accent = accent base.astra.progressFill = accent - return normalizeTheme(base, id, name) + return normalizeTheme(base, name) } export function createBundledThemes(): PrismTheme[] { return [ createDefaultTheme(), - createPresetTheme('theme_graphite', 'Graphite', '#4fc3f7'), - createPresetTheme('theme_midnight', 'Midnight', '#4f9bff'), - createPresetTheme('theme_green', 'Green', '#4ade80'), - createPresetTheme('theme_purple', 'Purple', '#a78bfa'), - createPresetTheme('theme_rose', 'Rose', '#fb7185'), + createPresetTheme('Graphite', '#4fc3f7'), + createPresetTheme('Midnight', '#4f9bff'), + createPresetTheme('Green', '#4ade80'), + createPresetTheme('Purple', '#a78bfa'), + createPresetTheme('Rose', '#fb7185'), ] } export function normalizeTheme( raw: unknown, - fallbackId = DEFAULT_THEME_ID, fallbackName = DEFAULT_THEME_NAME, ): PrismTheme { const parsed = typeof raw === 'object' && raw !== null ? raw as Partial : {} - const id = typeof parsed.id === 'string' && parsed.id.trim() - ? parsed.id.trim() - : fallbackId - const name = typeof parsed.name === 'string' && parsed.name.trim() ? parsed.name.trim() : fallbackName const normalized = createEmptyTheme() - normalized.id = id normalized.name = name normalized.credit = typeof parsed.credit === 'string' && parsed.credit.trim() ? parsed.credit.trim() @@ -700,9 +690,8 @@ function parseSectionValue(section: ThemeSectionName, key: string): string | nul return mapped ?? null } -function parseThemeContent(content: string, fallbackId: string, fallbackName: string): PrismTheme { +function parseThemeContent(content: string, fallbackName: string): PrismTheme { const nextTheme = createEmptyTheme() - nextTheme.id = fallbackId nextTheme.name = fallbackName let currentSection: ThemeSectionName | 'theme' | null = null @@ -741,12 +730,6 @@ function parseThemeContent(content: string, fallbackId: string, fallbackName: st } break } - case 'id': - nextTheme.id = value - break - case 'name': - nextTheme.name = value - break case 'credit': nextTheme.credit = value break @@ -775,15 +758,45 @@ function parseThemeContent(content: string, fallbackId: string, fallbackName: st getSectionTokenRecord(nextTheme, currentSection)[tokenKey] = toCssColor(quantizeThemeColor(parsedColor)) } - return normalizeTheme(nextTheme, fallbackId, fallbackName) + return normalizeTheme(nextTheme, fallbackName) +} + +export function extractLegacyThemeFileId(content: string): string | null { + let currentSection: ThemeSectionName | 'theme' | null = null + + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim() + if (!line || line.startsWith('#') || line.startsWith(';')) continue + + const sectionMatch = /^\[(.+)\]$/.exec(line) + if (sectionMatch) { + const sectionKey = normalizeKey(sectionMatch[1] ?? '') + currentSection = sectionKey === 'theme' + ? 'theme' + : (SECTION_KEY_MAP[sectionKey] ?? null) + continue + } + + if (currentSection !== 'theme') continue + + const equalsIndex = line.indexOf('=') + if (equalsIndex === -1) continue + + const key = normalizeKey(line.slice(0, equalsIndex)) + const value = line.slice(equalsIndex + 1).trim() + if (key !== 'id' || !value) continue + + return value + } + + return null } export function parseThemeFileContent( content: string, - fallbackId: string, fallbackName = DEFAULT_THEME_NAME, ): PrismTheme { - return parseThemeContent(content, fallbackId, fallbackName) + return parseThemeContent(content, fallbackName) } function serializeSection( @@ -807,13 +820,11 @@ function serializeSection( } export function serializeThemeFile(theme: PrismTheme): string { - const normalized = normalizeTheme(theme, theme.id, theme.name) + const normalized = normalizeTheme(theme, theme.name) const sections: string[] = [ '[Theme]', `format = ${THEME_FILE_FORMAT}`, `version = ${THEME_FILE_VERSION}`, - `id = ${normalized.id}`, - `name = ${normalized.name}`, ] if (normalized.credit) sections.push(`credit = ${normalized.credit}`) @@ -834,97 +845,92 @@ export function serializeThemeFile(theme: PrismTheme): string { export function createTemplateThemeFile(): string { const base = createDefaultTheme() + const resolved = resolveTheme(base) const themeSection = [ '[Theme]', `format = ${THEME_FILE_FORMAT}`, `version = ${THEME_FILE_VERSION}`, - 'id = theme_template', - 'name = Template Theme', - 'credit = Your Name', - 'website = https://example.com', - 'description = Custom Prism theme', + '# Optional metadata:', + '# credit = Your Name', + '# website = https://example.com', + '# description = Custom Prism theme', ] - - const appSection = serializeSection('App', { - accent: base.app.accent, - success: base.app.success, - warning: base.app.warning, - danger: base.app.danger, - background: base.app.background, - surface: base.app.surface, - surfaceAlt: base.app.surfaceAlt, - border: base.app.border, - text: base.app.text, - textMuted: base.app.textMuted, - }, APP_SCHEMA as SectionSchema>) - - appSection.push( + const appSection = [ + '[App]', + '# Start here. These are the main palette tokens.', + `accent = ${toThemeChannels(base.app.accent ?? DEFAULT_ACCENT)}`, + `background = ${toThemeChannels(base.app.background ?? 'rgb(0, 0, 0)')}`, + `surface = ${toThemeChannels(base.app.surface ?? 'rgba(8, 11, 16, 0.92)')}`, + `surface_alt = ${toThemeChannels(base.app.surfaceAlt ?? 'rgba(4, 8, 12, 0.98)')}`, + `border = ${toThemeChannels(base.app.border ?? 'rgba(255, 255, 255, 0.09)')}`, + `text = ${toThemeChannels(base.app.text ?? 'rgb(255, 255, 255)')}`, + '', + '# Optional palette extras:', + `text_muted = ${toThemeChannels(base.app.textMuted ?? 'rgba(255, 255, 255, 0.42)')}`, + `success = ${toThemeChannels(base.app.success ?? DEFAULT_SUCCESS)}`, + `warning = ${toThemeChannels(base.app.warning ?? DEFAULT_WARNING)}`, + `danger = ${toThemeChannels(base.app.danger ?? DEFAULT_DANGER)}`, + '', '# Optional shell overrides:', - `# toolbar_bg = ${toThemeChannels(withAlpha(base.app.surfaceAlt ?? 'rgba(4, 8, 12, 0.98)', 0.78))}`, - `# settings_bg_top = ${toThemeChannels(base.app.surface ?? 'rgba(8, 11, 16, 0.92)')}`, - `# settings_bg_bottom = ${toThemeChannels(base.app.surfaceAlt ?? 'rgba(4, 8, 12, 0.98)')}`, - `# bottom_bar_bg = ${toThemeChannels(withAlpha(base.app.surfaceAlt ?? 'rgba(4, 8, 12, 0.98)', 0.98))}`, - ) + `toolbar_bg = ${toThemeChannels(withAlpha(base.app.surfaceAlt ?? 'rgba(4, 8, 12, 0.98)', 0.78))}`, + `settings_bg_top = ${toThemeChannels(base.app.surface ?? 'rgba(8, 11, 16, 0.92)')}`, + `settings_bg_bottom = ${toThemeChannels(base.app.surfaceAlt ?? 'rgba(4, 8, 12, 0.98)')}`, + `bottom_bar_bg = ${toThemeChannels(withAlpha(base.app.surfaceAlt ?? 'rgba(4, 8, 12, 0.98)', 0.98))}`, + ] const controlsSection = serializeSection('Controls', { ...base.controls, flatControls: 'false', }, CONTROLS_SCHEMA as SectionSchema>) + controlsSection.splice(1, 0, '# Entire section optional. Remove tokens or the whole section to use Prism defaults.') const scopesSection = serializeSection('Scopes', { ...base.scopes }, SCOPES_SCHEMA as SectionSchema>) + scopesSection.splice(1, 0, '# Entire section optional. Remove tokens or the whole section to use Prism defaults.') const spectrumSection = serializeSection('Spectrum', { - line: base.spectrum.line, - sideLine: base.spectrum.sideLine, - fill: base.spectrum.fill, - heatLow: base.spectrum.heatLow, - heatMid: base.spectrum.heatMid, - heatHigh: base.spectrum.heatHigh, - heatBase: base.scopes.background, + ...base.spectrum, + background: resolved.spectrum.background, + guides: resolved.spectrum.guides, + labels: resolved.spectrum.labels, + heatBase: resolved.spectrum.heatBase, }, SPECTRUM_SCHEMA as SectionSchema>) const oscilloscopeSection = serializeSection('Oscilloscope', { - line: base.oscilloscope.line, - fill: base.oscilloscope.fill, + ...base.oscilloscope, + background: resolved.oscilloscope.background, + guides: resolved.oscilloscope.guides, }, OSCILLOSCOPE_SCHEMA as SectionSchema>) const vectorscopeSection = serializeSection('Vectorscope', { - trace: base.vectorscope.trace, - bandLow: base.vectorscope.bandLow, - bandMid: base.vectorscope.bandMid, - bandHigh: base.vectorscope.bandHigh, - labels: base.scopes.guides, + ...base.vectorscope, + background: resolved.vectorscope.background, + guides: resolved.vectorscope.guides, + labels: resolved.vectorscope.labels, }, VECTORSCOPE_SCHEMA as SectionSchema>) const spectrogramSection = serializeSection('Spectrogram', { - mono: base.spectrogram.mono, - heatLow: base.spectrogram.heatLow, - heatMid: base.spectrogram.heatMid, - heatHigh: base.spectrogram.heatHigh, + ...base.spectrogram, + background: resolved.spectrogram.background, }, SPECTROGRAM_SCHEMA as SectionSchema>) const vumeterSection = serializeSection('VUMeter', { - level: base.vumeter.level, - track: base.vumeter.track, - peak: base.vumeter.peak, - clip: base.vumeter.clip, - scale: base.scopes.guides, - labels: blendText(base.app.text ?? 'rgb(255, 255, 255)', base.app.textMuted ?? 'rgba(255, 255, 255, 0.42)', 0.35), + ...base.vumeter, + background: resolved.vumeter.background, + scale: resolved.vumeter.scale, + labels: resolved.vumeter.labels, }, VUMETER_SCHEMA as SectionSchema>) const lufsmeterSection = serializeSection('LUFSMeter', { - level: base.lufsmeter.level, - track: base.lufsmeter.track, - target: base.lufsmeter.target, - scale: base.scopes.guides, - labels: blendText(base.app.text ?? 'rgb(255, 255, 255)', base.app.textMuted ?? 'rgba(255, 255, 255, 0.42)', 0.2), + ...base.lufsmeter, + background: resolved.lufsmeter.background, + scale: resolved.lufsmeter.scale, + labels: resolved.lufsmeter.labels, }, LUFSMETER_SCHEMA as SectionSchema>) const waveformSection = serializeSection('Waveform', { - line: base.waveform.line, - bandLow: base.waveform.bandLow, - bandMid: base.waveform.bandMid, - bandHigh: base.waveform.bandHigh, + ...base.waveform, + background: resolved.waveform.background, + guides: resolved.waveform.guides, }, WAVEFORM_SCHEMA as SectionSchema>) const astraSection = serializeSection('Astra', { ...base.astra }, ASTRA_SCHEMA as SectionSchema>) @@ -934,29 +940,22 @@ export function createTemplateThemeFile(): string { # Colors use R, G, B or R, G, B, A (0-255) # CSS colors like #hex, rgb(), and rgba() also work # -# Theme metadata: -# [Theme] +# Start with [App]. +# Everything else below is optional and can be removed to inherit defaults. # -# Core UI: -# [App] Main palette for the window shell and text -# [Controls] Buttons, inputs, menus, sliders, and control chrome +# [Controls] and [Scopes] are shared override groups. +# Module sections show the full set of supported tokens for each module. # -# Shared scope defaults: -# [Scopes] Background, guides, and overlays shared by all scopes -# -# Module overrides: -# Add tokens inside each module section only when you want that module to -# diverge from the shared defaults. Background/guides can still be added to -# individual sections later even if they are not shown in this template. -# -# Set flat_controls = true to disable glass highlights/gradients. +# Remove any token to let Prism inherit or derive it. +# Remove an entire section if that area should use Prism's defaults. # ${[ themeSection.join('\n'), appSection.join('\n'), controlsSection.join('\n'), scopesSection.join('\n'), - '# Module overrides', + '# Module sections below are optional overrides.', + '# Keep the tokens you want to customize and delete the rest.', spectrumSection.join('\n'), oscilloscopeSection.join('\n'), vectorscopeSection.join('\n'), @@ -1303,13 +1302,12 @@ function resolveAstraTheme( } export function resolveTheme(theme: PrismTheme): PrismResolvedTheme { - const normalized = normalizeTheme(theme, theme.id, theme.name) + const normalized = normalizeTheme(theme, theme.name) const app = resolveAppTokens(normalized.app) const controls = resolveControlsTokens(normalized.controls, app) const scopes = resolveScopesTokens(normalized.scopes, app) return { - id: normalized.id, name: normalized.name, credit: normalized.credit, website: normalized.website, @@ -1339,17 +1337,46 @@ export function resolveNativeThemeSource(theme: PrismTheme | null | undefined): export function resolveLegacyThemeToPresetId(payload: LegacyThemeMigrationPayload): string | null { switch (payload.presetId) { case 'default': - return DEFAULT_THEME_ID + return DEFAULT_THEME_NAME case 'graphite': - return 'theme_graphite' + return 'Graphite' case 'midnight': - return 'theme_midnight' + return 'Midnight' case 'green': - return 'theme_green' + return 'Green' case 'purple': - return 'theme_purple' + return 'Purple' case 'rose': - return 'theme_rose' + return 'Rose' + default: + return null + } +} + +export function resolveLegacyThemeFileId(value: string | null | undefined): string | null { + if (typeof value !== 'string') { + return null + } + + switch (value.trim().toLowerCase()) { + case 'default': + case 'theme_default': + return DEFAULT_THEME_NAME + case 'graphite': + case 'theme_graphite': + return 'Graphite' + case 'midnight': + case 'theme_midnight': + return 'Midnight' + case 'green': + case 'theme_green': + return 'Green' + case 'purple': + case 'theme_purple': + return 'Purple' + case 'rose': + case 'theme_rose': + return 'Rose' default: return null } @@ -1360,7 +1387,6 @@ export function createMigratedAccentTheme(accent: string): PrismTheme | null { if (!parsed) return null const base = cloneTheme(createDefaultTheme()) - base.id = 'theme_migrated_accent' base.name = 'Migrated Accent' base.app.accent = toCssColor(parsed) base.controls.surfaceActive = withAlpha(base.app.accent, 0.12) @@ -1380,7 +1406,7 @@ export function createMigratedAccentTheme(accent: string): PrismTheme | null { base.waveform.line = base.app.accent base.astra.accent = base.app.accent base.astra.progressFill = base.app.accent - return normalizeTheme(base, base.id, base.name) + return normalizeTheme(base, base.name) } export function themeToCssVariables(theme: Pick): Record { @@ -1451,7 +1477,7 @@ export function applyResolvedThemeToDocument( } export function getDefaultThemeIdForLocalState(): string { - return DEFAULT_THEME_ID + return DEFAULT_THEME_NAME } export function getLegacyThemeMigrationVersion(): number { diff --git a/src/types/profile.ts b/src/types/profile.ts index 0bbba51..a2e5f33 100644 --- a/src/types/profile.ts +++ b/src/types/profile.ts @@ -12,7 +12,6 @@ export const DEFAULT_PROFILE_NAME = 'Default' export interface Profile { name: string - themeId: string | null scopeOrder: ScopeKind[] hiddenScopes: ScopeKind[] widthWeights: Record @@ -44,7 +43,7 @@ export interface PrismProfileFileV2 { version: typeof PROFILE_FILE_VERSION id: string name: string - themeId: string | null + themeId?: string | null scopeOrder: ScopeKind[] hiddenScopes: ScopeKind[] widthWeights: Record diff --git a/src/types/theme.ts b/src/types/theme.ts index e76ccb0..1422440 100644 --- a/src/types/theme.ts +++ b/src/types/theme.ts @@ -5,7 +5,6 @@ export const THEME_FILE_VERSION = 2 export const THEME_LOCAL_STATE_FORMAT = 'prism-theme-local' export const THEME_LOCAL_STATE_VERSION = 1 export const LEGACY_THEME_MIGRATION_VERSION = 1 -export const DEFAULT_THEME_ID = 'theme_default' export const DEFAULT_THEME_NAME = 'Default' export type ThemeSectionName = @@ -136,7 +135,6 @@ export interface ThemeAstraTokens { } export interface PrismTheme { - id: string name: string credit?: string website?: string @@ -162,7 +160,6 @@ export interface PrismThemeLocalStateV1 { } export interface ThemeSummary { - id: string name: string isDefault: boolean } @@ -327,7 +324,6 @@ export interface ResolvedAstraTheme { } export interface PrismResolvedTheme { - id: string name: string credit?: string website?: string diff --git a/test/profile-library.test.ts b/test/profile-library.test.ts index 8011c3f..3ba9dfb 100644 --- a/test/profile-library.test.ts +++ b/test/profile-library.test.ts @@ -25,18 +25,6 @@ async function createHarness(): Promise<{ localStatePath: string profilesDir: string rootDir: string -}> { - return createHarnessWithOptions() -} - -async function createHarnessWithOptions(options?: { - defaultThemeId?: string | null -}): Promise<{ - cleanup: () => Promise - library: FileBackedProfileLibrary - localStatePath: string - profilesDir: string - rootDir: string }> { const rootDir = await mkdtemp(join(tmpdir(), 'prism-profile-library-')) const profilesDir = join(rootDir, 'Documents', 'Prism Profiles') @@ -44,13 +32,7 @@ async function createHarnessWithOptions(options?: { return { cleanup: () => rm(rootDir, { recursive: true, force: true }), - library: new FileBackedProfileLibrary( - profilesDir, - localStatePath, - options && 'defaultThemeId' in options - ? async () => options.defaultThemeId ?? null - : undefined, - ), + library: new FileBackedProfileLibrary(profilesDir, localStatePath), localStatePath, profilesDir, rootDir, @@ -59,7 +41,6 @@ async function createHarnessWithOptions(options?: { function createProfile(name: string): Profile { const profile = createDefaultProfile(name) - profile.themeId = 'theme_default' profile.scopePopouts.spectrum = { poppedOut: true, windowBounds: { x: 120, y: 40, width: 420, height: 240 }, @@ -77,7 +58,7 @@ test('profile file serialization excludes geometry and round-trips with local me assert.equal(file.format, PROFILE_FILE_FORMAT) assert.equal(file.version, PROFILE_FILE_VERSION) - assert.equal(file.themeId, 'theme_default') + assert.equal('themeId' in file, false) assert.equal(JSON.stringify(file).includes('windowBounds'), false) assert.equal(JSON.stringify(file).includes('frameTarget'), false) assert.deepEqual(file.scopePopouts.spectrum, { poppedOut: true }) @@ -137,19 +118,19 @@ test('astra stays opt-in for profile scope order normalization', () => { assert.equal(normalizeScopeOrder(['spectrum', 'astra']).includes('astra'), true) }) -test('default profile seeds with the current active theme when available', async () => { - const harness = await createHarnessWithOptions({ defaultThemeId: 'theme_midnight' }) +test('default profile omits theme metadata from runtime state and saved files', async () => { + const harness = await createHarness() try { const snapshot = await harness.library.getSnapshot() - assert.equal(snapshot.profiles[DEFAULT_PROFILE_ID]?.themeId, 'theme_midnight') + assert.equal('themeId' in snapshot.profiles[DEFAULT_PROFILE_ID]!, false) const defaultFile = JSON.parse( await readFile(join(harness.profilesDir, 'Default.prsm'), 'utf8'), ) as { themeId?: string | null } - assert.equal(defaultFile.themeId, 'theme_midnight') + assert.equal('themeId' in defaultFile, false) } finally { await harness.cleanup() } @@ -238,6 +219,53 @@ test('partial files normalize, unsupported versions fail, and import does not ch } }) +test('legacy profile files with themeId import successfully and ignore embedded theme metadata', async () => { + const harness = await createHarness() + + try { + const legacyPath = join(harness.rootDir, 'legacy-theme.prsm') + await writeFile(legacyPath, `${JSON.stringify({ + format: PROFILE_FILE_FORMAT, + version: PROFILE_FILE_VERSION, + id: 'profile_legacy_theme', + name: 'Legacy Theme', + themeId: 'theme_midnight', + scopeOrder: ['spectrum', 'oscilloscope'], + hiddenScopes: ['vectorscope', 'spectrogram', 'vumeter', 'lufsmeter', 'waveform', 'astra'], + widthWeights: { + spectrum: 1, + oscilloscope: 1, + vectorscope: 1, + spectrogram: 1, + vumeter: 0.5, + lufsmeter: 0.5, + waveform: 1, + astra: 1, + }, + scopeSettings: createDefaultProfile('Legacy Theme').scopeSettings, + scopePopouts: { + spectrum: { poppedOut: false }, + oscilloscope: { poppedOut: false }, + vectorscope: { poppedOut: false }, + spectrogram: { poppedOut: false }, + vumeter: { poppedOut: false }, + lufsmeter: { poppedOut: false }, + waveform: { poppedOut: false }, + astra: { poppedOut: false }, + }, + }, null, 2)}\n`, 'utf8') + + const snapshot = await harness.library.importProfileFromPath(legacyPath) + const importedProfile = snapshot.profiles.profile_legacy_theme + + assert.ok(importedProfile) + assert.equal(importedProfile.name, 'Legacy Theme') + assert.equal('themeId' in importedProfile, false) + } finally { + await harness.cleanup() + } +}) + test('legacy migration writes managed files, preserves active profile, and stores local-only geometry', async () => { const harness = await createHarness() diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index a964b4b..f709e55 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -1,4 +1,6 @@ import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' import test from 'node:test' import { DEFAULT_VISUALIZER_TINT, @@ -31,6 +33,8 @@ import { moveDockedScopeOrder, useSettingsStore, } from '../src/renderer/stores/settingsStore' +import { useThemeStore } from '../src/renderer/stores/themeStore' +import { resolveThemeCreditDetails } from '../src/renderer/components/BottomBar' import { scopeSettingsToOptions } from '../src/renderer/components/ScopeModule' import { scopeSummary } from '../src/renderer/components/ScopeSettingsSection' import { @@ -54,7 +58,10 @@ import { type NativeVisualizerTransportBridge, } from '../src/renderer/audio/NativeVisualizerTransport' import { LUFSMeter } from '../src/renderer/visualizers/LUFSMeter' +import { Oscilloscope } from '../src/renderer/visualizers/Oscilloscope' import { SpectrumAnalyzer, type SpectrumAnalyzerOptions } from '../src/renderer/visualizers/SpectrumAnalyzer' +import { Vectorscope } from '../src/renderer/visualizers/Vectorscope' +import { getVectorscopeLayout } from '../src/renderer/visualizers/vectorscopeGrids' import { MultibandBuffer, MultibandSplitter, @@ -308,7 +315,6 @@ function installFakeElectronWindow(overrides: Record = {}): { function seedProfileDraftState(profile: Profile): void { useSettingsStore.setState({ - themeId: profile.themeId, scopeOrder: [...profile.scopeOrder], hiddenScopes: new Set(profile.hiddenScopes), widthWeights: { ...profile.widthWeights }, @@ -1177,6 +1183,53 @@ test('scopeSettingsToOptions forwards shared scope background and guides to osci assert.equal(vectorscope.labelColor, theme.vectorscope.labels) }) +test('Oscilloscope projects raw sample amplitude without renderer gain', () => { + const dom = installFakeCanvasDom() + const dataSource = { + getPendingOscilloscopeSamples: () => [], + getSampleRate: () => 48000, + isPlaying: () => false, + subscribeToSessionChanges: () => () => {}, + } + const oscilloscope = new Oscilloscope(createFakeCanvas(), { dataSource }) + + try { + const state = oscilloscope as unknown as { + projectSampleY: (sample: number, height: number) => number + } + + assert.equal(state.projectSampleY(0.5, 180), 45) + assert.equal(state.projectSampleY(-0.5, 180), 135) + } finally { + oscilloscope.dispose() + dom.restore() + } +}) + +test('Vectorscope uses the base layout radius for projection scale', () => { + const dom = installFakeCanvasDom() + const dataSource = { + getPendingVectorscopeSamples: () => [], + getSampleRate: () => 48000, + isPlaying: () => false, + subscribeToSessionChanges: () => () => {}, + } + const vectorscope = new Vectorscope(createFakeCanvas(), { dataSource }) + + try { + const state = vectorscope as unknown as { + getProjectionScale: (radius: number) => number + } + const layout = getVectorscopeLayout(320, 180, 'lissajous') + + assert.equal(state.getProjectionScale(layout.radius), layout.radius) + assert.equal(layout.radius, 81) + } finally { + vectorscope.dispose() + dom.restore() + } +}) + test('scopeSettingsToOptions forwards themed backgrounds and track colors to spectrogram, VU, and LUFS modules', () => { const profile = createDefaultProfile('Default') const authoredTheme = createDefaultTheme() @@ -1279,7 +1332,6 @@ test('applying a profile snapshot does not change the machine-local frame target test('profile draft comparisons return to clean after reverting a change', () => { const baselineProfile = createDefaultProfile(DEFAULT_PROFILE_NAME) - baselineProfile.themeId = 'theme_default' baselineProfile.windowBounds = { x: 24, y: 48, width: 900, height: 180 } baselineProfile.scopePopouts.spectrum = { poppedOut: true, @@ -1287,7 +1339,6 @@ test('profile draft comparisons return to clean after reverting a change', () => } const baselineDraft = buildProfileDraft({ - themeId: baselineProfile.themeId, scopeOrder: baselineProfile.scopeOrder, hiddenScopes: baselineProfile.hiddenScopes, widthWeights: baselineProfile.widthWeights, @@ -1297,7 +1348,6 @@ test('profile draft comparisons return to clean after reverting a change', () => }, baselineProfile.name) const changedDraft = buildProfileDraft({ - themeId: baselineProfile.themeId, scopeOrder: baselineProfile.scopeOrder, hiddenScopes: baselineProfile.hiddenScopes, widthWeights: baselineProfile.widthWeights, @@ -1313,7 +1363,6 @@ test('profile draft comparisons return to clean after reverting a change', () => }, baselineProfile.name) const revertedDraft = buildProfileDraft({ - themeId: baselineProfile.themeId, scopeOrder: baselineProfile.scopeOrder, hiddenScopes: baselineProfile.hiddenScopes, widthWeights: baselineProfile.widthWeights, @@ -1326,12 +1375,10 @@ test('profile draft comparisons return to clean after reverting a change', () => assert.equal(profilesMatch(baselineDraft, revertedDraft), true) }) -test('buildProfileDraft preserves unlinked themes instead of coercing the active theme', () => { +test('buildProfileDraft omits theme metadata from the runtime draft', () => { const profile = createDefaultProfile('Live Mix') - profile.themeId = null const draft = buildProfileDraft({ - themeId: profile.themeId, scopeOrder: profile.scopeOrder, hiddenScopes: profile.hiddenScopes, widthWeights: profile.widthWeights, @@ -1340,7 +1387,7 @@ test('buildProfileDraft preserves unlinked themes instead of coercing the active windowBounds: profile.windowBounds, }, profile.name) - assert.equal(draft.themeId, null) + assert.equal('themeId' in draft, false) }) test('resolveNativeThemeSource follows the active theme brightness for native UI', () => { @@ -1358,13 +1405,64 @@ test('resolveNativeThemeSource follows the active theme brightness for native UI assert.equal(resolveNativeThemeSource(lightTheme), 'light') }) +test('resolveThemeCreditDetails enables links only for valid http and https theme websites', () => { + assert.deepEqual( + resolveThemeCreditDetails({ credit: 'Night Shift', website: 'https://themes.example/night' }), + { + credit: 'Night Shift', + url: 'https://themes.example/night', + }, + ) + assert.deepEqual( + resolveThemeCreditDetails({ credit: 'Night Shift', website: 'http://themes.example/night' }), + { + credit: 'Night Shift', + url: 'http://themes.example/night', + }, + ) + assert.deepEqual( + resolveThemeCreditDetails({ credit: 'Night Shift', website: 'ftp://themes.example/night' }), + { + credit: 'Night Shift', + url: null, + }, + ) + assert.deepEqual( + resolveThemeCreditDetails({ credit: 'Night Shift', website: 'not a url' }), + { + credit: 'Night Shift', + url: null, + }, + ) + assert.deepEqual( + resolveThemeCreditDetails({ credit: ' ', website: 'https://themes.example/night' }), + { + credit: null, + url: null, + }, + ) +}) + +test('BottomBar theme section renders compact credit metadata and opens valid links through Electron', async () => { + const componentSource = await readFile(join(process.cwd(), 'src', 'renderer', 'components', 'BottomBar.tsx'), 'utf8') + const stylesSource = await readFile(join(process.cwd(), 'src', 'renderer', 'styles', 'globals.css'), 'utf8') + + assert.match(componentSource, /const themeCredit = resolveThemeCreditDetails\(activeTheme\)/) + assert.match(componentSource, /window\.electronAPI\.openExternalUrl\(url\)/) + assert.match(componentSource, /By \{themeCredit\.credit\}/) + assert.match(componentSource, /bottom-bar__section-header/) + assert.match(componentSource, /bottom-bar__theme-credit--link/) + assert.match(stylesSource, /\.bottom-bar__section--theme \{[\s\S]*min-width: 420px;/) + assert.match(stylesSource, /\.bottom-bar__section-header \{/) + assert.match(stylesSource, /\.bottom-bar__theme-credit--link \{/) +}) + test('toggleScope appends astra to the scope order when it is enabled from an opt-in profile', () => { const previousSettingsState = useSettingsStore.getState() const fakeWindow = installFakeElectronWindow() try { const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) - profile.themeId = 'theme_default' seedProfileDraftState(profile) assert.equal(useSettingsStore.getState().scopeOrder.includes('astra'), false) @@ -1458,7 +1556,6 @@ test('main-window bounds updates persist working state in Electron mode', () => try { const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) - profile.themeId = 'theme_default' profile.windowBounds = { x: 10, y: 20, width: 900, height: 180 } seedProfileDraftState(profile) @@ -1488,7 +1585,6 @@ test('initializeProfiles restores persisted dirty window bounds while keeping th const fakeWindow = installFakeElectronWindow({ getProfileSnapshot: async () => { const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) - profile.themeId = 'theme_default' profile.windowBounds = { x: 10, y: 20, width: 900, height: 180 } return { @@ -1506,11 +1602,9 @@ test('initializeProfiles restores persisted dirty window bounds while keeping th try { const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) - profile.themeId = 'theme_default' profile.windowBounds = { x: 10, y: 20, width: 900, height: 180 } fakeStorage.setItem('prism:settings', JSON.stringify({ - themeId: profile.themeId, scopeOrder: profile.scopeOrder, hiddenScopes: profile.hiddenScopes, widthWeights: profile.widthWeights, @@ -1534,6 +1628,45 @@ test('initializeProfiles restores persisted dirty window bounds while keeping th } }) +test('initializeProfiles ignores stale persisted theme-only state and loads the saved profile normally', async () => { + const previousSettingsState = useSettingsStore.getState() + const fakeStorage = installFakeLocalStorage() + const savedBounds = { x: 10, y: 20, width: 900, height: 180 } + const fakeWindow = installFakeElectronWindow({ + getProfileSnapshot: async () => { + const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) + profile.windowBounds = savedBounds + + return { + activeProfileId: DEFAULT_PROFILE_ID, + profiles: { + [DEFAULT_PROFILE_ID]: profile, + }, + } + }, + getWindowBounds: async () => savedBounds, + setWindowBounds: () => {}, + }) + + try { + fakeStorage.setItem('prism:settings', JSON.stringify({ + themeId: 'theme_midnight', + })) + + await useSettingsStore.getState().initializeProfiles() + + const state = useSettingsStore.getState() + assert.equal(state.activeProfileId, DEFAULT_PROFILE_ID) + assert.deepEqual(state.windowBounds, savedBounds) + assert.deepEqual(state.savedProfileBaseline?.windowBounds, savedBounds) + assert.equal(state.hasUnsavedProfileChanges, false) + } finally { + useSettingsStore.setState(previousSettingsState) + fakeWindow.restore() + fakeStorage.restore() + } +}) + test('profiles without saved window bounds mark the first user move dirty after load sync completes', async () => { const previousSettingsState = useSettingsStore.getState() const currentBounds = { x: 10, y: 20, width: 900, height: 180 } @@ -1544,7 +1677,6 @@ test('profiles without saved window bounds mark the first user move dirty after try { const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) - profile.themeId = 'theme_default' useSettingsStore.getState().applyExternalProfileSnapshot({ activeProfileId: DEFAULT_PROFILE_ID, @@ -1577,7 +1709,6 @@ test('loading a profile syncs the live window bounds without marking the draft d try { const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) - profile.themeId = 'theme_default' profile.windowBounds = { x: 10, y: 20, width: 900, height: 180 } useSettingsStore.getState().applyExternalProfileSnapshot({ @@ -1599,8 +1730,9 @@ test('loading a profile syncs the live window bounds without marking the draft d } }) -test('switching to a non-default profile with an unlinked theme does not mark it dirty', async () => { +test('switching profiles leaves the global theme alone and does not mark the profile dirty', async () => { const previousSettingsState = useSettingsStore.getState() + const previousThemeState = useThemeStore.getState() const fakeWindow = installFakeElectronWindow({ getWindowBounds: async () => ({ x: 10, y: 20, width: 900, height: 180 }), setWindowBounds: () => {}, @@ -1608,10 +1740,20 @@ test('switching to a non-default profile with an unlinked theme does not mark it try { const defaultProfile = createDefaultProfile(DEFAULT_PROFILE_NAME) - defaultProfile.themeId = 'theme_default' - const liveMixProfile = createDefaultProfile('Live Mix') - liveMixProfile.themeId = null + const midnightTheme = createDefaultTheme() + midnightTheme.name = 'Midnight' + midnightTheme.credit = 'Night Shift' + midnightTheme.app.background = 'rgb(6, 10, 20)' + useThemeStore.setState({ + themes: { + Default: createDefaultTheme(), + Midnight: midnightTheme, + }, + activeThemeId: 'Midnight', + activeTheme: resolveTheme(midnightTheme), + accent: resolveTheme(midnightTheme).interface.accent, + }) useSettingsStore.getState().applyExternalProfileSnapshot({ activeProfileId: 'profile_live_mix', @@ -1624,11 +1766,11 @@ test('switching to a non-default profile with an unlinked theme does not mark it await Promise.resolve() await Promise.resolve() - assert.equal(useSettingsStore.getState().themeId, null) - assert.equal(useSettingsStore.getState().savedProfileBaseline?.themeId, null) + assert.equal(useThemeStore.getState().activeThemeId, 'Midnight') assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false) } finally { useSettingsStore.setState(previousSettingsState) + useThemeStore.setState(previousThemeState) fakeWindow.restore() } }) @@ -1642,7 +1784,6 @@ test('profile load absorbs immediate macOS-style window bound adjustments withou try { const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) - profile.themeId = 'theme_default' profile.windowBounds = { x: 10, y: 20, width: 900, height: 180 } useSettingsStore.getState().applyExternalProfileSnapshot({ @@ -1671,7 +1812,6 @@ test('profile load absorbs immediate popout bound adjustments without marking di try { const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) - profile.themeId = 'theme_default' profile.scopePopouts.spectrum = { poppedOut: true, windowBounds: { x: 140, y: 60, width: 420, height: 240 }, @@ -1700,7 +1840,6 @@ test('geometry sync window extends while load-time macOS bound updates continue' try { const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) - profile.themeId = 'theme_default' seedProfileDraftState(profile) useSettingsStore.setState((state) => ({ @@ -1726,7 +1865,6 @@ test('popout bounds updates persist working state in Electron mode', () => { try { const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) - profile.themeId = 'theme_default' profile.scopePopouts.spectrum = { poppedOut: true, windowBounds: { x: 140, y: 60, width: 420, height: 240 }, diff --git a/test/theme-library.test.ts b/test/theme-library.test.ts index 12971e3..6ab82ff 100644 --- a/test/theme-library.test.ts +++ b/test/theme-library.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' -import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import test from 'node:test' import { FileBackedThemeLibrary } from '../src/main/themeLibrary' import { @@ -15,7 +15,6 @@ import { themeToCssVariables, } from '../src/shared/themeState' import { - DEFAULT_THEME_ID, DEFAULT_THEME_NAME, } from '../src/types/theme' @@ -49,7 +48,7 @@ test('theme files round-trip and keep grouped sections intact', () => { theme.vumeter.track = '#111827' const serialized = serializeThemeFile(theme) - const parsed = parseThemeFileContent(serialized, DEFAULT_THEME_ID, DEFAULT_THEME_NAME) + const parsed = parseThemeFileContent(serialized, DEFAULT_THEME_NAME) assert.match(serialized, /\[Theme\]/) assert.match(serialized, /version = 2/) @@ -57,8 +56,9 @@ test('theme files round-trip and keep grouped sections intact', () => { assert.match(serialized, /\[Controls\]/) assert.match(serialized, /\[Scopes\]/) assert.match(serialized, /flat_controls = true/) + assert.doesNotMatch(serialized, /^id = /m) + assert.doesNotMatch(serialized, /^name = /m) - assert.equal(parsed.id, DEFAULT_THEME_ID) assert.equal(parsed.name, DEFAULT_THEME_NAME) assert.equal(parsed.app.accent, 'rgb(74, 222, 128)') assert.equal(parsed.controls.menuSurface, 'rgb(12, 18, 32)') @@ -69,17 +69,29 @@ test('theme files round-trip and keep grouped sections intact', () => { assert.equal(parsed.astra.background, theme.astra.background) }) +test('theme files preserve optional credit and website metadata', () => { + const theme = createDefaultTheme() + theme.credit = 'Night Shift' + theme.website = 'https://themes.example/night-shift' + + const serialized = serializeThemeFile(theme) + const parsed = parseThemeFileContent(serialized, DEFAULT_THEME_NAME) + + assert.match(serialized, /credit = Night Shift/) + assert.match(serialized, /website = https:\/\/themes\.example\/night-shift/) + assert.equal(parsed.credit, 'Night Shift') + assert.equal(parsed.website, 'https://themes.example/night-shift') +}) + test('parseThemeFileContent preserves passthrough controls tokens from .iro files', () => { const parsed = parseThemeFileContent(` [Theme] format = prism-theme version = 2 -id = theme_flat -name = Flat [Controls] flat_controls = true -`, 'theme_flat', 'Flat') +`, 'Flat') const resolved = resolveTheme(parsed) @@ -89,6 +101,22 @@ flat_controls = true assert.equal(resolved.interface.glassHighlightStrong, 'transparent') }) +test('parseThemeFileContent derives the theme name from the filename stem and ignores legacy header names', () => { + const parsed = parseThemeFileContent(` +[Theme] +format = prism-theme +version = 2 +id = theme_midnight +name = Legacy Midnight + +[App] +accent = 79, 155, 255, 255 +`, 'Midnight') + + assert.equal(parsed.name, 'Midnight') + assert.equal(parsed.app.accent, 'rgb(79, 155, 255)') +}) + test('resolveTheme maps grouped app, controls, and scopes tokens into UI and scope surfaces', () => { const theme = createDefaultTheme() theme.app.accent = 'rgb(255, 159, 67)' @@ -160,17 +188,23 @@ test('themeToCssVariables exposes grouped UI, control, and scope variables', () test('createTemplateThemeFile presents a simplified recommended theme layout', () => { const template = createTemplateThemeFile() - const parsed = parseThemeFileContent(template, 'theme_template', 'Template Theme') + const parsed = parseThemeFileContent(template, 'Template Theme') - assert.match(template, /# Core UI:/) - assert.match(template, /# Shared scope defaults:/) - assert.match(template, /# Module overrides:/) + assert.match(template, /# Start with \[App\]\./) + assert.match(template, /# Everything else below is optional and can be removed to inherit defaults\./) + assert.match(template, /# \[Controls\] and \[Scopes\] are shared override groups\./) + assert.match(template, /# Module sections show the full set of supported tokens for each module\./) + assert.match(template, /# Remove any token to let Prism inherit or derive it\./) + assert.match(template, /# Remove an entire section if that area should use Prism's defaults\./) + assert.match(template, /# Optional palette extras:/) assert.match(template, /# Optional shell overrides:/) - assert.match(template, /flat_controls = false/) - assert.match(template, /# toolbar_bg =/) - assert.doesNotMatch(template, /\[Spectrum\]\nbackground =/) - assert.doesNotMatch(template, /\[Oscilloscope\]\nbackground =/) - assert.doesNotMatch(template, /\[Waveform\]\nbackground =/) + assert.match(template, /^\[Controls\]$/m) + assert.match(template, /^\[Scopes\]$/m) + assert.match(template, /^\[Spectrum\]$/m) + assert.match(template, /^flat_controls = false$/m) + assert.match(template, /^toolbar_bg = 4, 8, 12, 199$/m) + assert.equal(parsed.app.accent, 'rgb(56, 189, 248)') + assert.equal(parsed.app.textMuted, 'rgba(255, 255, 255, 0.42)') assert.equal(parsed.controls.flatControls, 'false') }) @@ -207,7 +241,7 @@ test('Astra renderer consumes Astra-specific button theme vars', async () => { }) test('bundled and migrated accent themes recolor every accent-driven scope', () => { - const purple = createBundledThemes().find((theme) => theme.id === 'theme_purple') + const purple = createBundledThemes().find((theme) => theme.name === 'Purple') assert.ok(purple) assert.equal(purple.oscilloscope.line, purple.app.accent) assert.equal(purple.vectorscope.trace, purple.app.accent) @@ -223,8 +257,8 @@ test('library seeds default themes and template file', async () => { try { const snapshot = await harness.library.getSnapshot() - assert.ok(snapshot.themes[DEFAULT_THEME_ID]) - assert.equal(snapshot.activeThemeId, DEFAULT_THEME_ID) + assert.ok(snapshot.themes[DEFAULT_THEME_NAME]) + assert.equal(snapshot.activeThemeId, DEFAULT_THEME_NAME) const fileNames = (await readdir(harness.themesDir)).sort() assert.ok(fileNames.includes('Default.iro')) @@ -235,35 +269,33 @@ test('library seeds default themes and template file', async () => { assert.match(defaultThemeContent, /version = 2/) assert.match(defaultThemeContent, /\[App\]/) assert.doesNotMatch(defaultThemeContent, /\[All\]/) - assert.match(templateContent, /\[Controls\]/) - assert.match(templateContent, /\[Scopes\]/) + assert.match(templateContent, /\[App\]/) + assert.match(templateContent, /^\[Controls\]$/m) + assert.match(templateContent, /^\[Scopes\]$/m) } finally { await harness.cleanup() } }) -test('importing the same embedded theme id replaces the managed theme', async () => { +test('re-importing the same filename stem replaces the managed theme', async () => { const harness = await createHarness() try { const theme = createDefaultTheme() - theme.id = 'theme_shared' theme.name = 'Shared' - const externalPath = join(harness.rootDir, 'shared.iro') + const externalPath = join(harness.rootDir, 'Shared.iro') await writeFile(externalPath, serializeThemeFile(theme), 'utf8') const firstSnapshot = await harness.library.importThemeFromPath(externalPath) - assert.equal(firstSnapshot.activeThemeId, 'theme_shared') + assert.equal(firstSnapshot.activeThemeId, 'Shared') - theme.name = 'Shared Updated' theme.app.accent = 'rgb(74, 222, 128)' - const updatedPath = join(harness.rootDir, 'shared-updated.iro') - await writeFile(updatedPath, serializeThemeFile(theme), 'utf8') + await writeFile(externalPath, serializeThemeFile(theme), 'utf8') - const secondSnapshot = await harness.library.importThemeFromPath(updatedPath) - assert.equal(secondSnapshot.activeThemeId, 'theme_shared') - assert.equal(secondSnapshot.themes.theme_shared.name, 'Shared Updated') - assert.equal(secondSnapshot.themes.theme_shared.app.accent, 'rgb(74, 222, 128)') + const secondSnapshot = await harness.library.importThemeFromPath(externalPath) + assert.equal(secondSnapshot.activeThemeId, 'Shared') + assert.equal(secondSnapshot.themes.Shared.name, 'Shared') + assert.equal(secondSnapshot.themes.Shared.app.accent, 'rgb(74, 222, 128)') } finally { await harness.cleanup() } @@ -276,7 +308,6 @@ test('library refreshes shipped bundled themes when their definitions change', a await harness.library.getSnapshot() const stalePurple = createDefaultTheme() - stalePurple.id = 'theme_purple' stalePurple.name = 'Purple' stalePurple.app.accent = 'rgb(167, 139, 250)' stalePurple.oscilloscope.line = 'rgb(56, 189, 248)' @@ -285,8 +316,8 @@ test('library refreshes shipped bundled themes when their definitions change', a await writeFile(join(harness.themesDir, 'Purple.iro'), serializeThemeFile(stalePurple), 'utf8') const snapshot = await harness.library.reloadThemes() - assert.equal(snapshot.themes.theme_purple.oscilloscope.line, snapshot.themes.theme_purple.app.accent) - assert.equal(snapshot.themes.theme_purple.vectorscope.trace, snapshot.themes.theme_purple.app.accent) + assert.equal(snapshot.themes.Purple.oscilloscope.line, snapshot.themes.Purple.app.accent) + assert.equal(snapshot.themes.Purple.vectorscope.trace, snapshot.themes.Purple.app.accent) } finally { await harness.cleanup() } @@ -303,9 +334,9 @@ test('library refreshes the managed template when its generated layout changes', await harness.library.reloadThemes() const templateContent = await readFile(join(harness.themesDir, '_TEMPLATE.iro'), 'utf8') - assert.match(templateContent, /# Core UI:/) - assert.match(templateContent, /flat_controls = false/) - assert.doesNotMatch(templateContent, /\[Spectrum\]\nbackground =/) + assert.match(templateContent, /# Start with \[App\]\./) + assert.match(templateContent, /^\[Controls\]$/m) + assert.match(templateContent, /^\[Spectrum\]$/m) } finally { await harness.cleanup() } @@ -321,16 +352,40 @@ test('legacy migration can create an accent theme and make it active', async () }) assert.equal(migration.didMigrate, true) - assert.equal(migration.snapshot.activeThemeId, 'theme_migrated_accent') - assert.ok(migration.snapshot.themes.theme_migrated_accent) + assert.equal(migration.snapshot.activeThemeId, 'Migrated Accent') + assert.ok(migration.snapshot.themes['Migrated Accent']) const localState = JSON.parse(await readFile(harness.localStatePath, 'utf8')) as { activeThemeId: string | null migrationVersion: number } - assert.equal(localState.activeThemeId, 'theme_migrated_accent') + assert.equal(localState.activeThemeId, 'Migrated Accent') assert.equal(localState.migrationVersion, 1) } finally { await harness.cleanup() } }) + +test('library remaps legacy active theme ids in local state to filename-derived keys', async () => { + const harness = await createHarness() + + try { + await mkdir(dirname(harness.localStatePath), { recursive: true }) + await writeFile(harness.localStatePath, JSON.stringify({ + format: 'prism-theme-local', + version: 1, + migrationVersion: 0, + activeThemeId: 'theme_midnight', + }, null, 2)) + + const snapshot = await harness.library.getSnapshot() + assert.equal(snapshot.activeThemeId, 'Midnight') + + const localState = JSON.parse(await readFile(harness.localStatePath, 'utf8')) as { + activeThemeId: string | null + } + assert.equal(localState.activeThemeId, 'Midnight') + } finally { + await harness.cleanup() + } +})