update theming system, minor qol improvements

This commit is contained in:
Boof2015
2026-03-31 14:15:56 -04:00
parent 6f63c1997f
commit 3a8a6dae75
25 changed files with 1838 additions and 740 deletions
+1
View File
@@ -13,6 +13,7 @@
"test:astra": "node scripts/run-astra-integration-tests.mjs",
"test:profiles": "node scripts/run-profile-library-tests.mjs",
"test:themes": "node scripts/run-theme-library-tests.mjs",
"test:window-state": "node scripts/run-window-state-tests.mjs",
"test:renderer-helpers": "node scripts/run-renderer-helper-tests.mjs",
"build:native": "cd native && node-gyp rebuild",
"rebuild:native": "node -e \"const e=require('electron/package.json').version;const a=process.arch;const{execSync}=require('child_process');execSync('node-gyp rebuild --target='+e+' --arch='+a+' --dist-url=https://electronjs.org/headers',{stdio:'inherit',cwd:'native'})\"",
+44
View File
@@ -0,0 +1,44 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawn } from 'node:child_process'
import { build } from 'esbuild'
const rootDir = dirname(dirname(fileURLToPath(import.meta.url)))
const tempDir = await mkdtemp(join(tmpdir(), 'prism-window-state-tests-'))
const bundledTestPath = join(tempDir, 'window-state-store.test.mjs')
const entryPoint = join(rootDir, 'test', 'window-state-store.test.ts')
let exitCode = 1
try {
await build({
entryPoints: [entryPoint],
outfile: bundledTestPath,
bundle: true,
platform: 'node',
format: 'esm',
target: 'node23',
sourcemap: 'inline',
})
exitCode = await new Promise((resolve) => {
const child = spawn(process.execPath, ['--test', bundledTestPath], {
stdio: 'inherit',
cwd: rootDir,
})
child.on('exit', (code) => {
resolve(code ?? 1)
})
child.on('error', () => {
resolve(1)
})
})
} finally {
await rm(tempDir, { recursive: true, force: true })
}
process.exit(exitCode)
+119 -68
View File
@@ -27,6 +27,7 @@ import { calculateResizedWindowBounds } from '../shared/windowResize'
import { FileBackedProfileLibrary } from './profileLibrary'
import { AstraIntegrationService } from './services/astraIntegration'
import { FileBackedThemeLibrary } from './themeLibrary'
import { FileBackedWindowStateStore } from './windowStateStore'
let mainWindow: BrowserWindow | null = null
let moveInterval: ReturnType<typeof setInterval> | null = null
@@ -55,6 +56,7 @@ const pendingThemeOpenPaths: string[] = []
let profileLibrary: FileBackedProfileLibrary | null = null
let themeLibrary: FileBackedThemeLibrary | null = null
let astraIntegrationService: AstraIntegrationService | null = null
let windowStateStore: FileBackedWindowStateStore | null = null
const WINDOW_DEFAULTS = {
width: 900,
@@ -93,6 +95,16 @@ function getThemeLibrary(): FileBackedThemeLibrary {
return themeLibrary
}
function getWindowStateStore(): FileBackedWindowStateStore {
if (!windowStateStore) {
windowStateStore = new FileBackedWindowStateStore(
join(app.getPath('userData'), 'window-state.json'),
)
}
return windowStateStore
}
function broadcastAstraState(state: AstraIntegrationState): void {
for (const window of BrowserWindow.getAllWindows()) {
if (window.isDestroyed() || window.webContents.isDestroyed()) continue
@@ -242,6 +254,27 @@ function isMainRendererWindow(window: BrowserWindow | null): boolean {
return window !== null && window === mainWindow
}
function getScopeKindForWindow(window: BrowserWindow | null): ScopeKind | null {
if (!window) return null
for (const [kind, popoutWindow] of scopePopoutWindows) {
if (popoutWindow === window) {
return kind
}
}
return null
}
function describeWindow(window: BrowserWindow): string {
if (isMainRendererWindow(window)) {
return 'main window'
}
const kind = getScopeKindForWindow(window)
return kind ? `${kind} popout` : `window ${window.id}`
}
function getBaseMinHeight(window: BrowserWindow): number {
return isMainRendererWindow(window) ? WINDOW_DEFAULTS.minHeight : POPOUT_DEFAULTS.minHeight
}
@@ -496,6 +529,84 @@ function normalizeBounds(raw: unknown, fallback: WindowBounds): WindowBounds {
}
}
function emitAlwaysOnTopChanged(window: BrowserWindow, next: boolean): void {
if (window.isDestroyed() || window.webContents.isDestroyed()) {
return
}
window.webContents.send('window:always-on-top-changed', next)
}
async function persistAlwaysOnTopPreference(window: BrowserWindow, next: boolean): Promise<void> {
if (isMainRendererWindow(window)) {
await getWindowStateStore().setMainAlwaysOnTop(next)
return
}
const kind = getScopeKindForWindow(window)
if (kind) {
await getWindowStateStore().setPopoutAlwaysOnTop(kind, next)
}
}
function setWindowAlwaysOnTop(window: BrowserWindow, next: boolean): void {
if (window.isDestroyed()) {
return
}
window.setAlwaysOnTop(next)
emitAlwaysOnTopChanged(window, next)
void persistAlwaysOnTopPreference(window, next).catch((error) => {
console.warn(`Could not persist always-on-top state for ${describeWindow(window)}:`, error)
})
}
function attachWindowShortcuts(window: BrowserWindow): void {
const scopeKeys = new Map([
['1', 0],
['2', 1],
['3', 2],
['4', 3],
['5', 4],
['6', 5],
['7', 6],
['8', 7],
])
window.webContents.on('before-input-event', (_event, input: Electron.Input) => {
if (input.type !== 'keyDown') {
return
}
const isMainWindow = isMainRendererWindow(window)
if (!input.alt && !input.control && !input.meta && !input.shift) {
if (isMainWindow) {
const scopeIndex = scopeKeys.get(input.key)
if (scopeIndex !== undefined) {
window.webContents.send('shortcut:toggle-scope', scopeIndex)
return
}
if (input.key === ' ') {
window.webContents.send('shortcut:toggle-capture')
return
}
}
if (input.key === 't') {
setWindowAlwaysOnTop(window, !window.isAlwaysOnTop())
}
return
}
if (isMainWindow && input.key === ',' && input.meta && !input.alt && !input.control && !input.shift) {
window.webContents.send('shortcut:toggle-settings')
}
})
}
function loadRendererTarget(window: BrowserWindow, query: Record<string, string>): void {
if (process.env.ELECTRON_RENDERER_URL) {
const url = new URL(process.env.ELECTRON_RENDERER_URL)
@@ -513,7 +624,7 @@ function createMainWindow(): void {
mainWindow = new BrowserWindow({
...WINDOW_DEFAULTS,
...getFramelessWindowChromeOptions(),
alwaysOnTop: true,
alwaysOnTop: getWindowStateStore().getMainAlwaysOnTop(),
autoHideMenuBar: true,
resizable: true,
maximizable: true,
@@ -576,16 +687,10 @@ function createMainWindow(): void {
scheduleMainWindowBoundsSave(mainWindow)
})
attachWindowShortcuts(mainWindow)
loadRendererTarget(mainWindow, { window: 'main' })
}
function setAllWindowsAlwaysOnTop(next: boolean): void {
mainWindow?.setAlwaysOnTop(next)
for (const window of scopePopoutWindows.values()) {
window.setAlwaysOnTop(next)
}
}
function emitPopoutBoundsChanged(kind: ScopeKind, window: BrowserWindow): void {
if (!mainWindow || mainWindow.isDestroyed() || window.isDestroyed() || !mainRendererReady) return
@@ -664,7 +769,7 @@ function createScopePopoutWindow(kind: ScopeKind, rawBounds?: WindowBounds): Bro
skipTaskbar: true,
autoHideMenuBar: true,
title: `Prism ${kind}`,
alwaysOnTop: mainWindow.isAlwaysOnTop(),
alwaysOnTop: getWindowStateStore().getPopoutAlwaysOnTop(kind),
show: false,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
@@ -713,6 +818,7 @@ function createScopePopoutWindow(kind: ScopeKind, rawBounds?: WindowBounds): Bro
popoutWindow.on('move', () => emitPopoutBoundsChanged(kind, popoutWindow))
popoutWindow.on('resize', () => emitPopoutBoundsChanged(kind, popoutWindow))
attachWindowShortcuts(popoutWindow)
loadRendererTarget(popoutWindow, { window: 'scope-popout', scope: kind })
return popoutWindow
}
@@ -888,19 +994,11 @@ function setupIPC(): void {
const targetWindow = getWindowFromSender(event.sender)
if (!targetWindow) return
const current = targetWindow.isAlwaysOnTop()
const next = !current
if (mainWindow && targetWindow === mainWindow) {
setAllWindowsAlwaysOnTop(next)
mainWindow?.webContents.send('window:always-on-top-changed', next)
return
}
targetWindow.setAlwaysOnTop(next)
setWindowAlwaysOnTop(targetWindow, !targetWindow.isAlwaysOnTop())
})
ipcMain.handle('window:is-always-on-top', (event) => {
return getWindowFromSender(event.sender)?.isAlwaysOnTop() ?? true
return getWindowFromSender(event.sender)?.isAlwaysOnTop() ?? false
})
ipcMain.handle('audio:get-desktop-sources', async () => {
@@ -1216,64 +1314,17 @@ function setupIPC(): void {
})
}
function setupShortcuts(): void {
if (!mainWindow) return
const shortcutWindow = mainWindow
const scopeKeys = new Map([
['1', 0],
['2', 1],
['3', 2],
['4', 3],
['5', 4],
['6', 5],
['7', 6],
['8', 7],
])
const beforeInputHandler = (_event: Electron.Event, input: Electron.Input) => {
if (input.type !== 'keyDown') {
return
}
if (!input.alt && !input.control && !input.meta && !input.shift) {
const scopeIndex = scopeKeys.get(input.key)
if (scopeIndex !== undefined) {
shortcutWindow.webContents.send('shortcut:toggle-scope', scopeIndex)
return
}
if (input.key === 't') {
const next = !shortcutWindow.isAlwaysOnTop()
setAllWindowsAlwaysOnTop(next)
shortcutWindow.webContents.send('window:always-on-top-changed', next)
return
}
if (input.key === ' ') {
shortcutWindow.webContents.send('shortcut:toggle-capture')
return
}
}
if (input.key === ',' && input.meta && !input.alt && !input.control && !input.shift) {
shortcutWindow.webContents.send('shortcut:toggle-settings')
}
}
shortcutWindow.webContents.on('before-input-event', beforeInputHandler)
}
const hasSingleInstanceLock = app.requestSingleInstanceLock()
if (!hasSingleInstanceLock) {
app.quit()
} else {
app.whenReady().then(() => {
app.whenReady().then(async () => {
setupPermissions()
void getAstraIntegrationService().initialize()
setupIPC()
await getWindowStateStore().initialize()
createMainWindow()
setupShortcuts()
queueProfileOpenPaths(extractProfilePathsFromArgv(process.argv))
queueThemeOpenPaths(extractThemePathsFromArgv(process.argv))
void processPendingProfileOpenPaths()
+21 -4
View File
@@ -167,10 +167,7 @@ export class FileBackedThemeLibrary {
entries = await this.readManagedEntries()
}
if (!entries.some((entry) => entry.id === DEFAULT_THEME_ID)) {
await this.writeManagedTheme(entries, DEFAULT_THEME_ID, createDefaultTheme())
entries = await this.readManagedEntries()
}
entries = await this.syncBundledThemes(entries)
await this.ensureTemplateFile()
@@ -188,6 +185,26 @@ export class FileBackedThemeLibrary {
}
}
private async syncBundledThemes(entries: ManagedThemeEntry[]): Promise<ManagedThemeEntry[]> {
let nextEntries = entries
for (const theme of createBundledThemes()) {
const existingEntry = nextEntries.find((entry) => entry.id === theme.id) ?? null
const shouldWrite = !existingEntry || serializeThemeFile(existingEntry.theme) !== serializeThemeFile(theme)
if (!shouldWrite) continue
await this.writeManagedTheme(nextEntries, theme.id, theme, existingEntry?.path)
nextEntries = await this.readManagedEntries()
}
if (!nextEntries.some((entry) => entry.id === DEFAULT_THEME_ID)) {
await this.writeManagedTheme(nextEntries, DEFAULT_THEME_ID, createDefaultTheme())
nextEntries = await this.readManagedEntries()
}
return nextEntries
}
private async ensureTemplateFile(): Promise<void> {
const targetPath = resolve(join(this.themesDir, TEMPLATE_THEME_FILE_NAME))
if (await this.pathExists(targetPath)) {
+83
View File
@@ -0,0 +1,83 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import { createEmptyWindowLocalState, normalizeWindowLocalState } from '../shared/windowState'
import type { ScopeKind } from '../types/scope'
import type { PrismWindowLocalStateV1 } from '../types/windowState'
export class FileBackedWindowStateStore {
private state: PrismWindowLocalStateV1 = createEmptyWindowLocalState()
private initialized = false
private writeChain: Promise<void> = Promise.resolve()
constructor(private readonly localStatePath: string) {}
async initialize(): Promise<void> {
if (this.initialized) return
this.state = await this.readLocalState()
this.initialized = true
}
getMainAlwaysOnTop(): boolean {
return this.state.mainAlwaysOnTop
}
getPopoutAlwaysOnTop(kind: ScopeKind): boolean {
return this.state.popoutAlwaysOnTop[kind] === true
}
async setMainAlwaysOnTop(alwaysOnTop: boolean): Promise<void> {
await this.ensureInitialized()
this.state = normalizeWindowLocalState({
...this.state,
mainAlwaysOnTop: alwaysOnTop,
popoutAlwaysOnTop: { ...this.state.popoutAlwaysOnTop },
})
await this.persistState()
}
async setPopoutAlwaysOnTop(kind: ScopeKind, alwaysOnTop: boolean): Promise<void> {
await this.ensureInitialized()
const popoutAlwaysOnTop = { ...this.state.popoutAlwaysOnTop }
if (alwaysOnTop) {
popoutAlwaysOnTop[kind] = true
} else {
delete popoutAlwaysOnTop[kind]
}
this.state = normalizeWindowLocalState({
...this.state,
popoutAlwaysOnTop,
})
await this.persistState()
}
private async ensureInitialized(): Promise<void> {
if (!this.initialized) {
await this.initialize()
}
}
private async readLocalState(): Promise<PrismWindowLocalStateV1> {
try {
const raw = await readFile(this.localStatePath, 'utf8')
return normalizeWindowLocalState(JSON.parse(raw) as unknown)
} catch {
return createEmptyWindowLocalState()
}
}
private async persistState(): Promise<void> {
const state = normalizeWindowLocalState(this.state)
const payload = `${JSON.stringify(state, null, 2)}\n`
this.writeChain = this.writeChain
.catch(() => {})
.then(async () => {
await mkdir(dirname(this.localStatePath), { recursive: true })
await writeFile(this.localStatePath, payload, 'utf8')
})
await this.writeChain
}
}
-72
View File
@@ -47,16 +47,11 @@ 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 themeId = useSettingsStore((s) => s.themeId)
const setThemeId = useSettingsStore((s) => s.setThemeId)
const {
themes,
activeThemeId,
loadTheme,
renameTheme,
deleteTheme,
reloadThemes,
importThemeFromDialog,
showThemesFolder,
} = useThemeStore()
const astraState = useAstraStore((s) => s.integrationState)
@@ -161,26 +156,6 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
setThemeId(value)
}
const handleRenameTheme = async (): Promise<void> => {
if (!activeThemeId || activeThemeId === 'theme_default') return
const activeTheme = themes[activeThemeId]
if (!activeTheme) return
const nextName = window.prompt('Rename theme', activeTheme.name)?.trim()
if (!nextName) return
await renameTheme(activeThemeId, nextName)
}
const handleDeleteTheme = async (): Promise<void> => {
if (!activeThemeId || activeThemeId === 'theme_default') return
const activeTheme = themes[activeThemeId]
if (!activeTheme) return
if (!window.confirm(`Delete "${activeTheme.name}"?`)) return
await deleteTheme(activeThemeId)
setThemeId(useThemeStore.getState().activeThemeId)
}
const handleSaveAstraConfig = async (): Promise<void> => {
const nextConfig: AstraIntegrationConfig = {
baseUrl: astraBaseUrlInput,
@@ -264,27 +239,6 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
</option>
))}
</ThemedSelect>
<button
type="button"
className="settings-chip"
onClick={() => {
void (async () => {
await importThemeFromDialog()
setThemeId(useThemeStore.getState().activeThemeId)
})()
}}
>
Import
</button>
<button
type="button"
className="settings-chip"
onClick={() => {
void reloadThemes()
}}
>
Reload
</button>
<button
type="button"
className="settings-chip"
@@ -294,32 +248,6 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
>
Folder
</button>
{activeThemeId && activeThemeId !== 'theme_default' ? (
<button
type="button"
className="settings-chip"
onClick={() => {
void handleRenameTheme()
}}
>
Rename
</button>
) : null}
{activeThemeId && activeThemeId !== 'theme_default' ? (
<button
type="button"
className="settings-chip"
onClick={() => {
void handleDeleteTheme()
}}
>
Delete
</button>
) : null}
<div className="settings-status-pill">
<span className="settings-status-pill__dot" />
<span>{themeId ? 'Saved With Profile' : 'Not Linked'}</span>
</div>
</div>
</div>
</section>
+31 -21
View File
@@ -71,12 +71,13 @@ export function scopeSettingsToOptions(
const s = settings as ScopeSettings['spectrum']
const t = theme as ResolvedSpectrumTheme
return {
lineColor: t.primary,
secondaryLineColor: t.secondary,
lineColor: t.line,
secondaryLineColor: t.sideLine,
gradientColors: t.fillGradient,
heatColors: t.heatColors,
backgroundColor: t.background,
gridColor: t.guides,
labelColor: t.labels,
fftSize: s.fftSize,
tiltDbPerOctave: s.tiltDbPerOctave,
heatmapFill: s.heatmap,
@@ -91,9 +92,10 @@ export function scopeSettingsToOptions(
const s = settings as ScopeSettings['oscilloscope']
const t = theme as ResolvedOscilloscopeTheme
return {
lineColor: t.primary,
lineColor: t.line,
backgroundColor: t.background,
gridColor: t.guides,
gridMajorColor: t.guides,
gridMinorColor: t.guidesSecondary,
underfillColor: t.fill,
pitchLock: s.pitchLock,
underfillEnabled: s.underfillEnabled,
@@ -105,13 +107,15 @@ export function scopeSettingsToOptions(
const s = settings as ScopeSettings['vectorscope']
const t = theme as ResolvedVectorscopeTheme
return {
lineColor: t.primary,
lineColor: t.trace,
backgroundColor: t.background,
gridColor: t.guides,
gridMajorColor: t.guides,
gridMinorColor: t.guidesSecondary,
labelColor: t.labels,
bandColors: {
low: t.lowBand,
mid: t.midBand,
high: t.highBand,
low: t.bandLow,
mid: t.bandMid,
high: t.bandHigh,
},
mode: s.mode,
multiband: s.multiband,
@@ -124,8 +128,9 @@ export function scopeSettingsToOptions(
const s = settings as ScopeSettings['spectrogram']
const t = theme as ResolvedSpectrogramTheme
return {
lineColor: t.primary,
lineColor: t.mono,
heatColors: t.heatColors,
backgroundColor: t.background,
fftSize: s.fftSize,
scrollSpeed: s.scrollSpeed,
clarityMode: s.clarityMode,
@@ -137,11 +142,13 @@ export function scopeSettingsToOptions(
const s = settings as ScopeSettings['vumeter']
const t = theme as ResolvedVUMeterTheme
return {
lineColor: t.primary,
backgroundColor: t.background,
lineColor: t.level,
trackColor: t.track,
peakColor: t.peak,
clipColor: t.clip,
scaleColor: t.guides,
labelColor: t.text,
scaleColor: t.scale,
labelColor: t.labels,
mode: s.mode,
orientation: s.orientation,
}
@@ -150,10 +157,12 @@ export function scopeSettingsToOptions(
const s = settings as ScopeSettings['lufsmeter']
const t = theme as ResolvedLUFSMeterTheme
return {
lineColor: t.primary,
backgroundColor: t.background,
lineColor: t.level,
trackColor: t.track,
targetColor: t.target,
scaleColor: t.guides,
labelColor: t.text,
scaleColor: t.scale,
labelColor: t.labels,
mode: s.mode,
}
}
@@ -161,13 +170,14 @@ export function scopeSettingsToOptions(
const s = settings as ScopeSettings['waveform']
const t = theme as ResolvedWaveformTheme
return {
lineColor: t.primary,
backgroundColor: t.background,
lineColor: t.line,
gridMajorColor: t.guides,
gridMinorColor: t.guides,
gridMinorColor: t.guidesSecondary,
bandColors: {
low: t.lowBand,
mid: t.midBand,
high: t.highBand,
low: t.bandLow,
mid: t.bandMid,
high: t.bandHigh,
},
mode: s.mode,
scrollSpeed: s.scrollSpeed,
+40 -11
View File
@@ -1,11 +1,19 @@
import { useState, useEffect, useCallback, useRef, type CSSProperties, type JSX, type PointerEvent as ReactPointerEvent } from 'react'
import { useState, useEffect, useCallback, useRef, type JSX, type PointerEvent as ReactPointerEvent } from 'react'
import { useSettingsStore } from '../stores/settingsStore'
function SettingsIcon(): JSX.Element {
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
<circle cx="8" cy="8" r="2.2" fill="none" stroke="currentColor" strokeWidth="1.2" />
<path d="M8 1.7v2M8 12.3v2M14.3 8h-2M3.7 8h-2M12.4 3.6l-1.4 1.4M5 11l-1.4 1.4M12.4 12.4 11 11M5 5 3.6 3.6" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
<g fill="currentColor">
<rect x="7.15" y="1.15" width="1.7" height="2.7" rx="0.45" />
<rect x="7.15" y="1.15" width="1.7" height="2.7" rx="0.45" transform="rotate(60 8 8)" />
<rect x="7.15" y="1.15" width="1.7" height="2.7" rx="0.45" transform="rotate(120 8 8)" />
<rect x="7.15" y="1.15" width="1.7" height="2.7" rx="0.45" transform="rotate(180 8 8)" />
<rect x="7.15" y="1.15" width="1.7" height="2.7" rx="0.45" transform="rotate(240 8 8)" />
<rect x="7.15" y="1.15" width="1.7" height="2.7" rx="0.45" transform="rotate(300 8 8)" />
</g>
<circle cx="8" cy="8" r="3.45" fill="none" stroke="currentColor" strokeWidth="1.85" />
<circle cx="8" cy="8" r="1.5" fill="none" stroke="currentColor" strokeWidth="1.05" />
</svg>
)
}
@@ -70,6 +78,11 @@ interface ToolbarProps {
const DEFAULT_PROFILE_ID = 'profile_default'
function isToolbarInteractiveTarget(target: EventTarget | null): boolean {
return target instanceof Element
&& target.closest('button, .toolbar__reposition-menu') !== null
}
function getErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error && error.message
? error.message
@@ -88,13 +101,13 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
const updateActiveProfile = useSettingsStore((s) => s.updateActiveProfile)
const importProfileFromDialog = useSettingsStore((s) => s.importProfileFromDialog)
const showProfilesFolder = useSettingsStore((s) => s.showProfilesFolder)
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(true)
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(false)
const [showReposition, setShowReposition] = useState(false)
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false)
const profileButtonRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
window.electronAPI.isAlwaysOnTop().then(setIsAlwaysOnTop)
void window.electronAPI.isAlwaysOnTop().then(setIsAlwaysOnTop)
const unsubscribe = window.electronAPI.onAlwaysOnTopChanged(setIsAlwaysOnTop)
return unsubscribe
}, [])
@@ -284,10 +297,30 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
window.electronAPI.stopWindowMove()
}, [])
const handleToolbarDragStart = useCallback((event: ReactPointerEvent<HTMLDivElement>): void => {
if (isToolbarInteractiveTarget(event.target) || event.button !== 0) return
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
window.electronAPI.startWindowMove()
}, [])
const handleToolbarDragEnd = useCallback((event: ReactPointerEvent<HTMLDivElement>): void => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
window.electronAPI.stopWindowMove()
}, [])
const activeProfile = activeProfileId ? profiles[activeProfileId] : null
return (
<div className="toolbar">
<div
className="toolbar"
onPointerDown={handleToolbarDragStart}
onPointerUp={handleToolbarDragEnd}
onPointerCancel={handleToolbarDragEnd}
onLostPointerCapture={handleToolbarDragEnd}
>
<button
type="button"
className="toolbar__grab"
@@ -303,7 +336,6 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
<div
className="toolbar__brand"
style={{ WebkitAppRegion: 'drag' } as CSSProperties}
>
<span className="toolbar__brand-mark" />
<span className="toolbar__brand-text">Prism</span>
@@ -325,10 +357,7 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
</button>
</div>
<div
className="toolbar__spacer"
style={{ WebkitAppRegion: 'drag' } as CSSProperties}
/>
<div className="toolbar__spacer" />
<div className="toolbar__actions">
<div className="toolbar__reposition-wrap">
+34 -2
View File
@@ -21,8 +21,24 @@ function PopInIcon(): JSX.Element {
function SettingsIcon(): JSX.Element {
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
<circle cx="8" cy="8" r="2.1" fill="none" stroke="currentColor" strokeWidth="1.2" />
<path d="M8 1.8v1.7M8 12.5v1.7M14.2 8h-1.7M3.5 8H1.8M12.2 3.8l-1.2 1.2M5 11l-1.2 1.2M12.2 12.2 11 11M5 5 3.8 3.8" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
<g fill="currentColor">
<rect x="7.15" y="1.15" width="1.7" height="2.7" rx="0.45" />
<rect x="7.15" y="1.15" width="1.7" height="2.7" rx="0.45" transform="rotate(60 8 8)" />
<rect x="7.15" y="1.15" width="1.7" height="2.7" rx="0.45" transform="rotate(120 8 8)" />
<rect x="7.15" y="1.15" width="1.7" height="2.7" rx="0.45" transform="rotate(180 8 8)" />
<rect x="7.15" y="1.15" width="1.7" height="2.7" rx="0.45" transform="rotate(240 8 8)" />
<rect x="7.15" y="1.15" width="1.7" height="2.7" rx="0.45" transform="rotate(300 8 8)" />
</g>
<circle cx="8" cy="8" r="3.45" fill="none" stroke="currentColor" strokeWidth="1.85" />
<circle cx="8" cy="8" r="1.5" fill="none" stroke="currentColor" strokeWidth="1.05" />
</svg>
)
}
function PinIcon(): JSX.Element {
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M10.9 2.5 13 4.6 10.8 7v2.2l-1 1L8 8.4 4.8 11.6 4 10.8l3.2-3.2-1.8-1.8 1-1H8.6z" fill="none" stroke="currentColor" strokeWidth="1.1" strokeLinejoin="round" />
</svg>
)
}
@@ -49,12 +65,19 @@ const defaultTheme = resolveTheme(createDefaultTheme())
export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps): JSX.Element {
const [snapshot, setSnapshot] = useState<ScopePopoutSnapshot<ScopeKind> | null>(null)
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(false)
const [miniSettingsOpen, setMiniSettingsOpen] = useState(false)
const prevMiniSettingsOpenRef = useRef(false)
const frameTarget = usePerformanceStore((s) => s.frameTarget)
const frameScheduler = useMemo(() => new FrameScheduler({ frameTarget }), [])
const dataSource = useMemo(() => new ScopePopoutDataSource(scopeKind), [scopeKind])
useEffect(() => {
void window.electronAPI.isAlwaysOnTop().then(setIsAlwaysOnTop)
const unsubscribe = window.electronAPI.onAlwaysOnTopChanged(setIsAlwaysOnTop)
return unsubscribe
}, [])
useEffect(() => {
frameScheduler.setFrameTarget(frameTarget)
}, [frameScheduler, frameTarget])
@@ -191,6 +214,15 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps)
</div>
<div className="scope-popout__actions">
<button
type="button"
className={`scope-popout__button ${isAlwaysOnTop ? 'is-active' : ''}`.trim()}
onClick={() => window.electronAPI.toggleAlwaysOnTop()}
aria-label={isAlwaysOnTop ? 'Unpin from top' : 'Pin to top'}
title={isAlwaysOnTop ? 'Unpin from top' : 'Pin to top'}
>
<PinIcon />
</button>
<button
type="button"
className={`scope-popout__button ${miniSettingsOpen ? 'is-active' : ''}`.trim()}
+114 -103
View File
@@ -38,11 +38,23 @@
--control-bg-active: rgba(var(--accent-rgb), 0.12);
--control-border: rgba(255, 255, 255, 0.08);
--control-border-active: rgba(var(--accent-rgb), 0.28);
--control-text: rgba(255, 255, 255, 0.92);
--input-bg: rgba(10, 14, 20, 0.96);
--input-bg-focus: rgba(12, 17, 24, 0.98);
--input-border: rgba(255, 255, 255, 0.09);
--input-border-focus: rgba(var(--accent-rgb), 0.26);
--option-bg: rgba(8, 11, 16, 0.96);
--option-text: rgba(245, 247, 250, 1);
--slider-track: rgba(255, 255, 255, 0.08);
--slider-fill: rgba(56, 189, 248, 0.82);
--slider-thumb: rgba(125, 211, 252, 1);
--divider: rgba(255, 255, 255, 0.08);
--scope-bg: #000000;
--scope-guides: rgba(255, 255, 255, 0.1);
--scope-overlay-bg: rgba(8, 12, 18, 0.88);
--scope-overlay-text: rgba(255, 255, 255, 0.76);
--scope-overlay-border: rgba(255, 255, 255, 0.12);
--scope-resize-handle: rgba(255, 255, 255, 0.12);
}
* {
@@ -57,7 +69,7 @@ body,
width: 100%;
height: 100%;
overflow: hidden;
background: #000000;
background: var(--bg-primary);
color: var(--text-primary);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
-webkit-font-smoothing: antialiased;
@@ -190,8 +202,8 @@ select {
overflow: hidden;
background:
linear-gradient(180deg, var(--settings-bg-top), var(--settings-bg-bottom)),
rgba(0, 0, 0, 0.96);
border-top: 1px solid rgba(var(--accent-rgb), 0.14);
var(--panel-surface-soft);
border-top: 1px solid var(--divider);
box-shadow: 0 -16px 34px rgba(0, 0, 0, 0.34);
}
@@ -239,9 +251,9 @@ select {
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid rgba(var(--accent-rgb), 0.22);
background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.12), rgba(255, 255, 255, 0.02));
box-shadow: inset 0 1px 0 rgba(var(--accent-rgb), 0.08);
border: 1px solid var(--control-border-active);
background: linear-gradient(180deg, var(--control-bg-active), var(--panel-surface));
box-shadow: inset 0 1px 0 var(--glass-highlight);
}
.toolbar__brand-mark {
@@ -285,7 +297,7 @@ select {
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid transparent;
border: 1px solid var(--control-border);
background: var(--control-bg);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
color: var(--text-secondary);
@@ -300,9 +312,9 @@ select {
.toolbar__profile-button:hover,
.toolbar__profile-button.is-active {
color: var(--text-primary);
border-color: rgba(var(--accent-rgb), 0.26);
background: var(--control-bg-hover);
color: var(--control-text);
border-color: var(--control-border-active);
background: var(--control-bg-active);
}
.toolbar__profile-name {
@@ -317,7 +329,7 @@ select {
height: 6px;
border-radius: 999px;
background: var(--warning);
box-shadow: 0 0 8px rgba(255, 191, 0, 0.35);
box-shadow: 0 0 8px var(--warning);
flex-shrink: 0;
}
@@ -325,7 +337,6 @@ select {
flex: 1;
min-width: 0;
height: 100%;
-webkit-app-region: drag;
}
.toolbar__reposition-wrap {
@@ -340,7 +351,7 @@ select {
display: flex;
flex-direction: column;
background: var(--menu-bg);
border: 1px solid rgba(var(--accent-rgb), 0.22);
border: 1px solid var(--menu-border);
border-radius: 8px;
overflow: hidden;
z-index: 20;
@@ -370,7 +381,7 @@ select {
min-height: 28px;
padding: 0 9px;
border-radius: 999px;
border: 1px solid transparent;
border: 1px solid var(--control-border);
background: var(--control-bg);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
color: var(--text-tertiary);
@@ -384,16 +395,16 @@ select {
}
.toolbar__chip:hover {
color: rgba(255, 255, 255, 0.78);
border-color: rgba(var(--accent-rgb), 0.18);
color: var(--control-text);
border-color: var(--control-border-active);
background: var(--control-bg-hover);
}
.toolbar__chip.is-active {
color: var(--accent);
border-color: rgba(var(--accent-rgb), 0.26);
background: rgba(var(--accent-rgb), 0.12);
box-shadow: inset 0 0 0 1px rgba(var(--accent-rgb), 0.08);
border-color: var(--control-border-active);
background: var(--control-bg-active);
box-shadow: inset 0 0 0 1px var(--glass-highlight);
}
.toolbar__actions {
@@ -406,10 +417,10 @@ select {
width: 28px;
height: 28px;
border-radius: 999px;
border: 1px solid transparent;
border: 1px solid var(--control-border);
background: var(--control-bg);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.56);
color: var(--text-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
@@ -427,29 +438,29 @@ select {
}
.toolbar__icon-button:hover {
color: rgba(255, 255, 255, 0.82);
border-color: rgba(var(--accent-rgb), 0.18);
color: var(--control-text);
border-color: var(--control-border-active);
background: var(--control-bg-hover);
transform: translateY(-1px);
}
.toolbar__icon-button.is-active {
color: var(--accent);
border-color: rgba(var(--accent-rgb), 0.24);
background: rgba(var(--accent-rgb), 0.12);
border-color: var(--control-border-active);
background: var(--control-bg-active);
}
.toolbar__icon-button--danger:hover {
color: var(--danger);
border-color: rgba(248, 113, 113, 0.22);
background: rgba(248, 113, 113, 0.1);
border-color: var(--danger);
background: var(--control-bg-hover);
}
.scope-strip {
position: relative;
width: 100%;
height: 100%;
background-color: var(--bg-primary);
background-color: var(--scope-bg);
}
.scope-strip__grid {
@@ -484,9 +495,9 @@ select {
height: 28px;
padding: 0;
border-radius: 999px;
border: 1px solid transparent;
background: rgba(8, 12, 18, 0.88);
color: rgba(255, 255, 255, 0.76);
border: 1px solid var(--scope-overlay-border);
background: var(--scope-overlay-bg);
color: var(--scope-overlay-text);
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
cursor: pointer;
@@ -535,7 +546,7 @@ select {
}
.scope-strip__reorder-button:disabled {
color: rgba(255, 255, 255, 0.32);
color: var(--text-muted);
border-color: transparent;
cursor: default;
}
@@ -568,7 +579,7 @@ select {
.scope-strip__resize-handle-grip {
width: 1px;
height: 100%;
background: linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.12), transparent);
background: linear-gradient(180deg, transparent, var(--scope-resize-handle), transparent);
}
.astra-scope {
@@ -830,7 +841,7 @@ select {
}
.settings-panel {
background: linear-gradient(180deg, rgba(7, 10, 15, 0.95), rgba(4, 6, 10, 0.98));
background: linear-gradient(180deg, var(--settings-bg-top), var(--settings-bg-bottom));
display: flex;
flex-direction: column;
gap: 10px;
@@ -878,7 +889,7 @@ select {
.settings-section-title {
width: 100%;
color: rgba(255, 255, 255, 0.72);
color: var(--text-secondary);
font-size: 10px;
}
@@ -888,7 +899,7 @@ select {
flex-direction: column;
gap: 12px;
padding: 0 16px 14px;
border-left: 1px solid rgba(var(--accent-rgb), 0.12);
border-left: 1px solid var(--divider);
}
.settings-scope-section:first-child {
@@ -902,7 +913,7 @@ select {
gap: 12px;
padding: 10px 0 12px;
min-height: 42px;
border-bottom: 1px solid rgba(var(--accent-rgb), 0.14);
border-bottom: 1px solid var(--divider);
}
.settings-scope-section__title {
@@ -913,7 +924,7 @@ select {
.settings-scope-section__summary {
flex: 1;
color: rgba(255, 255, 255, 0.44);
color: var(--text-tertiary);
font-size: 9px;
letter-spacing: 0.08em;
white-space: nowrap;
@@ -965,46 +976,50 @@ select {
.settings-control__select {
position: relative;
display: flex;
align-items: center;
display: block;
width: 100%;
min-height: 34px;
padding: 0 12px;
padding-right: 36px;
border-radius: 10px;
border: 1px solid rgba(var(--accent-rgb), 0.2);
background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.1), rgba(7, 10, 15, 0.96));
box-shadow: inset 0 1px 0 rgba(var(--accent-rgb), 0.08);
color: var(--text-primary);
border: 1px solid var(--control-border);
background: var(--control-bg);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
color: var(--control-text);
font-size: 11px;
transition: border-color 140ms ease, background-color 140ms ease, box-shadow 140ms ease, color 140ms ease;
}
.settings-control__select:hover,
.settings-control__select:focus-within {
border-color: rgba(var(--accent-rgb), 0.34);
background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.16), rgba(8, 12, 18, 0.98));
color: var(--text-primary);
border-color: var(--control-border-active);
background: var(--control-bg-hover);
color: var(--control-text);
}
.themed-select__control {
display: block;
width: 100%;
min-width: 0;
height: 100%;
padding: 0;
min-height: 32px;
padding: 0 36px 0 12px;
border: 0;
border-radius: inherit;
box-sizing: border-box;
background: transparent;
color: inherit;
font: inherit;
font-size: 11px;
line-height: 1.2;
outline: none;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
}
.themed-select__control option,
.themed-select__control optgroup {
background: rgb(10, 14, 20);
color: rgb(245, 247, 250);
background: var(--option-bg);
color: var(--option-text);
}
.themed-select__control optgroup {
@@ -1049,10 +1064,10 @@ select {
.settings-control__range::-webkit-slider-runnable-track {
height: 6px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.05);
border: 1px solid var(--control-border);
background:
linear-gradient(90deg, rgba(var(--accent-rgb), 0.82), rgba(var(--accent-rgb), 0.46)) 0 / var(--range-percent) 100% no-repeat,
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.04));
linear-gradient(90deg, var(--slider-fill), var(--slider-fill)) 0 / var(--range-percent) 100% no-repeat,
linear-gradient(180deg, var(--slider-track), rgba(255, 255, 255, 0.04));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.05),
0 0 0 1px rgba(0, 0, 0, 0.22);
@@ -1064,8 +1079,8 @@ select {
height: 16px;
margin-top: -6px;
border-radius: 5px;
border: 1px solid rgba(var(--accent-rgb), 0.78);
background: linear-gradient(180deg, rgba(20, 26, 34, 0.98), rgba(7, 10, 15, 0.98));
border: 1px solid var(--control-border-active);
background: var(--slider-thumb);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.12),
0 0 0 2px rgba(var(--accent-rgb), 0.14),
@@ -1075,8 +1090,8 @@ select {
.settings-control__range::-moz-range-track {
height: 6px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.05);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.04));
border: 1px solid var(--control-border);
background: linear-gradient(180deg, var(--slider-track), rgba(255, 255, 255, 0.04));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.05),
0 0 0 1px rgba(0, 0, 0, 0.22);
@@ -1086,8 +1101,8 @@ select {
width: 12px;
height: 16px;
border-radius: 5px;
border: 1px solid rgba(var(--accent-rgb), 0.78);
background: linear-gradient(180deg, rgba(20, 26, 34, 0.98), rgba(7, 10, 15, 0.98));
border: 1px solid var(--control-border-active);
background: var(--slider-thumb);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.12),
0 0 0 2px rgba(var(--accent-rgb), 0.14),
@@ -1098,19 +1113,19 @@ select {
height: 6px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.02);
background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.82), rgba(var(--accent-rgb), 0.46));
background: linear-gradient(90deg, var(--slider-fill), var(--slider-fill));
}
.settings-control__range:hover::-webkit-slider-runnable-track,
.settings-control__range:focus-visible::-webkit-slider-runnable-track {
background:
linear-gradient(90deg, rgba(var(--accent-rgb), 0.92), rgba(var(--accent-rgb), 0.58)) 0 / var(--range-percent) 100% no-repeat,
linear-gradient(180deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05));
linear-gradient(90deg, var(--slider-fill), var(--slider-fill)) 0 / var(--range-percent) 100% no-repeat,
linear-gradient(180deg, var(--control-bg-hover), var(--slider-track));
}
.settings-control__range:hover::-moz-range-progress,
.settings-control__range:focus-visible::-moz-range-progress {
background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.92), rgba(var(--accent-rgb), 0.58));
background: linear-gradient(90deg, var(--slider-fill), var(--slider-fill));
}
.settings-control__range:hover::-webkit-slider-thumb,
@@ -1145,7 +1160,7 @@ select {
min-height: 28px;
padding: 0 11px;
border-radius: 9px;
border: 1px solid transparent;
border: 1px solid var(--control-border);
background: var(--control-bg);
color: var(--text-secondary);
font-size: 10px;
@@ -1158,16 +1173,16 @@ select {
}
.settings-chip:hover {
color: var(--text-primary);
border-color: rgba(var(--accent-rgb), 0.18);
color: var(--control-text);
border-color: var(--control-border-active);
background: var(--control-bg-hover);
}
.settings-chip.is-active {
color: rgba(255, 255, 255, 0.92);
border-color: rgba(var(--accent-rgb), 0.34);
background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.22), rgba(var(--accent-rgb), 0.1));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
color: var(--control-text);
border-color: var(--control-border-active);
background: var(--control-bg-active);
box-shadow: inset 0 1px 0 var(--glass-highlight);
}
.settings-status-pill {
@@ -1177,9 +1192,9 @@ select {
min-height: 32px;
padding: 0 10px;
border-radius: 10px;
border: 1px solid rgba(var(--accent-rgb), 0.22);
background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.12), rgba(7, 10, 15, 0.96));
box-shadow: inset 0 1px 0 rgba(var(--accent-rgb), 0.08);
border: 1px solid var(--control-border);
background: linear-gradient(180deg, var(--panel-surface), var(--panel-surface-soft));
box-shadow: inset 0 1px 0 var(--glass-highlight);
color: var(--text-secondary);
font-size: 10px;
white-space: nowrap;
@@ -1194,42 +1209,38 @@ select {
.settings-status-pill.is-connecting .settings-status-pill__dot {
background: var(--accent);
box-shadow: 0 0 8px rgba(var(--accent-rgb), 0.3);
box-shadow: 0 0 8px var(--accent-glow);
}
.settings-status-pill.is-connecting {
border-color: rgba(var(--accent-rgb), 0.32);
background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.16), rgba(7, 10, 15, 0.96));
border-color: var(--accent);
}
.settings-status-pill.is-capturing .settings-status-pill__dot {
background: var(--success);
box-shadow: 0 0 8px rgba(34, 197, 94, 0.4);
box-shadow: 0 0 8px var(--success);
}
.settings-status-pill.is-capturing {
border-color: rgba(34, 197, 94, 0.34);
background: linear-gradient(180deg, rgba(34, 197, 94, 0.14), rgba(7, 10, 15, 0.96));
border-color: var(--success);
}
.settings-status-pill.is-connected .settings-status-pill__dot {
background: var(--success);
box-shadow: 0 0 8px rgba(34, 197, 94, 0.34);
box-shadow: 0 0 8px var(--success);
}
.settings-status-pill.is-connected {
border-color: rgba(34, 197, 94, 0.28);
background: linear-gradient(180deg, rgba(34, 197, 94, 0.12), rgba(7, 10, 15, 0.96));
border-color: var(--success);
}
.settings-status-pill.is-error .settings-status-pill__dot {
background: var(--danger);
box-shadow: 0 0 8px rgba(248, 113, 113, 0.36);
box-shadow: 0 0 8px var(--danger);
}
.settings-status-pill.is-error {
border-color: rgba(248, 113, 113, 0.34);
background: linear-gradient(180deg, rgba(248, 113, 113, 0.14), rgba(7, 10, 15, 0.96));
border-color: var(--danger);
}
.settings-error-text {
@@ -1255,7 +1266,7 @@ select {
width: 20px;
height: 20px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
border: 1px solid var(--divider);
background: var(--swatch-color);
cursor: pointer;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.34);
@@ -1263,13 +1274,13 @@ select {
}
.settings-swatch:hover {
border-color: rgba(255, 255, 255, 0.28);
border-color: var(--control-border-active);
}
.settings-swatch.is-active {
border-color: rgba(255, 255, 255, 0.88);
border-color: var(--control-border-active);
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.88),
0 0 0 1px var(--control-border-active),
0 0 0 4px rgba(var(--accent-rgb), 0.12);
}
@@ -1284,8 +1295,8 @@ select {
width: 84px;
height: 28px;
border-radius: 9px;
border: 1px solid transparent;
background: linear-gradient(180deg, rgba(10, 14, 20, 0.96), rgba(5, 8, 12, 0.96));
border: 1px solid var(--input-border);
background: linear-gradient(180deg, var(--input-bg), var(--settings-bg-bottom));
cursor: pointer;
padding: 3px 4px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
@@ -1319,7 +1330,7 @@ select {
min-height: 92px;
padding: 0;
background: var(--bottom-bar-bg);
border-top: 1px solid rgba(var(--accent-rgb), 0.14);
border-top: 1px solid var(--divider);
flex-shrink: 0;
}
@@ -1401,9 +1412,9 @@ select {
gap: 4px;
padding: 4px;
border-radius: 12px;
border: 1px solid rgba(var(--accent-rgb), 0.22);
background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.08), rgba(255, 255, 255, 0.02));
box-shadow: inset 0 1px 0 rgba(var(--accent-rgb), 0.08);
border: 1px solid var(--control-border);
background: linear-gradient(180deg, var(--panel-surface), var(--control-bg));
box-shadow: inset 0 1px 0 var(--glass-highlight);
}
.bottom-bar__inline--theme {
@@ -1424,7 +1435,7 @@ select {
width: 1px;
align-self: stretch;
margin: 0 16px 0 0;
background: linear-gradient(180deg, transparent, rgba(var(--accent-rgb), 0.18) 18%, rgba(var(--accent-rgb), 0.08) 82%, transparent);
background: linear-gradient(180deg, transparent, var(--divider) 18%, var(--divider) 82%, transparent);
flex-shrink: 0;
}
@@ -1499,8 +1510,8 @@ select {
align-items: center;
flex: 0 0 auto;
padding: 10px 14px 10px 16px;
border-left: 1px solid rgba(var(--accent-rgb), 0.14);
background: linear-gradient(90deg, rgba(2, 4, 7, 0.76), var(--bottom-bar-bg) 24%, var(--bottom-bar-bg));
border-left: 1px solid var(--divider);
background: linear-gradient(90deg, transparent, var(--bottom-bar-bg) 28%, var(--bottom-bar-bg));
}
.bottom-bar__close {
@@ -1713,10 +1724,10 @@ select {
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
background: var(--scope-guides);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
background: var(--scope-overlay-border);
}
+87
View File
@@ -4,6 +4,10 @@ export interface RgbColor {
b: number
}
export interface RgbaColor extends RgbColor {
a: number
}
export const DEFAULT_VISUALIZER_TINT: RgbColor = { r: 56, g: 189, b: 248 }
function clampByte(value: number): number {
@@ -25,6 +29,23 @@ function parseRgbToken(value: string): number | null {
return clampByte(numeric)
}
function parseAlphaToken(value: string): number | null {
const token = value.trim()
if (!token) return null
if (token.endsWith('%')) {
const percent = Number.parseFloat(token.slice(0, -1))
if (!Number.isFinite(percent)) return null
return Math.max(0, Math.min(1, percent / 100))
}
const numeric = Number.parseFloat(token)
if (!Number.isFinite(numeric)) return null
return numeric > 1
? Math.max(0, Math.min(1, numeric / 255))
: Math.max(0, Math.min(1, numeric))
}
export function parseColorToRgb(color: string): RgbColor | null {
const normalizedColor = color.trim()
@@ -71,6 +92,62 @@ export function parseColorToRgb(color: string): RgbColor | null {
}
}
export function parseColorToRgba(color: string): RgbaColor | null {
const normalizedColor = color.trim()
if (normalizedColor.startsWith('#')) {
const hex = normalizedColor.slice(1)
const normalizedHex = hex.length === 3 || hex.length === 4
? hex.split('').map((ch) => `${ch}${ch}`).join('')
: hex
if (normalizedHex.length !== 6 && normalizedHex.length !== 8) return null
const r = Number.parseInt(normalizedHex.slice(0, 2), 16)
const g = Number.parseInt(normalizedHex.slice(2, 4), 16)
const b = Number.parseInt(normalizedHex.slice(4, 6), 16)
const a = normalizedHex.length === 8
? Number.parseInt(normalizedHex.slice(6, 8), 16) / 255
: 1
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null
return { r, g, b, a }
}
const rgbMatch = /^rgba?\((.*)\)$/i.exec(normalizedColor)
if (!rgbMatch) return null
const rawBody = rgbMatch[1]?.trim()
if (!rawBody) return null
const [colorPart, alphaPart] = rawBody.includes('/')
? rawBody.split('/', 2)
: [rawBody, undefined]
const tokens = colorPart.includes(',')
? colorPart.split(',').map((token) => token.trim())
: colorPart.split(/\s+/).filter(Boolean)
if (tokens.length < 3) return null
const r = parseRgbToken(tokens[0])
const g = parseRgbToken(tokens[1])
const b = parseRgbToken(tokens[2])
if (r === null || g === null || b === null) return null
const a = alphaPart
? parseAlphaToken(alphaPart)
: tokens[3]
? parseAlphaToken(tokens[3])
: 1
if (a === null) return null
return {
r: Math.round(r),
g: Math.round(g),
b: Math.round(b),
a,
}
}
export function colorToRgbChannels(color: string): string | null {
const rgb = parseColorToRgb(color)
if (!rgb) return null
@@ -80,3 +157,13 @@ export function colorToRgbChannels(color: string): string | null {
export function resolveColorToRgb(color: string, fallback: RgbColor = DEFAULT_VISUALIZER_TINT): RgbColor {
return parseColorToRgb(color) ?? fallback
}
export function multiplyColorAlpha(color: string, factor: number): string {
const parsed = parseColorToRgba(color)
if (!parsed) return color
const alpha = Math.max(0, Math.min(1, parsed.a * factor))
if (alpha >= 0.999) {
return `rgb(${parsed.r}, ${parsed.g}, ${parsed.b})`
}
return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${Number(alpha.toFixed(3))})`
}
+10 -2
View File
@@ -11,7 +11,9 @@ export interface LUFSMeterDataSource extends VisualizerSessionSource {
export interface LUFSMeterOptions {
mode?: LUFSMeterMode
backgroundColor?: string
lineColor?: string
trackColor?: string
targetColor?: string
scaleColor?: string
labelColor?: string
@@ -23,7 +25,9 @@ type ResolvedLUFSMeterOptions = Required<Omit<LUFSMeterOptions, 'dataSource' | '
const defaultOptions: ResolvedLUFSMeterOptions = {
mode: 'bar',
backgroundColor: 'transparent',
lineColor: '#38bdf8',
trackColor: 'rgba(56, 189, 248, 0.08)',
targetColor: 'rgba(56, 189, 248, 0.25)',
scaleColor: 'rgba(255, 255, 255, 0.35)',
labelColor: 'rgba(255, 255, 255, 0.8)',
@@ -396,7 +400,7 @@ export class LUFSMeter {
}
private drawFrame = (): void => {
const { canvas, ctx } = this
const { canvas, ctx, options } = this
const width = canvas.width
const height = canvas.height
@@ -407,6 +411,10 @@ export class LUFSMeter {
this.processAudio()
ctx.clearRect(0, 0, width, height)
if (options.backgroundColor !== 'transparent') {
ctx.fillStyle = options.backgroundColor
ctx.fillRect(0, 0, width, height)
}
this.drawBars(width, height)
}
@@ -450,7 +458,7 @@ export class LUFSMeter {
ctx.fillText(labels[i], x + barWidth / 2, padding)
// Bar background
ctx.fillStyle = `rgba(${tintR}, ${tintG}, ${tintB}, 0.08)`
ctx.fillStyle = this.options.trackColor
ctx.fillRect(x, barAreaTop, barWidth, barAreaHeight)
// Bar fill — gradient from dim at bottom to bright at top
+9 -6
View File
@@ -5,7 +5,7 @@ import {
isNativeAvailable
} from '../audio/native'
import { getNormalizedOscilloscopeDisplaySamples } from '../audio/native/oscilloscopeDisplaySamples'
import { colorToRgbChannels } from '../utils/color'
import { colorToRgbChannels, multiplyColorAlpha } from '../utils/color'
import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource'
import { FrameScheduler } from './frameScheduler'
import { VisualizerFrameLoop } from './visualizerFrameLoop'
@@ -19,7 +19,8 @@ export interface OscilloscopeOptions {
lineWidth?: number
backgroundColor?: string
showGrid?: boolean
gridColor?: string
gridMajorColor?: string
gridMinorColor?: string
underfillColor?: string
pitchLock?: boolean
underfillEnabled?: boolean
@@ -34,7 +35,8 @@ const defaultOptions: ResolvedOscilloscopeOptions = {
lineWidth: 2,
backgroundColor: 'transparent',
showGrid: true,
gridColor: 'rgba(255, 255, 255, 0.1)',
gridMajorColor: 'rgba(255, 255, 255, 0.1)',
gridMinorColor: 'rgba(255, 255, 255, 0.05)',
underfillColor: 'rgba(245, 248, 252, 0.18)',
pitchLock: true,
underfillEnabled: false,
@@ -294,7 +296,8 @@ export class Oscilloscope {
canvas.height,
options.backgroundColor,
options.showGrid,
options.gridColor,
options.gridMajorColor,
options.gridMinorColor,
].join(':')
if (this.staticLayerKey === key) {
@@ -323,7 +326,7 @@ export class Oscilloscope {
const height = canvas.height
const dpr = window.devicePixelRatio || 1
ctx.strokeStyle = options.gridColor
ctx.strokeStyle = options.gridMajorColor
ctx.lineWidth = dpr
ctx.beginPath()
@@ -336,7 +339,7 @@ export class Oscilloscope {
ctx.lineTo(width / 2, height)
ctx.stroke()
ctx.strokeStyle = options.gridColor.replace('0.1', '0.05')
ctx.strokeStyle = options.gridMinorColor || multiplyColorAlpha(options.gridMajorColor, 0.5)
for (let i = 1; i < 4; i++) {
if (i === 2) continue
ctx.beginPath()
+11
View File
@@ -30,6 +30,7 @@ export interface SpectrogramOptions {
colorScheme?: 'heat' | 'mono'
lineColor?: string
heatColors?: [string, string, string]
backgroundColor?: string
dataSource?: SpectrogramDataSource
frameScheduler?: FrameScheduler
}
@@ -54,6 +55,7 @@ const defaultOptions: ResolvedSpectrogramOptions = {
colorScheme: 'heat',
lineColor: '#38bdf8',
heatColors: ['rgb(15, 7, 33)', 'rgb(163, 26, 121)', 'rgb(255, 241, 209)'],
backgroundColor: 'transparent',
}
const defaultSpectrogramDataSource: SpectrogramDataSource = {
@@ -95,6 +97,7 @@ function resolveOptions(base: ResolvedSpectrogramOptions, overrides: Partial<Spe
colorScheme: overrides.colorScheme ?? base.colorScheme,
lineColor: overrides.lineColor ?? base.lineColor,
heatColors: overrides.heatColors ?? base.heatColors,
backgroundColor: overrides.backgroundColor ?? base.backgroundColor,
}
}
@@ -697,6 +700,10 @@ export class Spectrogram {
this.dataSource.getPendingSpectrogramSamples()
// Freeze waterfall in place instead of blanking
this.ctx.clearRect(0, 0, width, height)
if (this.options.backgroundColor !== 'transparent') {
this.ctx.fillStyle = this.options.backgroundColor
this.ctx.fillRect(0, 0, width, height)
}
this.ctx.drawImage(this.waterfallCanvas, 0, 0)
return
}
@@ -728,6 +735,10 @@ export class Spectrogram {
}
this.ctx.clearRect(0, 0, width, height)
if (this.options.backgroundColor !== 'transparent') {
this.ctx.fillStyle = this.options.backgroundColor
this.ctx.fillRect(0, 0, width, height)
}
this.ctx.drawImage(this.waterfallCanvas, 0, 0)
}
+11 -3
View File
@@ -22,7 +22,9 @@ export interface VUMeterDataSource extends VisualizerSessionSource {
export interface VUMeterOptions {
mode?: VUMeterMode
orientation?: VUMeterOrientation
backgroundColor?: string
lineColor?: string
trackColor?: string
peakColor?: string
clipColor?: string
scaleColor?: string
@@ -36,7 +38,9 @@ type ResolvedVUMeterOptions = Required<Omit<VUMeterOptions, 'dataSource' | 'fram
const defaultOptions: ResolvedVUMeterOptions = {
mode: 'bar',
orientation: DEFAULT_VU_METER_ORIENTATION,
backgroundColor: 'transparent',
lineColor: '#38bdf8',
trackColor: 'rgba(56, 189, 248, 0.08)',
peakColor: 'rgb(255, 127, 0)',
clipColor: 'rgba(255, 120, 80, 0.9)',
scaleColor: 'rgba(255, 255, 255, 0.12)',
@@ -258,7 +262,7 @@ export class VUMeter {
const hotThreshold = this.dbToNormalized(-6) * w
// Background track
ctx.fillStyle = alphaColor(this.options.scaleColor, 0.25)
ctx.fillStyle = this.options.trackColor
ctx.fillRect(x, y, w, h)
// Main level bar
@@ -310,7 +314,7 @@ export class VUMeter {
const levelHeight = levelNorm * h
const hotThreshold = this.dbToNormalized(-6) * h
ctx.fillStyle = alphaColor(this.options.scaleColor, 0.25)
ctx.fillStyle = this.options.trackColor
ctx.fillRect(x, y, w, h)
if (levelHeight > 0) {
@@ -396,7 +400,7 @@ export class VUMeter {
const corr = Math.max(-1, Math.min(1, this.correlation))
// Background track
ctx.fillStyle = alphaColor(this.options.scaleColor, 0.25)
ctx.fillStyle = this.options.trackColor
ctx.fillRect(x, y, w, h)
// Center line
@@ -561,6 +565,10 @@ export class VUMeter {
this.processAudio()
ctx.clearRect(0, 0, width, height)
if (options.backgroundColor !== 'transparent') {
ctx.fillStyle = options.backgroundColor
ctx.fillRect(0, 0, width, height)
}
if (options.mode === 'needle') {
this.drawNeedleMode(width, height)
+19 -4
View File
@@ -17,7 +17,9 @@ export interface VectorscopeOptions {
lineWidth?: number
backgroundColor?: string
showGrid?: boolean
gridColor?: string
gridMajorColor?: string
gridMinorColor?: string
labelColor?: string
bandColors?: {
low: string
mid: string
@@ -38,7 +40,9 @@ const defaultOptions: ResolvedVectorscopeOptions = {
lineWidth: 1.5,
backgroundColor: 'transparent',
showGrid: true,
gridColor: 'rgba(255, 255, 255, 0.1)',
gridMajorColor: 'rgba(255, 255, 255, 0.1)',
gridMinorColor: 'rgba(255, 255, 255, 0.05)',
labelColor: 'rgba(255, 255, 255, 0.1)',
bandColors: {
low: '#ff4444',
mid: '#44dd44',
@@ -247,7 +251,9 @@ export class Vectorscope {
canvas.height,
options.backgroundColor,
options.showGrid,
options.gridColor,
options.gridMajorColor,
options.gridMinorColor,
options.labelColor,
options.mode,
].join(':')
@@ -266,7 +272,16 @@ export class Vectorscope {
if (options.showGrid) {
const dpr = window.devicePixelRatio || 1
drawVectorscopeGridForMode(this.staticLayerCtx, canvas.width, canvas.height, options.gridColor, options.mode, dpr)
drawVectorscopeGridForMode(
this.staticLayerCtx,
canvas.width,
canvas.height,
options.gridMajorColor,
options.gridMinorColor,
options.labelColor,
options.mode,
dpr,
)
}
this.staticLayerKey = key
+7 -1
View File
@@ -24,6 +24,7 @@ export interface WaveformDataSource extends VisualizerSessionSource {
}
export interface WaveformOptions {
backgroundColor?: string
lineColor?: string
gridMajorColor?: string
gridMinorColor?: string
@@ -43,6 +44,7 @@ export interface WaveformOptions {
type ResolvedWaveformOptions = Required<Omit<WaveformOptions, 'dataSource' | 'frameScheduler'>>
const defaultOptions: ResolvedWaveformOptions = {
backgroundColor: 'transparent',
lineColor: '#38bdf8',
gridMajorColor: 'rgba(255, 255, 255, 0.08)',
gridMinorColor: 'rgba(255, 255, 255, 0.04)',
@@ -377,7 +379,7 @@ export class Waveform {
}
private ensureStaticLayer(width: number, height: number): void {
const key = `${width}:${height}:${this.options.mode}`
const key = `${width}:${height}:${this.options.mode}:${this.options.backgroundColor}`
if (this.staticLayerKey === key) {
return
}
@@ -385,6 +387,10 @@ export class Waveform {
this.staticLayerCanvas.width = this.canvas.width
this.staticLayerCanvas.height = this.canvas.height
this.staticLayerCtx.clearRect(0, 0, this.canvas.width, this.canvas.height)
if (this.options.backgroundColor !== 'transparent') {
this.staticLayerCtx.fillStyle = this.options.backgroundColor
this.staticLayerCtx.fillRect(0, 0, this.canvas.width, this.canvas.height)
}
this.drawGrid(this.staticLayerCtx, width, height)
this.staticLayerKey = key
}
+26 -19
View File
@@ -1,4 +1,5 @@
import type { VectorscopeMode } from './Vectorscope'
import { multiplyColorAlpha } from '../utils/color'
const INV_SQRT2 = 1 / Math.sqrt(2)
const COS45 = Math.SQRT2 / 2 // 0.7071...
@@ -91,12 +92,14 @@ export function transformPoint(
export function drawLissajousGrid(
ctx: CanvasRenderingContext2D,
layout: VectorscopeLayout,
gridColor: string,
gridMajorColor: string,
gridMinorColor: string,
labelColor: string,
dpr: number
): void {
const { centerX, centerY, radius } = layout
ctx.strokeStyle = gridColor
ctx.strokeStyle = gridMajorColor
ctx.lineWidth = dpr
// Outer box
@@ -120,8 +123,7 @@ export function drawLissajousGrid(
ctx.stroke()
// Diagonal guides (dimmer)
const dimColor = gridColor.replace(/[\d.]+\)$/, (m) => `${parseFloat(m) * 0.5})`)
ctx.strokeStyle = dimColor
ctx.strokeStyle = gridMinorColor || multiplyColorAlpha(gridMajorColor, 0.5)
ctx.beginPath()
ctx.moveTo(centerX - radius, centerY - radius)
@@ -134,7 +136,7 @@ export function drawLissajousGrid(
ctx.stroke()
// Labels
ctx.fillStyle = gridColor
ctx.fillStyle = labelColor
ctx.font = `${10 * dpr}px monospace`
ctx.textAlign = 'center'
ctx.fillText('L', centerX, centerY - radius - 6 * dpr)
@@ -147,13 +149,15 @@ export function drawLissajousGrid(
export function drawPolarGrid(
ctx: CanvasRenderingContext2D,
layout: VectorscopeLayout,
gridColor: string,
gridMajorColor: string,
gridMinorColor: string,
labelColor: string,
unipolar: boolean,
dpr: number
): void {
const { centerX, centerY, radius } = layout
ctx.strokeStyle = gridColor
ctx.strokeStyle = gridMajorColor
ctx.lineWidth = dpr
// Concentric circles (or semicircles for unipolar)
@@ -185,8 +189,7 @@ export function drawPolarGrid(
ctx.stroke()
// Diagonal guides (L and R channel axes) — dimmer
const dimColor = gridColor.replace(/[\d.]+\)$/, (m) => `${parseFloat(m) * 0.5})`)
ctx.strokeStyle = dimColor
ctx.strokeStyle = gridMinorColor || multiplyColorAlpha(gridMajorColor, 0.5)
if (unipolar) {
ctx.beginPath()
@@ -211,7 +214,7 @@ export function drawPolarGrid(
}
// Labels
ctx.fillStyle = gridColor
ctx.fillStyle = labelColor
ctx.font = `${10 * dpr}px monospace`
ctx.textAlign = 'center'
@@ -230,13 +233,15 @@ export function drawPolarGrid(
export function drawLinearGrid(
ctx: CanvasRenderingContext2D,
layout: VectorscopeLayout,
gridColor: string,
gridMajorColor: string,
_gridMinorColor: string,
labelColor: string,
unipolar: boolean,
dpr: number
): void {
const { centerX, centerY, radius } = layout
ctx.strokeStyle = gridColor
ctx.strokeStyle = gridMajorColor
ctx.lineWidth = dpr
const scales = [0.25, 0.5, 0.75, 1.0]
@@ -275,7 +280,7 @@ export function drawLinearGrid(
ctx.stroke()
// Labels
ctx.fillStyle = gridColor
ctx.fillStyle = labelColor
ctx.font = `${10 * dpr}px monospace`
ctx.textAlign = 'center'
@@ -295,7 +300,9 @@ export function drawVectorscopeGridForMode(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
gridColor: string,
gridMajorColor: string,
gridMinorColor: string,
labelColor: string,
mode: VectorscopeMode,
dpr: number = 1
): void {
@@ -303,19 +310,19 @@ export function drawVectorscopeGridForMode(
switch (mode) {
case 'lissajous':
drawLissajousGrid(ctx, layout, gridColor, dpr)
drawLissajousGrid(ctx, layout, gridMajorColor, gridMinorColor, labelColor, dpr)
break
case 'polar-unipolar':
drawPolarGrid(ctx, layout, gridColor, true, dpr)
drawPolarGrid(ctx, layout, gridMajorColor, gridMinorColor, labelColor, true, dpr)
break
case 'polar-bipolar':
drawPolarGrid(ctx, layout, gridColor, false, dpr)
drawPolarGrid(ctx, layout, gridMajorColor, gridMinorColor, labelColor, false, dpr)
break
case 'linear-unipolar':
drawLinearGrid(ctx, layout, gridColor, true, dpr)
drawLinearGrid(ctx, layout, gridMajorColor, gridMinorColor, labelColor, true, dpr)
break
case 'linear-bipolar':
drawLinearGrid(ctx, layout, gridColor, false, dpr)
drawLinearGrid(ctx, layout, gridMajorColor, gridMinorColor, labelColor, false, dpr)
break
}
}
+662 -354
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
import { SCOPE_KINDS, type ScopeKind } from '../types/scope'
import {
WINDOW_LOCAL_STATE_FORMAT,
WINDOW_LOCAL_STATE_VERSION,
type PrismWindowLocalStateV1,
} from '../types/windowState'
export function createEmptyWindowLocalState(): PrismWindowLocalStateV1 {
return {
format: WINDOW_LOCAL_STATE_FORMAT,
version: WINDOW_LOCAL_STATE_VERSION,
mainAlwaysOnTop: false,
popoutAlwaysOnTop: {},
}
}
export function normalizeWindowLocalState(raw: unknown): PrismWindowLocalStateV1 {
const parsed = typeof raw === 'object' && raw !== null
? raw as Partial<PrismWindowLocalStateV1>
: {}
const popoutAlwaysOnTop = typeof parsed.popoutAlwaysOnTop === 'object' && parsed.popoutAlwaysOnTop !== null
? SCOPE_KINDS.reduce((acc, kind) => {
if ((parsed.popoutAlwaysOnTop as Partial<Record<ScopeKind, unknown>>)[kind] === true) {
acc[kind] = true
}
return acc
}, {} as Partial<Record<ScopeKind, boolean>>)
: {}
return {
format: WINDOW_LOCAL_STATE_FORMAT,
version: WINDOW_LOCAL_STATE_VERSION,
mainAlwaysOnTop: parsed.mainAlwaysOnTop === true,
popoutAlwaysOnTop,
}
}
+148 -49
View File
@@ -1,7 +1,7 @@
import type { ScopeKind } from './scope'
export const THEME_FILE_FORMAT = 'prism-theme'
export const THEME_FILE_VERSION = 1
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
@@ -9,29 +9,107 @@ export const DEFAULT_THEME_ID = 'theme_default'
export const DEFAULT_THEME_NAME = 'Default'
export type ThemeSectionName =
| 'all'
| 'interface'
| 'app'
| 'controls'
| 'scopes'
| ScopeKind
export interface ThemeTokens {
primary?: string
secondary?: string
guides?: string
text?: string
background?: string
lowBand?: string
midBand?: string
highBand?: string
fill?: string
peak?: string
clip?: string
target?: string
heatLow?: string
heatMid?: string
heatHigh?: string
export interface ThemeAppTokens {
accent?: string
success?: string
warning?: string
danger?: string
background?: string
surface?: string
surfaceAlt?: string
border?: string
text?: string
textMuted?: string
}
export interface ThemeControlsTokens {
surface?: string
surfaceHover?: string
surfaceActive?: string
border?: string
borderActive?: string
text?: string
inputSurface?: string
inputBorder?: string
menuSurface?: string
menuBorder?: string
slider?: string
}
export interface ThemeScopesTokens {
background?: string
guides?: string
overlaySurface?: string
overlayText?: string
overlayBorder?: string
resizeHandle?: string
}
export interface ThemeSpectrumTokens {
line?: string
sideLine?: string
fill?: string
heatLow?: string
heatMid?: string
heatHigh?: string
}
export interface ThemeOscilloscopeTokens {
line?: string
fill?: string
}
export interface ThemeVectorscopeTokens {
trace?: string
bandLow?: string
bandMid?: string
bandHigh?: string
}
export interface ThemeSpectrogramTokens {
mono?: string
heatLow?: string
heatMid?: string
heatHigh?: string
}
export interface ThemeVUMeterTokens {
level?: string
track?: string
peak?: string
clip?: string
}
export interface ThemeLUFSMeterTokens {
level?: string
track?: string
target?: string
}
export interface ThemeWaveformTokens {
line?: string
bandLow?: string
bandMid?: string
bandHigh?: string
}
export interface ThemeAstraTokens {
accent?: string
background?: string
surface?: string
border?: string
text?: string
progressTrack?: string
progressFill?: string
buttonSurface?: string
buttonBorder?: string
statusOk?: string
statusError?: string
}
export interface PrismTheme {
@@ -40,16 +118,17 @@ export interface PrismTheme {
credit?: string
website?: string
description?: string
all: ThemeTokens
interface: ThemeTokens
spectrum: ThemeTokens
oscilloscope: ThemeTokens
vectorscope: ThemeTokens
spectrogram: ThemeTokens
vumeter: ThemeTokens
lufsmeter: ThemeTokens
waveform: ThemeTokens
astra: ThemeTokens
app: ThemeAppTokens
controls: ThemeControlsTokens
scopes: ThemeScopesTokens
spectrum: ThemeSpectrumTokens
oscilloscope: ThemeOscilloscopeTokens
vectorscope: ThemeVectorscopeTokens
spectrogram: ThemeSpectrogramTokens
vumeter: ThemeVUMeterTokens
lufsmeter: ThemeLUFSMeterTokens
waveform: ThemeWaveformTokens
astra: ThemeAstraTokens
}
export interface PrismThemeLocalStateV1 {
@@ -83,7 +162,7 @@ export interface LegacyThemeMigrationResult {
export interface ResolvedInterfaceTheme {
primary: string
secondary: string
guides: string
border: string
text: string
background: string
accent: string
@@ -115,72 +194,92 @@ export interface ResolvedInterfaceTheme {
controlBgActive: string
controlBorder: string
controlBorderActive: string
controlText: string
inputBg: string
inputBgFocus: string
inputBorder: string
inputBorderFocus: string
optionBg: string
optionText: string
sliderTrack: string
sliderFill: string
sliderThumb: string
divider: string
scopeBackground: string
scopeGuides: string
scopeOverlayBg: string
scopeOverlayText: string
scopeOverlayBorder: string
scopeResizeHandle: string
success: string
warning: string
danger: string
}
export interface ResolvedSpectrumTheme {
primary: string
secondary: string
line: string
sideLine: string
guides: string
guidesSecondary: string
labels: string
background: string
fill: string
fillGradient: [string, string, string]
heatColors: [string, string, string]
}
export interface ResolvedOscilloscopeTheme {
primary: string
line: string
guides: string
guidesSecondary: string
background: string
fill: string
}
export interface ResolvedVectorscopeTheme {
primary: string
trace: string
guides: string
guidesSecondary: string
labels: string
background: string
lowBand: string
midBand: string
highBand: string
bandLow: string
bandMid: string
bandHigh: string
}
export interface ResolvedSpectrogramTheme {
primary: string
guides: string
mono: string
background: string
heatColors: [string, string, string]
}
export interface ResolvedVUMeterTheme {
primary: string
level: string
track: string
peak: string
clip: string
guides: string
text: string
scale: string
labels: string
background: string
}
export interface ResolvedLUFSMeterTheme {
primary: string
level: string
track: string
target: string
guides: string
text: string
scale: string
labels: string
background: string
}
export interface ResolvedWaveformTheme {
primary: string
line: string
guides: string
guidesSecondary: string
background: string
lowBand: string
midBand: string
highBand: string
bandLow: string
bandMid: string
bandHigh: string
}
export interface ResolvedAstraTheme {
+11
View File
@@ -0,0 +1,11 @@
import type { ScopeKind } from './scope'
export const WINDOW_LOCAL_STATE_FORMAT = 'prism-window-local'
export const WINDOW_LOCAL_STATE_VERSION = 1
export interface PrismWindowLocalStateV1 {
format: typeof WINDOW_LOCAL_STATE_FORMAT
version: typeof WINDOW_LOCAL_STATE_VERSION
mainAlwaysOnTop: boolean
popoutAlwaysOnTop: Partial<Record<ScopeKind, boolean>>
}
+53 -3
View File
@@ -745,8 +745,10 @@ test('scopeSettingsToOptions wires spectrum side overlay settings into analyzer
const options = scopeSettingsToOptions('spectrum', profile.scopeSettings.spectrum, theme.spectrum)
assert.equal(options.showSideLine, true)
assert.equal(options.secondaryLineColor, theme.spectrum.secondary)
assert.equal(options.lineColor, theme.spectrum.primary)
assert.equal(options.secondaryLineColor, theme.spectrum.sideLine)
assert.equal(options.lineColor, theme.spectrum.line)
assert.equal(options.backgroundColor, theme.spectrum.background)
assert.equal(options.gridColor, theme.spectrum.guides)
})
test('astra playback progress advances from updatedAt while playing', () => {
@@ -904,7 +906,55 @@ test('scopeSettingsToOptions wires waveform stereo mode into analyzer options',
assert.equal(options.mode, 'stereo')
assert.equal(options.multiband, true)
assert.equal(options.lineColor, theme.waveform.primary)
assert.equal(options.lineColor, theme.waveform.line)
assert.equal(options.backgroundColor, theme.waveform.background)
assert.equal(options.gridMajorColor, theme.waveform.guides)
assert.equal(options.gridMinorColor, theme.waveform.guidesSecondary)
})
test('scopeSettingsToOptions forwards shared scope background and guides to oscilloscope and vectorscope', () => {
const profile = createDefaultProfile('Default')
const authoredTheme = createDefaultTheme()
authoredTheme.scopes.background = 'rgb(3, 4, 5)'
authoredTheme.scopes.guides = 'rgba(120, 130, 140, 0.2)'
const theme = resolveTheme(authoredTheme)
const oscilloscope = scopeSettingsToOptions('oscilloscope', profile.scopeSettings.oscilloscope, theme.oscilloscope)
assert.equal(oscilloscope.backgroundColor, 'rgb(3, 4, 5)')
assert.equal(oscilloscope.gridMajorColor, theme.oscilloscope.guides)
assert.equal(oscilloscope.gridMinorColor, theme.oscilloscope.guidesSecondary)
const vectorscope = scopeSettingsToOptions('vectorscope', profile.scopeSettings.vectorscope, theme.vectorscope)
assert.equal(vectorscope.backgroundColor, 'rgb(3, 4, 5)')
assert.equal(vectorscope.gridMajorColor, theme.vectorscope.guides)
assert.equal(vectorscope.gridMinorColor, theme.vectorscope.guidesSecondary)
assert.equal(vectorscope.labelColor, theme.vectorscope.labels)
})
test('scopeSettingsToOptions forwards themed backgrounds and track colors to spectrogram, VU, and LUFS modules', () => {
const profile = createDefaultProfile('Default')
const authoredTheme = createDefaultTheme()
authoredTheme.scopes.background = 'rgb(6, 7, 8)'
authoredTheme.vumeter.track = 'rgb(9, 10, 11)'
authoredTheme.lufsmeter.track = 'rgb(12, 13, 14)'
authoredTheme.lufsmeter.target = 'rgb(15, 16, 17)'
const theme = resolveTheme(authoredTheme)
const spectrogram = scopeSettingsToOptions('spectrogram', profile.scopeSettings.spectrogram, theme.spectrogram)
assert.equal(spectrogram.backgroundColor, 'rgb(6, 7, 8)')
const vumeter = scopeSettingsToOptions('vumeter', profile.scopeSettings.vumeter, theme.vumeter)
assert.equal(vumeter.backgroundColor, 'rgb(6, 7, 8)')
assert.equal(vumeter.trackColor, 'rgb(9, 10, 11)')
assert.equal(vumeter.scaleColor, theme.vumeter.scale)
assert.equal(vumeter.labelColor, theme.vumeter.labels)
const lufsmeter = scopeSettingsToOptions('lufsmeter', profile.scopeSettings.lufsmeter, theme.lufsmeter)
assert.equal(lufsmeter.backgroundColor, 'rgb(6, 7, 8)')
assert.equal(lufsmeter.trackColor, 'rgb(12, 13, 14)')
assert.equal(lufsmeter.targetColor, 'rgb(15, 16, 17)')
assert.equal(lufsmeter.scaleColor, theme.lufsmeter.scale)
assert.equal(lufsmeter.labelColor, theme.lufsmeter.labels)
})
test('scopeSummary includes Stereo for waveform only when stereo mode is enabled', () => {
+125 -18
View File
@@ -5,10 +5,13 @@ import { join } from 'node:path'
import test from 'node:test'
import { FileBackedThemeLibrary } from '../src/main/themeLibrary'
import {
createBundledThemes,
createDefaultTheme,
createMigratedAccentTheme,
parseThemeFileContent,
resolveTheme,
serializeThemeFile,
themeToCssVariables,
} from '../src/shared/themeState'
import {
DEFAULT_THEME_ID,
@@ -37,37 +40,110 @@ async function createHarness(): Promise<{
test('theme files round-trip and keep grouped sections intact', () => {
const theme = createDefaultTheme()
theme.app.accent = '#4ade80'
theme.controls.menuSurface = 'rgb(12, 18, 32)'
theme.scopes.background = '#030712'
theme.spectrum.heatMid = 'rgb(200, 50, 120)'
theme.vumeter.track = '#111827'
const serialized = serializeThemeFile(theme)
const parsed = parseThemeFileContent(serialized, DEFAULT_THEME_ID, DEFAULT_THEME_NAME)
assert.match(serialized, /\[Theme\]/)
assert.match(serialized, /version = 2/)
assert.match(serialized, /\[App\]/)
assert.match(serialized, /\[Controls\]/)
assert.match(serialized, /\[Scopes\]/)
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)')
assert.equal(parsed.scopes.background, 'rgb(3, 7, 18)')
assert.equal(parsed.spectrum.heatMid, 'rgb(200, 50, 120)')
assert.equal(parsed.interface.secondary, theme.interface.secondary)
assert.equal(parsed.vumeter.track, 'rgb(17, 24, 39)')
assert.equal(parsed.astra.background, theme.astra.background)
})
test('astra theme falls back to [All] tokens until [Astra] overrides are provided', () => {
test('resolveTheme maps grouped app, controls, and scopes tokens into UI and scope surfaces', () => {
const theme = createDefaultTheme()
theme.all.primary = 'rgb(255, 159, 67)'
theme.all.background = 'rgb(5, 6, 7)'
theme.all.text = 'rgb(240, 244, 248)'
theme.app.accent = 'rgb(255, 159, 67)'
theme.app.background = 'rgb(5, 6, 7)'
theme.app.surface = 'rgb(8, 9, 10)'
theme.app.surfaceAlt = 'rgb(11, 12, 13)'
theme.app.text = 'rgb(240, 244, 248)'
theme.app.textMuted = 'rgba(220, 224, 228, 0.4)'
theme.controls.text = 'rgb(225, 229, 233)'
theme.controls.menuSurface = 'rgb(14, 15, 16)'
theme.controls.menuBorder = 'rgb(17, 18, 19)'
theme.controls.inputSurface = 'rgb(20, 21, 22)'
theme.controls.inputBorder = 'rgb(23, 24, 25)'
theme.controls.slider = 'rgb(26, 27, 28)'
theme.scopes.background = 'rgb(29, 30, 31)'
theme.scopes.guides = 'rgba(100, 110, 120, 0.2)'
theme.scopes.overlaySurface = 'rgb(32, 33, 34)'
theme.scopes.overlayText = 'rgb(235, 236, 237)'
theme.scopes.overlayBorder = 'rgb(35, 36, 37)'
theme.scopes.resizeHandle = 'rgb(38, 39, 40)'
theme.vumeter.track = 'rgb(41, 42, 43)'
theme.lufsmeter.track = 'rgb(44, 45, 46)'
theme.astra = {}
const fallbackResolved = resolveTheme(theme)
assert.equal(fallbackResolved.astra.accent, 'rgb(255, 159, 67)')
assert.equal(fallbackResolved.astra.background, 'rgb(5, 6, 7)')
assert.equal(fallbackResolved.astra.text, 'rgb(240, 244, 248)')
const resolved = resolveTheme(theme)
assert.equal(resolved.interface.accent, 'rgb(255, 159, 67)')
assert.equal(resolved.interface.menuBg, 'rgb(14, 15, 16)')
assert.equal(resolved.interface.menuBorder, 'rgb(17, 18, 19)')
assert.equal(resolved.interface.inputBg, 'rgb(20, 21, 22)')
assert.equal(resolved.interface.inputBorder, 'rgb(23, 24, 25)')
assert.equal(resolved.interface.controlText, 'rgb(225, 229, 233)')
assert.equal(resolved.interface.optionBg, 'rgb(14, 15, 16)')
assert.equal(resolved.interface.optionText, 'rgb(225, 229, 233)')
assert.equal(resolved.interface.sliderFill, 'rgb(26, 27, 28)')
assert.equal(resolved.interface.scopeBackground, 'rgb(29, 30, 31)')
assert.equal(resolved.interface.scopeGuides, 'rgba(100, 110, 120, 0.2)')
assert.equal(resolved.interface.scopeOverlayBg, 'rgb(32, 33, 34)')
assert.equal(resolved.interface.scopeOverlayText, 'rgb(235, 236, 237)')
assert.equal(resolved.interface.scopeOverlayBorder, 'rgb(35, 36, 37)')
assert.equal(resolved.interface.scopeResizeHandle, 'rgb(38, 39, 40)')
assert.equal(resolved.spectrogram.background, 'rgb(29, 30, 31)')
assert.equal(resolved.waveform.guides, 'rgba(100, 110, 120, 0.2)')
assert.equal(resolved.vumeter.track, 'rgb(41, 42, 43)')
assert.equal(resolved.lufsmeter.track, 'rgb(44, 45, 46)')
assert.equal(resolved.astra.accent, 'rgb(255, 159, 67)')
assert.equal(resolved.astra.background, 'rgb(29, 30, 31)')
assert.equal(resolved.astra.text, 'rgb(240, 244, 248)')
})
theme.astra.primary = 'rgb(129, 140, 248)'
theme.astra.background = 'rgba(12, 18, 32, 0.88)'
theme.astra.text = 'rgb(248, 250, 252)'
test('themeToCssVariables exposes grouped UI, control, and scope variables', () => {
const theme = createDefaultTheme()
theme.controls.menuSurface = 'rgb(11, 12, 13)'
theme.controls.text = 'rgb(220, 221, 222)'
theme.scopes.overlaySurface = 'rgb(14, 15, 16)'
theme.scopes.overlayText = 'rgb(223, 224, 225)'
theme.scopes.overlayBorder = 'rgb(17, 18, 19)'
theme.scopes.resizeHandle = 'rgb(20, 21, 22)'
const overriddenResolved = resolveTheme(theme)
assert.equal(overriddenResolved.astra.accent, 'rgb(129, 140, 248)')
assert.match(overriddenResolved.astra.background, /^rgba\(12, 18, 32, 0\.87/)
assert.equal(overriddenResolved.astra.text, 'rgb(248, 250, 252)')
const variables = themeToCssVariables(resolveTheme(theme))
assert.equal(variables['--menu-bg'], 'rgb(11, 12, 13)')
assert.equal(variables['--option-bg'], 'rgb(11, 12, 13)')
assert.equal(variables['--option-text'], 'rgb(220, 221, 222)')
assert.equal(variables['--slider-fill'], resolveTheme(theme).interface.sliderFill)
assert.equal(variables['--scope-overlay-bg'], 'rgb(14, 15, 16)')
assert.equal(variables['--scope-overlay-text'], 'rgb(223, 224, 225)')
assert.equal(variables['--scope-overlay-border'], 'rgb(17, 18, 19)')
assert.equal(variables['--scope-resize-handle'], 'rgb(20, 21, 22)')
})
test('bundled and migrated accent themes recolor every accent-driven scope', () => {
const purple = createBundledThemes().find((theme) => theme.id === 'theme_purple')
assert.ok(purple)
assert.equal(purple.oscilloscope.line, purple.app.accent)
assert.equal(purple.vectorscope.trace, purple.app.accent)
const migrated = createMigratedAccentTheme('#4ade80')
assert.ok(migrated)
assert.equal(migrated.oscilloscope.line, migrated.app.accent)
assert.equal(migrated.vectorscope.trace, migrated.app.accent)
})
test('library seeds default themes and template file', async () => {
@@ -81,6 +157,14 @@ test('library seeds default themes and template file', async () => {
const fileNames = (await readdir(harness.themesDir)).sort()
assert.ok(fileNames.includes('Default.iro'))
assert.ok(fileNames.includes('_TEMPLATE.iro'))
const defaultThemeContent = await readFile(join(harness.themesDir, 'Default.iro'), 'utf8')
const templateContent = await readFile(join(harness.themesDir, '_TEMPLATE.iro'), 'utf8')
assert.match(defaultThemeContent, /version = 2/)
assert.match(defaultThemeContent, /\[App\]/)
assert.doesNotMatch(defaultThemeContent, /\[All\]/)
assert.match(templateContent, /\[Controls\]/)
assert.match(templateContent, /\[Scopes\]/)
} finally {
await harness.cleanup()
}
@@ -100,14 +184,37 @@ test('importing the same embedded theme id replaces the managed theme', async ()
assert.equal(firstSnapshot.activeThemeId, 'theme_shared')
theme.name = 'Shared Updated'
theme.all.primary = 'rgb(74, 222, 128)'
theme.app.accent = 'rgb(74, 222, 128)'
const updatedPath = join(harness.rootDir, 'shared-updated.iro')
await writeFile(updatedPath, 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.all.primary, 'rgb(74, 222, 128)')
assert.equal(secondSnapshot.themes.theme_shared.app.accent, 'rgb(74, 222, 128)')
} finally {
await harness.cleanup()
}
})
test('library refreshes shipped bundled themes when their definitions change', async () => {
const harness = await createHarness()
try {
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)'
stalePurple.vectorscope.trace = 'rgb(56, 189, 248)'
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)
} finally {
await harness.cleanup()
}
+135
View File
@@ -0,0 +1,135 @@
import assert from 'node:assert/strict'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import test from 'node:test'
import { FileBackedWindowStateStore } from '../src/main/windowStateStore'
import {
WINDOW_LOCAL_STATE_FORMAT,
WINDOW_LOCAL_STATE_VERSION,
} from '../src/types/windowState'
async function createHarness(): Promise<{
cleanup: () => Promise<void>
localStatePath: string
rootDir: string
store: FileBackedWindowStateStore
}> {
const rootDir = await mkdtemp(join(tmpdir(), 'prism-window-state-'))
const localStatePath = join(rootDir, 'userData', 'window-state.json')
return {
cleanup: () => rm(rootDir, { recursive: true, force: true }),
localStatePath,
rootDir,
store: new FileBackedWindowStateStore(localStatePath),
}
}
test('window state defaults to unpinned when no local state file exists', async () => {
const harness = await createHarness()
try {
await harness.store.initialize()
assert.equal(harness.store.getMainAlwaysOnTop(), false)
assert.equal(harness.store.getPopoutAlwaysOnTop('spectrum'), false)
assert.equal(harness.store.getPopoutAlwaysOnTop('waveform'), false)
} finally {
await harness.cleanup()
}
})
test('main window always-on-top persists immediately and survives reload', async () => {
const harness = await createHarness()
try {
await harness.store.initialize()
await harness.store.setMainAlwaysOnTop(true)
const saved = JSON.parse(await readFile(harness.localStatePath, 'utf8')) as {
format: string
version: number
mainAlwaysOnTop: boolean
}
assert.equal(saved.format, WINDOW_LOCAL_STATE_FORMAT)
assert.equal(saved.version, WINDOW_LOCAL_STATE_VERSION)
assert.equal(saved.mainAlwaysOnTop, true)
const reloaded = new FileBackedWindowStateStore(harness.localStatePath)
await reloaded.initialize()
assert.equal(reloaded.getMainAlwaysOnTop(), true)
} finally {
await harness.cleanup()
}
})
test('popout always-on-top persists independently per scope kind', async () => {
const harness = await createHarness()
try {
await harness.store.initialize()
await harness.store.setMainAlwaysOnTop(true)
await harness.store.setPopoutAlwaysOnTop('spectrum', true)
const reloaded = new FileBackedWindowStateStore(harness.localStatePath)
await reloaded.initialize()
assert.equal(reloaded.getMainAlwaysOnTop(), true)
assert.equal(reloaded.getPopoutAlwaysOnTop('spectrum'), true)
assert.equal(reloaded.getPopoutAlwaysOnTop('waveform'), false)
} finally {
await harness.cleanup()
}
})
test('turning a popout pin back off clears its saved local state without affecting others', async () => {
const harness = await createHarness()
try {
await harness.store.initialize()
await harness.store.setPopoutAlwaysOnTop('spectrum', true)
await harness.store.setPopoutAlwaysOnTop('waveform', true)
await harness.store.setPopoutAlwaysOnTop('spectrum', false)
const saved = JSON.parse(await readFile(harness.localStatePath, 'utf8')) as {
popoutAlwaysOnTop: Record<string, boolean | undefined>
}
assert.equal(saved.popoutAlwaysOnTop.spectrum, undefined)
assert.equal(saved.popoutAlwaysOnTop.waveform, true)
const reloaded = new FileBackedWindowStateStore(harness.localStatePath)
await reloaded.initialize()
assert.equal(reloaded.getPopoutAlwaysOnTop('spectrum'), false)
assert.equal(reloaded.getPopoutAlwaysOnTop('waveform'), true)
} finally {
await harness.cleanup()
}
})
test('invalid saved window state normalizes to supported keys and boolean values', async () => {
const harness = await createHarness()
try {
await mkdir(dirname(harness.localStatePath), { recursive: true })
await writeFile(harness.localStatePath, `${JSON.stringify({
format: 'unexpected',
version: 99,
mainAlwaysOnTop: true,
popoutAlwaysOnTop: {
spectrum: true,
waveform: 'yes',
invalid_scope: true,
},
}, null, 2)}\n`, 'utf8')
await harness.store.initialize()
assert.equal(harness.store.getMainAlwaysOnTop(), true)
assert.equal(harness.store.getPopoutAlwaysOnTop('spectrum'), true)
assert.equal(harness.store.getPopoutAlwaysOnTop('waveform'), false)
assert.equal(harness.store.getPopoutAlwaysOnTop('oscilloscope'), false)
} finally {
await harness.cleanup()
}
})