changes (theres too many)

This commit is contained in:
Boof2015
2026-04-06 23:10:16 -04:00
parent a5cbbed8c3
commit a685b891e4
19 changed files with 663 additions and 342 deletions
+26 -1
View File
@@ -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<void> {
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
-3
View File
@@ -45,7 +45,6 @@ export class FileBackedProfileLibrary {
constructor(
private readonly profilesDir: string,
private readonly localStatePath: string,
private readonly resolveDefaultThemeId?: () => Promise<string | null>,
) {}
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,
+63 -49
View File
@@ -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<ThemeLibrarySnapshot> {
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<ThemeLibrarySnapshot> {
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<ThemeLibrarySnapshot> {
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<PrismTheme> {
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<PrismThemeLocalStateV1> {
@@ -283,11 +304,12 @@ export class FileBackedThemeLibrary {
theme: PrismTheme,
currentPath?: string,
): Promise<string> {
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<string> {
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))
+1
View File
@@ -74,6 +74,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
importThemeDialog: () => ipcRenderer.invoke('themes:import-dialog') as Promise<ThemeLibrarySnapshot | null>,
revealThemesFolder: () => ipcRenderer.invoke('themes:reveal-folder') as Promise<void>,
migrateLegacyTheme: (payload: LegacyThemeMigrationPayload) => ipcRenderer.invoke('themes:migrate-legacy', payload) as Promise<LegacyThemeMigrationResult>,
openExternalUrl: (url: string) => ipcRenderer.invoke('shell:open-external', url) as Promise<void>,
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),
+70 -3
View File
@@ -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<HTMLDivElement | null>(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<void> => {
await loadTheme(value)
setThemeId(value)
}
const handleSaveAstraConfig = async (): Promise<void> => {
@@ -236,6 +273,18 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
}
}
const handleOpenThemeWebsite = async (url: string): Promise<void> => {
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<HTMLDivElement>): void => {
const railElement = event.currentTarget
const target = event.target
@@ -300,7 +349,25 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
<div className="bottom-bar__divider" />
<section className="bottom-bar__section bottom-bar__section--theme">
<div className="bottom-bar__section-title">Theme</div>
<div className="bottom-bar__section-header">
<div className="bottom-bar__section-title">Theme</div>
{themeCredit.credit ? (
themeCredit.url ? (
<a
className="bottom-bar__theme-credit bottom-bar__theme-credit--link"
href={themeCredit.url}
onClick={(event) => {
event.preventDefault()
void handleOpenThemeWebsite(themeCredit.url!)
}}
>
By {themeCredit.credit}
</a>
) : (
<span className="bottom-bar__theme-credit">By {themeCredit.credit}</span>
)
) : null}
</div>
<div className="bottom-bar__section-body">
<div className="bottom-bar__inline bottom-bar__inline--theme">
<ThemedSelect
+1
View File
@@ -73,6 +73,7 @@ declare global {
importThemeDialog: () => Promise<ThemeLibrarySnapshot | null>
revealThemesFolder: () => Promise<void>
migrateLegacyTheme: (payload: LegacyThemeMigrationPayload) => Promise<LegacyThemeMigrationResult>
openExternalUrl: (url: string) => Promise<void>
expandSettings: (panelHeight: number) => void
collapseSettings: (panelHeight: number) => void
setSettingsHeight: (panelHeight: number) => void
-2
View File
@@ -9,7 +9,6 @@ import {
} from '../../shared/profileState'
export interface ProfileDraftSource {
themeId: string | null
scopeOrder: ScopeKind[]
hiddenScopes: Iterable<ScopeKind>
widthWeights: Record<ScopeKind, number>
@@ -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 },
+2 -44
View File
@@ -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<ScopeKind, number>
@@ -40,7 +38,6 @@ interface PersistedSettingsState {
}
interface WorkingSettingsState {
themeId: string | null
scopeOrder: ScopeKind[]
hiddenScopes: Set<ScopeKind>
widthWeights: Record<ScopeKind, number>
@@ -58,7 +55,6 @@ interface SettingsState extends WorkingSettingsState {
geometrySyncUntil: number
initializeProfiles: () => Promise<void>
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<PersistedSettingsState>): 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<ScopeKind>(normalizeHiddenScopes(normalizedProfile.hiddenScopes)),
widthWeights: normalizeWidthWeights(normalizedProfile.widthWeights),
@@ -208,9 +188,6 @@ function createWorkingStateFromProfile(profile: Profile): WorkingSettingsState {
function createWorkingStateFromPersistedState(state: Partial<PersistedSettingsState>): WorkingSettingsState {
return {
themeId: typeof state.themeId === 'string' && state.themeId.trim()
? state.themeId.trim()
: null,
scopeOrder: normalizeScopeOrder(state.scopeOrder),
hiddenScopes: new Set<ScopeKind>(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<SettingsState>((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<SettingsState>((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)
+2 -2
View File
@@ -82,9 +82,9 @@ applyThemeToDOM(fallbackTheme)
export const useThemeStore = create<ThemeState>((set) => ({
themes: {
[fallbackTheme.id]: createDefaultTheme(),
[fallbackTheme.name]: createDefaultTheme(),
},
activeThemeId: fallbackTheme.id,
activeThemeId: fallbackTheme.name,
activeTheme: fallbackTheme,
accent: fallbackTheme.interface.accent,
+29 -1
View File
@@ -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;
}
+7 -4
View File
@@ -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()
+5 -3
View File
@@ -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
-15
View File
@@ -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> | 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),
+142 -116
View File
@@ -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<T extends SectionTokenMap>(
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<PrismTheme>
: {}
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<T extends SectionTokenMap>(
@@ -807,13 +820,11 @@ function serializeSection<T extends SectionTokenMap>(
}
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<Record<string, string | undefined>>)
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<Record<string, string | undefined>>)
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<Record<string, string | undefined>>)
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<Record<string, string | undefined>>)
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<Record<string, string | undefined>>)
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<Record<string, string | undefined>>)
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<Record<string, string | undefined>>)
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<Record<string, string | undefined>>)
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<Record<string, string | undefined>>)
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<Record<string, string | undefined>>)
const astraSection = serializeSection('Astra', { ...base.astra }, ASTRA_SCHEMA as SectionSchema<Record<string, string | undefined>>)
@@ -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<PrismResolvedTheme, 'interface'>): Record<string, string> {
@@ -1451,7 +1477,7 @@ export function applyResolvedThemeToDocument(
}
export function getDefaultThemeIdForLocalState(): string {
return DEFAULT_THEME_ID
return DEFAULT_THEME_NAME
}
export function getLegacyThemeMigrationVersion(): number {
+1 -2
View File
@@ -12,7 +12,6 @@ export const DEFAULT_PROFILE_NAME = 'Default'
export interface Profile {
name: string
themeId: string | null
scopeOrder: ScopeKind[]
hiddenScopes: ScopeKind[]
widthWeights: Record<ScopeKind, number>
@@ -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<ScopeKind, number>
-4
View File
@@ -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
+53 -25
View File
@@ -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<void>
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()
+164 -26
View File
@@ -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<string, unknown> = {}): {
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 },
+97 -42
View File
@@ -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()
}
})