profile system, improved UI/UX

This commit is contained in:
Boof2015
2026-03-27 21:22:29 -04:00
parent a6221552c0
commit e126a264cd
11 changed files with 1162 additions and 240 deletions
+117 -8
View File
@@ -1,9 +1,12 @@
import { app, BrowserWindow, desktopCapturer, ipcMain, session } from 'electron'
import { app, BrowserWindow, desktopCapturer, ipcMain, screen, session } from 'electron'
import { join } from 'path'
import type { CaptureBackendSupport, CaptureBackendSupportEntry } from '../types/capture'
let mainWindow: BrowserWindow | null = null
let currentSettingsHeight = 0
let moveInterval: ReturnType<typeof setInterval> | null = null
let moveStartCursor: { x: number; y: number } | null = null
let moveStartPosition: number[] | null = null
const WINDOW_DEFAULTS = {
width: 900,
@@ -21,8 +24,8 @@ function createWindow(): void {
alwaysOnTop: true,
autoHideMenuBar: true,
resizable: true,
maximizable: false,
fullscreenable: false,
maximizable: true,
fullscreenable: true,
title: 'Prism',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
@@ -104,6 +107,31 @@ function setupIPC(): void {
mainWindow?.minimize()
})
ipcMain.on('window:start-move', () => {
if (!mainWindow) return
const cursor = screen.getCursorScreenPoint()
moveStartCursor = { x: cursor.x, y: cursor.y }
moveStartPosition = mainWindow.getPosition()
if (moveInterval) clearInterval(moveInterval)
moveInterval = setInterval(() => {
if (!mainWindow || !moveStartCursor || !moveStartPosition) return
const current = screen.getCursorScreenPoint()
const dx = current.x - moveStartCursor.x
const dy = current.y - moveStartCursor.y
mainWindow.setPosition(moveStartPosition[0] + dx, moveStartPosition[1] + dy)
}, 16)
})
ipcMain.on('window:stop-move', () => {
if (moveInterval) {
clearInterval(moveInterval)
moveInterval = null
}
moveStartCursor = null
moveStartPosition = null
})
ipcMain.on('window:close', () => {
mainWindow?.close()
})
@@ -128,21 +156,76 @@ function setupIPC(): void {
return getCaptureBackendSupport()
})
ipcMain.on('window:set-bounds', (_event, bounds: { x: number; y: number; width: number; height: number }) => {
if (!mainWindow) return
// Saved bounds are base (without settings). Add back current settings height so scopes stay the same size.
mainWindow.setBounds({
...bounds,
height: bounds.height + currentSettingsHeight,
})
})
ipcMain.handle('window:get-bounds', () => {
if (!mainWindow) return null
const bounds = mainWindow.getBounds()
// Strip settings height so we always save base bounds
return {
...bounds,
height: bounds.height - currentSettingsHeight,
}
})
ipcMain.on('window:reposition', (_event, position: 'top' | 'bottom') => {
if (!mainWindow) return
const display = screen.getDisplayMatching(mainWindow.getBounds())
const workArea = display.workArea
const [, height] = mainWindow.getSize()
if (position === 'top') {
mainWindow.setPosition(workArea.x, workArea.y)
} else {
mainWindow.setPosition(workArea.x, workArea.y + workArea.height - height)
}
mainWindow.setSize(workArea.width, height)
})
ipcMain.on('window:expand-settings', (_event, panelHeight: number) => {
if (!mainWindow) return
const [width, height] = mainWindow.getSize()
const bounds = mainWindow.getBounds()
const [minW] = mainWindow.getMinimumSize()
const newHeight = bounds.height + panelHeight
mainWindow.setMinimumSize(minW, WINDOW_DEFAULTS.minHeight + panelHeight)
mainWindow.setSize(width, height + panelHeight, true)
// Check if expanding would push window off screen bottom
const display = screen.getDisplayMatching(bounds)
const workArea = display.workArea
const bottomEdge = bounds.y + newHeight
if (bottomEdge > workArea.y + workArea.height) {
const newY = Math.max(workArea.y, workArea.y + workArea.height - newHeight)
mainWindow.setBounds({ x: bounds.x, y: newY, width: bounds.width, height: newHeight })
} else {
mainWindow.setSize(bounds.width, newHeight, true)
}
currentSettingsHeight = Math.max(0, currentSettingsHeight + Math.round(panelHeight))
})
ipcMain.on('window:collapse-settings', (_event, panelHeight: number) => {
if (!mainWindow) return
const [width, height] = mainWindow.getSize()
const bounds = mainWindow.getBounds()
const [minW] = mainWindow.getMinimumSize()
const newHeight = Math.max(WINDOW_DEFAULTS.minHeight, bounds.height - panelHeight)
mainWindow.setMinimumSize(minW, WINDOW_DEFAULTS.minHeight)
mainWindow.setSize(width, Math.max(WINDOW_DEFAULTS.minHeight, height - panelHeight), true)
// If window was pushed up when expanding, push it back down
const display = screen.getDisplayMatching(bounds)
const workArea = display.workArea
const wasAtBottom = bounds.y + bounds.height >= workArea.y + workArea.height - 10
if (wasAtBottom) {
const newY = Math.min(bounds.y + panelHeight, workArea.y + workArea.height - newHeight)
mainWindow.setBounds({ x: bounds.x, y: newY, width: bounds.width, height: newHeight })
} else {
mainWindow.setSize(bounds.width, newHeight, true)
}
currentSettingsHeight = Math.max(0, currentSettingsHeight - Math.round(panelHeight))
})
@@ -156,7 +239,33 @@ function setupIPC(): void {
mainWindow.setMinimumSize(minW, WINDOW_DEFAULTS.minHeight + nextHeight)
if (delta !== 0) {
mainWindow.setSize(width, Math.max(WINDOW_DEFAULTS.minHeight, height + delta), true)
const newHeight = Math.max(WINDOW_DEFAULTS.minHeight, height + delta)
// If expanding near the bottom of the screen, move the window up so it doesn't go off-screen
const bounds = mainWindow.getBounds()
const display = screen.getDisplayMatching(bounds)
const workArea = display.workArea
const bottomEdge = bounds.y + newHeight
if (delta > 0 && bottomEdge > workArea.y + workArea.height) {
const newY = Math.max(workArea.y, workArea.y + workArea.height - newHeight)
mainWindow.setBounds({ x: bounds.x, y: newY, width, height: newHeight })
} else if (delta < 0) {
// Collapsing: if we moved the window up previously, move it back down
const baseHeight = newHeight - nextHeight
const naturalBottom = bounds.y + baseHeight
if (naturalBottom < workArea.y + workArea.height) {
// Push window down so it stays near the bottom
const maxY = workArea.y + workArea.height - newHeight
if (bounds.y < maxY) {
mainWindow.setSize(width, newHeight, true)
} else {
mainWindow.setBounds({ x: bounds.x, y: maxY, width, height: newHeight })
}
} else {
mainWindow.setSize(width, newHeight, true)
}
} else {
mainWindow.setSize(width, newHeight, true)
}
}
currentSettingsHeight = nextHeight
+5
View File
@@ -10,6 +10,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
platform: process.platform,
minimize: () => ipcRenderer.send('window:minimize'),
close: () => ipcRenderer.send('window:close'),
startWindowMove: () => ipcRenderer.send('window:start-move'),
stopWindowMove: () => ipcRenderer.send('window:stop-move'),
setWindowBounds: (bounds: { x: number; y: number; width: number; height: number }) => ipcRenderer.send('window:set-bounds', bounds),
getWindowBounds: () => ipcRenderer.invoke('window:get-bounds') as Promise<{ x: number; y: number; width: number; height: number } | null>,
repositionWindow: (position: 'top' | 'bottom') => ipcRenderer.send('window:reposition', position),
toggleAlwaysOnTop: () => ipcRenderer.send('window:toggle-always-on-top'),
isAlwaysOnTop: () => ipcRenderer.invoke('window:is-always-on-top'),
getDesktopSources: () => ipcRenderer.invoke('audio:get-desktop-sources') as Promise<{ id: string; name: string }[]>,
+27 -12
View File
@@ -2,18 +2,18 @@ import { useState, useRef, useCallback, useEffect, type JSX } from 'react'
import Strip from './components/Strip'
import Toolbar from './components/Toolbar'
import SettingsPanel from './components/SettingsPanel'
import BottomBar from './components/BottomBar'
import { useSettingsStore } from './stores/settingsStore'
import { useAudioStore } from './stores/audioStore'
import { SCOPE_KINDS } from '../types/scope'
const DEFAULT_SETTINGS_PANEL_HEIGHT = 280
const SETTINGS_EXPAND_HEIGHT = 280
export default function App(): JSX.Element {
const [toolbarVisible, setToolbarVisible] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
const [settingsPanelHeight, setSettingsPanelHeight] = useState(DEFAULT_SETTINGS_PANEL_HEIGHT)
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const appliedSettingsHeightRef = useRef(0)
const prevSettingsOpenRef = useRef(false)
const toggleScope = useSettingsStore((s) => s.toggleScope)
@@ -25,13 +25,15 @@ export default function App(): JSX.Element {
}
}, [])
// Expand/collapse window by a fixed amount when settings toggle — no dynamic tracking
useEffect(() => {
const nextHeight = settingsOpen ? settingsPanelHeight : 0
if (appliedSettingsHeightRef.current !== nextHeight) {
window.electronAPI.setSettingsHeight(nextHeight)
appliedSettingsHeightRef.current = nextHeight
if (settingsOpen && !prevSettingsOpenRef.current) {
window.electronAPI.expandSettings(SETTINGS_EXPAND_HEIGHT)
} else if (!settingsOpen && prevSettingsOpenRef.current) {
window.electronAPI.collapseSettings(SETTINGS_EXPAND_HEIGHT)
}
}, [settingsOpen, settingsPanelHeight])
prevSettingsOpenRef.current = settingsOpen
}, [settingsOpen])
const showToolbar = useCallback(() => {
if (hideTimeoutRef.current) {
@@ -56,6 +58,17 @@ export default function App(): JSX.Element {
setSettingsOpen(false)
}, [])
const handleAltDragStart = useCallback((event: React.MouseEvent) => {
if (event.altKey && event.button === 0) {
event.preventDefault()
window.electronAPI.startWindowMove()
}
}, [])
const handleAltDragEnd = useCallback(() => {
window.electronAPI.stopWindowMove()
}, [])
// Keyboard shortcuts from main process
useEffect(() => {
const unsubs = [
@@ -84,6 +97,8 @@ export default function App(): JSX.Element {
className="prism-app"
onMouseEnter={showToolbar}
onMouseLeave={scheduleHide}
onMouseDown={handleAltDragStart}
onMouseUp={handleAltDragEnd}
>
<div
className={`prism-toolbar-layer ${toolbarVisible ? 'is-visible' : ''}`.trim()}
@@ -96,10 +111,10 @@ export default function App(): JSX.Element {
</div>
{settingsOpen && (
<SettingsPanel
onClose={handleCloseSettings}
onHeightChange={setSettingsPanelHeight}
/>
<div className="prism-settings-region" style={{ height: SETTINGS_EXPAND_HEIGHT }}>
<SettingsPanel />
<BottomBar onClose={handleCloseSettings} />
</div>
)}
</div>
)
+22 -1
View File
@@ -111,6 +111,7 @@ class ElectronCaptureRuntime {
private audioContext: AudioContext | null = null
private stream: MediaStream | null = null
private sourceNode: MediaStreamAudioSourceNode | null = null
private gainNode: GainNode | null = null
private workletNode: AudioWorkletNode | null = null
private workletLoaded = false
private chunkListeners = new Set<(chunk: CaptureChunk) => void>()
@@ -120,6 +121,12 @@ class ElectronCaptureRuntime {
private sampleRate = 48000
private channelCount = 2
setInputGain(db: number): void {
if (this.gainNode) {
this.gainNode.gain.value = Math.pow(10, db / 20)
}
}
subscribe(listener: (chunk: CaptureChunk) => void): () => void {
this.chunkListeners.add(listener)
return () => {
@@ -193,6 +200,11 @@ class ElectronCaptureRuntime {
this.workletLoaded = true
}
if (!this.gainNode) {
this.gainNode = this.audioContext.createGain()
this.gainNode.gain.value = 1.0
}
if (!this.workletNode) {
this.workletNode = new AudioWorkletNode(this.audioContext, 'capture-processor', {
numberOfInputs: 1,
@@ -236,7 +248,12 @@ class ElectronCaptureRuntime {
this.stream = stream
this.sourceNode = this.audioContext.createMediaStreamSource(stream)
this.sourceNode.connect(this.workletNode)
if (this.gainNode) {
this.sourceNode.connect(this.gainNode)
this.gainNode.connect(this.workletNode)
} else {
this.sourceNode.connect(this.workletNode)
}
const audioTrack = stream.getAudioTracks()[0] ?? null
const trackSettings = audioTrack?.getSettings()
@@ -810,6 +827,10 @@ class AudioCapture {
})
}
setInputGain(db: number): void {
this.electronRuntime.setInputGain(db)
}
private emitStatus(): void {
const status = this.getStatus()
for (const listener of this.statusListeners) {
+218
View File
@@ -0,0 +1,218 @@
import { useEffect, type CSSProperties, type JSX } from 'react'
import { useAudioStore } from '../stores/audioStore'
import { useSettingsStore } from '../stores/settingsStore'
import { useThemeStore, PRESETS, PRESET_IDS } from '../stores/themeStore'
import type { ScopeKind } from '../../types/scope'
import { SCOPE_KINDS } from '../../types/scope'
const SCOPE_LABELS: Record<ScopeKind, string> = {
spectrum: 'Spectrum',
oscilloscope: 'Oscilloscope',
vectorscope: 'Vectorscope',
spectrogram: 'Spectrogram',
vumeter: 'VU Meter',
lufsmeter: 'LUFS Meter',
waveform: 'Waveform',
}
interface BottomBarProps {
onClose: () => void
}
export default function BottomBar({ onClose }: BottomBarProps): JSX.Element {
const hiddenScopes = useSettingsStore((s) => s.hiddenScopes)
const toggleScope = useSettingsStore((s) => s.toggleScope)
const { presetId, accent, setPreset, setCustomAccent, customAccent } = useThemeStore()
const {
systemSources,
devices,
selectedSystemSourceId,
selectedDeviceId,
captureMode,
isCapturing,
captureStatus,
captureError,
inputGainDb,
refreshSystemSources,
refreshDevices,
refreshBackendSupport,
selectSystemSource,
selectDevice,
startCapture,
setInputGain,
} = useAudioStore()
useEffect(() => {
void refreshBackendSupport()
void refreshSystemSources()
void refreshDevices()
}, [refreshBackendSupport, refreshSystemSources, refreshDevices])
const handleSourceChange = async (value: string): Promise<void> => {
if (value.startsWith('system:')) {
const sourceId = value.slice('system:'.length)
await selectSystemSource(sourceId)
await startCapture()
return
}
if (value.startsWith('device:')) {
const deviceId = value.slice('device:'.length)
await selectDevice(deviceId)
await startCapture()
}
}
const selectedSourceValue = captureMode === 'system'
? `system:${selectedSystemSourceId ?? systemSources[0]?.id ?? '__default_system_output__'}`
: `device:${selectedDeviceId ?? ''}`
const visibleSystemSources = systemSources.length
? systemSources
: [{ id: '__default_system_output__', label: 'System Output', kind: 'system', isDefault: true }]
const showInputDevices = devices.length > 0
const indicatorLabel = isCapturing
? 'Capturing'
: captureStatus === 'connecting'
? 'Connecting'
: captureStatus === 'error'
? 'Capture Failed'
: 'Idle'
return (
<div className="bottom-bar">
<section className="bottom-bar__section">
<div className="bottom-bar__section-title">Modules</div>
<div className="bottom-bar__inline">
{SCOPE_KINDS.map((kind) => {
const active = !hiddenScopes.has(kind)
return (
<button
key={kind}
type="button"
className={`settings-chip ${active ? 'is-active' : ''}`.trim()}
onClick={() => toggleScope(kind)}
title={SCOPE_LABELS[kind]}
>
{SCOPE_LABELS[kind]}
</button>
)
})}
</div>
</section>
<div className="bottom-bar__divider" />
<section className="bottom-bar__section">
<div className="bottom-bar__section-title">Theme</div>
<div className="bottom-bar__inline">
{PRESET_IDS.map((id) => {
const preset = PRESETS[id]
const active = presetId === id && !customAccent
return (
<button
key={id}
type="button"
className={`settings-swatch ${active ? 'is-active' : ''}`.trim()}
style={{ '--swatch-color': preset.accent } as CSSProperties}
onClick={() => setPreset(id)}
title={preset.name}
aria-label={preset.name}
/>
)
})}
<input
className="settings-accent-input"
type="color"
value={accent}
onChange={(event) => setCustomAccent(event.target.value)}
title="Custom accent color"
/>
{customAccent && (
<button
type="button"
className="settings-chip"
onClick={() => setCustomAccent(null)}
>
Reset
</button>
)}
</div>
</section>
<div className="bottom-bar__divider" />
<section className="bottom-bar__section">
<div className="bottom-bar__section-title">Audio Source</div>
<div className="bottom-bar__inline">
<select
className="settings-control__select"
value={selectedSourceValue}
onChange={(event) => {
void handleSourceChange(event.target.value)
}}
>
<optgroup label="Output Devices">
{visibleSystemSources.map((source) => (
<option key={source.id} value={`system:${source.id}`}>
{source.isDefault ? `${source.label} (Default)` : source.label}
</option>
))}
</optgroup>
{showInputDevices ? (
<optgroup label="Input Devices">
{devices.map((device) => (
<option key={device.deviceId} value={`device:${device.deviceId}`}>
{device.label || `Input ${device.deviceId.slice(0, 8)}`}
</option>
))}
</optgroup>
) : null}
</select>
<div className={`settings-status-pill is-${captureStatus}`.trim()}>
<span className="settings-status-pill__dot" />
<span>{indicatorLabel}</span>
</div>
</div>
{captureError ? (
<div className="settings-error-text">{captureError}</div>
) : null}
</section>
<div className="bottom-bar__divider" />
<section className="bottom-bar__section">
<div className="bottom-bar__section-title">Trim</div>
<div className="bottom-bar__inline">
<span className="bottom-bar__trim-value">
{inputGainDb > 0 ? '+' : ''}{inputGainDb.toFixed(1)}dB
</span>
<input
className="settings-control__range bottom-bar__trim-slider"
type="range"
min={-12}
max={12}
step={0.5}
value={inputGainDb}
onChange={(event) => setInputGain(Number(event.target.value))}
onDoubleClick={() => setInputGain(0)}
/>
</div>
</section>
<button
type="button"
className="settings-panel__close"
onClick={onClose}
>
Close
</button>
</div>
)
}
+3 -190
View File
@@ -1,7 +1,5 @@
import { useEffect, useMemo, useRef, type CSSProperties, type JSX, type ReactNode } from 'react'
import { useAudioStore } from '../stores/audioStore'
import { useMemo, type CSSProperties, type JSX, type ReactNode } from 'react'
import { useSettingsStore, type ScopeSettings } from '../stores/settingsStore'
import { useThemeStore, PRESETS, PRESET_IDS } from '../stores/themeStore'
import type { ScopeKind } from '../../types/scope'
import { buildAnalyzerGridTemplateColumns } from '../analyzerLayout'
@@ -470,31 +468,8 @@ function ScopeSettingsSection({
)
}
interface SettingsPanelProps {
onClose: () => void
onHeightChange?: (height: number) => void
}
export default function SettingsPanel({ onClose, onHeightChange }: SettingsPanelProps): JSX.Element {
const {
systemSources,
devices,
selectedSystemSourceId,
selectedDeviceId,
captureMode,
isCapturing,
captureStatus,
captureError,
refreshSystemSources,
refreshDevices,
refreshBackendSupport,
selectSystemSource,
selectDevice,
startCapture,
} = useAudioStore()
export default function SettingsPanel(): JSX.Element {
const { scopeSettings, updateScopeSettings, hiddenScopes, scopeOrder, widthWeights } = useSettingsStore()
const { presetId, accent, setPreset, setCustomAccent, customAccent } = useThemeStore()
const panelRef = useRef<HTMLDivElement | null>(null)
const visibleScopes = useMemo(
() => scopeOrder.filter((kind) => !hiddenScopes.has(kind)),
@@ -506,170 +481,8 @@ export default function SettingsPanel({ onClose, onHeightChange }: SettingsPanel
return { gridTemplateColumns } as CSSProperties
}, [visibleScopes, widthWeights])
useEffect(() => {
void refreshBackendSupport()
void refreshSystemSources()
void refreshDevices()
}, [refreshBackendSupport, refreshSystemSources, refreshDevices])
useEffect(() => {
const panel = panelRef.current
if (!panel || !onHeightChange) return
const reportHeight = (): void => {
const nextHeight = Math.ceil(panel.getBoundingClientRect().height)
onHeightChange(nextHeight)
}
reportHeight()
const observer = typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver(() => reportHeight())
observer?.observe(panel)
window.addEventListener('resize', reportHeight)
return () => {
observer?.disconnect()
window.removeEventListener('resize', reportHeight)
}
}, [onHeightChange, visibleScopes.length])
const handleSourceChange = async (value: string): Promise<void> => {
if (value.startsWith('system:')) {
const sourceId = value.slice('system:'.length)
await selectSystemSource(sourceId)
await startCapture()
return
}
if (value.startsWith('device:')) {
const deviceId = value.slice('device:'.length)
await selectDevice(deviceId)
await startCapture()
}
}
const selectedSourceValue = captureMode === 'system'
? `system:${selectedSystemSourceId ?? systemSources[0]?.id ?? '__default_system_output__'}`
: `device:${selectedDeviceId ?? ''}`
const visibleSystemSources = systemSources.length
? systemSources
: [{ id: '__default_system_output__', label: 'System Output', kind: 'system', isDefault: true }]
const showInputDevices = devices.length > 0
const renderSystemSourceLabel = (label: string, isDefault?: boolean): string => (
isDefault ? `${label} (Default)` : label
)
const renderInputDeviceValue = (deviceId: string): string => `device:${deviceId}`
const renderSystemSourceValue = (sourceId: string): string => `system:${sourceId}`
const indicatorLabel = isCapturing
? 'Capturing'
: captureStatus === 'connecting'
? 'Connecting'
: captureStatus === 'error'
? 'Capture Failed'
: 'Idle'
return (
<div className="settings-panel" ref={panelRef}>
<div className="settings-panel__utility-row">
<section className="settings-utility-section settings-utility-section--source">
<div className="settings-section-title">Audio Source</div>
<label className="settings-control settings-control--stack">
<span className="settings-control__label">Source</span>
<select
className="settings-control__select"
value={selectedSourceValue}
onChange={(event) => {
void handleSourceChange(event.target.value)
}}
>
<optgroup label="Output Devices">
{visibleSystemSources.map((source) => (
<option key={source.id} value={renderSystemSourceValue(source.id)}>
{renderSystemSourceLabel(source.label, source.isDefault)}
</option>
))}
</optgroup>
{showInputDevices ? (
<optgroup label="Input Devices">
{devices.map((device) => (
<option key={device.deviceId} value={renderInputDeviceValue(device.deviceId)}>
{device.label || `Input ${device.deviceId.slice(0, 8)}`}
</option>
))}
</optgroup>
) : null}
</select>
</label>
<div className={`settings-status-pill is-${captureStatus}`.trim()}>
<span className="settings-status-pill__dot" />
<span>{indicatorLabel}</span>
</div>
{captureError ? (
<div className="settings-error-text">{captureError}</div>
) : null}
</section>
<section className="settings-utility-section settings-utility-section--theme">
<div className="settings-section-title">Theme</div>
<div className="settings-theme-swatches">
{PRESET_IDS.map((id) => {
const preset = PRESETS[id]
const active = presetId === id && !customAccent
return (
<button
key={id}
type="button"
className={`settings-swatch ${active ? 'is-active' : ''}`.trim()}
style={{ '--swatch-color': preset.accent } as CSSProperties}
onClick={() => setPreset(id)}
title={preset.name}
aria-label={preset.name}
/>
)
})}
</div>
<label className="settings-control settings-control--stack">
<span className="settings-control__label">Custom Accent</span>
<div className="settings-accent-row">
<input
className="settings-accent-input"
type="color"
value={accent}
onChange={(event) => setCustomAccent(event.target.value)}
/>
<button
type="button"
className="settings-chip"
onClick={() => setCustomAccent(null)}
>
Reset
</button>
</div>
</label>
</section>
<button
type="button"
className="settings-panel__close"
onClick={onClose}
>
Close
</button>
</div>
<div className="settings-panel">
<div className="settings-panel__scope-track" style={scopeTrackStyle}>
{visibleScopes.map((kind) => (
<ScopeSettingsSection
+253 -25
View File
@@ -1,18 +1,6 @@
import { useState, useEffect, useCallback, type CSSProperties, type JSX } from 'react'
import type { ScopeKind } from '../../types/scope'
import { SCOPE_KINDS } from '../../types/scope'
import { useState, useEffect, useCallback, useRef, type CSSProperties, type JSX } from 'react'
import { useSettingsStore } from '../stores/settingsStore'
const SCOPE_LABELS: Record<ScopeKind, string> = {
spectrum: 'SPEC',
oscilloscope: 'OSC',
vectorscope: 'VEC',
spectrogram: 'GRAM',
vumeter: 'VU',
lufsmeter: 'LUFS',
waveform: 'WAVE',
}
function SettingsIcon(): JSX.Element {
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
@@ -38,15 +26,63 @@ function CloseIcon(): JSX.Element {
)
}
function GripIcon(): JSX.Element {
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
<circle cx="5.5" cy="4" r="1.2" fill="currentColor" />
<circle cx="10.5" cy="4" r="1.2" fill="currentColor" />
<circle cx="5.5" cy="8" r="1.2" fill="currentColor" />
<circle cx="10.5" cy="8" r="1.2" fill="currentColor" />
<circle cx="5.5" cy="12" r="1.2" fill="currentColor" />
<circle cx="10.5" cy="12" r="1.2" fill="currentColor" />
</svg>
)
}
function MinimizeIcon(): JSX.Element {
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M3.5 8h9" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg>
)
}
function RepositionIcon(): JSX.Element {
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M4 6l4-3 4 3M4 10l4 3 4-3" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
function ChevronIcon(): JSX.Element {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" style={{ width: 10, height: 10 }}>
<path d="M5 6l3 3 3-3" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
interface ToolbarProps {
onOpenSettings: () => void
settingsOpen: boolean
}
export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps): JSX.Element {
const hiddenScopes = useSettingsStore((state) => state.hiddenScopes)
const toggleScope = useSettingsStore((state) => state.toggleScope)
const profiles = useSettingsStore((s) => s.profiles)
const activeProfileId = useSettingsStore((s) => s.activeProfileId)
const saveProfile = useSettingsStore((s) => s.saveProfile)
const loadProfile = useSettingsStore((s) => s.loadProfile)
const deleteProfile = useSettingsStore((s) => s.deleteProfile)
const renameProfile = useSettingsStore((s) => s.renameProfile)
const updateActiveProfile = useSettingsStore((s) => s.updateActiveProfile)
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(true)
const [showReposition, setShowReposition] = useState(false)
const [showProfileMenu, setShowProfileMenu] = useState(false)
const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
const profileMenuRef = useRef<HTMLDivElement>(null)
const renameInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
window.electronAPI.isAlwaysOnTop().then(setIsAlwaysOnTop)
@@ -54,12 +90,72 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
return unsubscribe
}, [])
// Close profile menu on outside click
useEffect(() => {
if (!showProfileMenu) return
const handleClick = (e: MouseEvent): void => {
if (profileMenuRef.current && !profileMenuRef.current.contains(e.target as Node)) {
setShowProfileMenu(false)
setRenamingId(null)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [showProfileMenu])
// Focus rename input when it appears
useEffect(() => {
if (renamingId && renameInputRef.current) {
renameInputRef.current.focus()
renameInputRef.current.select()
}
}, [renamingId])
const handlePin = useCallback(() => {
window.electronAPI.toggleAlwaysOnTop()
}, [])
const handleReposition = useCallback((position: 'top' | 'bottom') => {
window.electronAPI.repositionWindow(position)
setShowReposition(false)
}, [])
const handleSaveNew = useCallback(() => {
const count = Object.keys(profiles).length
saveProfile(`Profile ${count}`)
setShowProfileMenu(false)
}, [profiles, saveProfile])
const handleSaveOverwrite = useCallback(() => {
updateActiveProfile()
setShowProfileMenu(false)
}, [updateActiveProfile])
const handleStartRename = useCallback((id: string, currentName: string) => {
setRenamingId(id)
setRenameValue(currentName)
}, [])
const handleFinishRename = useCallback(() => {
if (renamingId && renameValue.trim()) {
renameProfile(renamingId, renameValue.trim())
}
setRenamingId(null)
}, [renamingId, renameValue, renameProfile])
const profileIds = Object.keys(profiles)
const activeProfile = activeProfileId ? profiles[activeProfileId] : null
return (
<div className="toolbar">
<div
className="toolbar__grab"
style={{ WebkitAppRegion: 'drag' } as CSSProperties}
title="Drag to move window"
>
<GripIcon />
</div>
<div
className="toolbar__brand"
style={{ WebkitAppRegion: 'drag' } as CSSProperties}
@@ -68,23 +164,145 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
<span className="toolbar__brand-text">Prism</span>
</div>
<div className="toolbar__chips">
{SCOPE_KINDS.map((kind) => {
const active = !hiddenScopes.has(kind)
return (
<div className="toolbar__profile" ref={profileMenuRef}>
<button
type="button"
className={`toolbar__profile-button ${showProfileMenu ? 'is-active' : ''}`.trim()}
onClick={() => setShowProfileMenu((prev) => !prev)}
title="Presets"
>
<span className="toolbar__profile-name">
{activeProfile?.name ?? 'Presets'}
</span>
<ChevronIcon />
</button>
{showProfileMenu && (
<div className="toolbar__profile-menu">
<div className="toolbar__profile-menu-section">
<div className="toolbar__profile-menu-label">Presets</div>
{profileIds.map((id) => {
const profile = profiles[id]
const isActive = id === activeProfileId
const isDefault = id === 'profile_default'
if (renamingId === id) {
return (
<div key={id} className="toolbar__profile-menu-item">
<input
ref={renameInputRef}
className="toolbar__profile-rename-input"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={handleFinishRename}
onKeyDown={(e) => {
if (e.key === 'Enter') handleFinishRename()
if (e.key === 'Escape') setRenamingId(null)
}}
/>
</div>
)
}
return (
<div
key={id}
className={`toolbar__profile-menu-item ${isActive ? 'is-active' : ''}`.trim()}
>
<button
type="button"
className="toolbar__profile-menu-item-name"
onClick={() => {
loadProfile(id)
setShowProfileMenu(false)
}}
>
{isActive && <span className="toolbar__profile-check">&#10003;</span>}
{profile.name}
</button>
{!isDefault && (
<div className="toolbar__profile-menu-item-actions">
<button
type="button"
className="toolbar__profile-menu-action"
onClick={() => handleStartRename(id, profile.name)}
title="Rename"
>
&#9998;
</button>
<button
type="button"
className="toolbar__profile-menu-action toolbar__profile-menu-action--danger"
onClick={() => deleteProfile(id)}
title="Delete"
>
&times;
</button>
</div>
)}
</div>
)
})}
</div>
<div className="toolbar__profile-menu-divider" />
<button
key={kind}
type="button"
className={`toolbar__chip ${active ? 'is-active' : ''}`.trim()}
onClick={() => toggleScope(kind)}
className="toolbar__profile-menu-action-row"
onClick={handleSaveNew}
>
{SCOPE_LABELS[kind]}
Save as New Preset
</button>
)
})}
{activeProfileId && (
<button
type="button"
className="toolbar__profile-menu-action-row"
onClick={handleSaveOverwrite}
>
Save to "{activeProfile?.name}"
</button>
)}
</div>
)}
</div>
<div
className="toolbar__spacer"
style={{ WebkitAppRegion: 'drag' } as CSSProperties}
/>
<div className="toolbar__actions">
<div className="toolbar__reposition-wrap">
<button
type="button"
className={`toolbar__icon-button ${showReposition ? 'is-active' : ''}`.trim()}
onClick={() => setShowReposition((prev) => !prev)}
title="Reposition window"
aria-label="Reposition window"
>
<RepositionIcon />
</button>
{showReposition && (
<div className="toolbar__reposition-menu">
<button
type="button"
className="toolbar__reposition-option"
onClick={() => handleReposition('top')}
>
Top
</button>
<button
type="button"
className="toolbar__reposition-option"
onClick={() => handleReposition('bottom')}
>
Bottom
</button>
</div>
)}
</div>
<button
type="button"
className={`toolbar__icon-button ${settingsOpen ? 'is-active' : ''}`.trim()}
@@ -105,6 +323,16 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
<PinIcon />
</button>
<button
type="button"
className="toolbar__icon-button"
onClick={() => window.electronAPI.minimize()}
title="Minimize"
aria-label="Minimize"
>
<MinimizeIcon />
</button>
<button
type="button"
className="toolbar__icon-button toolbar__icon-button--danger"
+5
View File
@@ -12,6 +12,11 @@ declare global {
platform: string
minimize: () => void
close: () => void
startWindowMove: () => void
stopWindowMove: () => void
setWindowBounds: (bounds: { x: number; y: number; width: number; height: number }) => void
getWindowBounds: () => Promise<{ x: number; y: number; width: number; height: number } | null>
repositionWindow: (position: 'top' | 'bottom') => void
toggleAlwaysOnTop: () => void
isAlwaysOnTop: () => Promise<boolean>
getDesktopSources: () => Promise<{ id: string; name: string }[]>
+8
View File
@@ -23,6 +23,8 @@ interface AudioState {
captureError: string | null
sampleRate: number
channelCount: number
inputGainDb: number
setInputGain: (db: number) => void
refreshSystemSources: () => Promise<void>
refreshDevices: () => Promise<void>
refreshBackendSupport: () => Promise<void>
@@ -62,6 +64,12 @@ export const useAudioStore = create<AudioState>((set, get) => ({
captureError: null,
sampleRate: 48000,
channelCount: 2,
inputGainDb: 0,
setInputGain: (db: number) => {
audioCapture.setInputGain(db)
set({ inputGainDb: db })
},
refreshSystemSources: async () => {
const systemSources = await audioCapture.listSources('system')
+185
View File
@@ -63,6 +63,48 @@ const DEFAULT_SCOPE_SETTINGS: ScopeSettings = {
const DEFAULT_VISIBLE: ScopeKind[] = ['spectrum', 'oscilloscope', 'vectorscope', 'vumeter']
const STORAGE_KEY = 'prism:settings'
const PROFILES_STORAGE_KEY = 'prism:profiles'
const ACTIVE_PROFILE_KEY = 'prism:activeProfile'
const DEFAULT_PROFILE_ID = 'profile_default'
export interface Profile {
name: string
scopeOrder: ScopeKind[]
hiddenScopes: ScopeKind[]
widthWeights: Record<ScopeKind, number>
scopeSettings: ScopeSettings
windowBounds?: { x: number; y: number; width: number; height: number }
}
function loadProfiles(): Record<string, Profile> {
try {
const raw = localStorage.getItem(PROFILES_STORAGE_KEY)
if (raw) return JSON.parse(raw)
} catch { /* ignore */ }
return {}
}
function saveProfiles(profiles: Record<string, Profile>): void {
try {
localStorage.setItem(PROFILES_STORAGE_KEY, JSON.stringify(profiles))
} catch { /* ignore */ }
}
function loadActiveProfileId(): string | null {
try {
return localStorage.getItem(ACTIVE_PROFILE_KEY)
} catch { return null }
}
function saveActiveProfileId(id: string | null): void {
try {
if (id) {
localStorage.setItem(ACTIVE_PROFILE_KEY, id)
} else {
localStorage.removeItem(ACTIVE_PROFILE_KEY)
}
} catch { /* ignore */ }
}
interface SettingsState {
scopeOrder: ScopeKind[]
@@ -73,11 +115,21 @@ interface SettingsState {
// Derived
visibleScopes: () => ScopeKind[]
// Profiles
profiles: Record<string, Profile>
activeProfileId: string | null
// Actions
toggleScope: (kind: ScopeKind) => void
moveScope: (kind: ScopeKind, direction: 'left' | 'right') => void
setScopeWidthWeight: (kind: ScopeKind, weight: number) => void
updateScopeSettings: <K extends ScopeKind>(kind: K, settings: Partial<ScopeSettings[K]>) => void
saveProfile: (name: string) => string
saveProfileAs: (name: string) => string
updateActiveProfile: () => void
loadProfile: (id: string) => void
deleteProfile: (id: string) => void
renameProfile: (id: string, name: string) => void
}
function loadFromStorage(): Partial<{ scopeOrder: ScopeKind[]; hiddenScopes: ScopeKind[]; widthWeights: Record<ScopeKind, number>; scopeSettings: ScopeSettings }> {
@@ -154,6 +206,24 @@ const defaultWeights: Record<ScopeKind, number> = {
vumeter: 0.5, lufsmeter: 0.5, waveform: 1,
}
// Ensure a default profile always exists
function ensureDefaultProfile(profiles: Record<string, Profile>): Record<string, Profile> {
if (profiles[DEFAULT_PROFILE_ID]) return profiles
const defaultProfile: Profile = {
name: 'Default',
scopeOrder: [...SCOPE_KINDS],
hiddenScopes: SCOPE_KINDS.filter((kind) => !DEFAULT_VISIBLE.includes(kind)),
widthWeights: { ...defaultWeights },
scopeSettings: JSON.parse(JSON.stringify(DEFAULT_SCOPE_SETTINGS)),
}
const updated = { [DEFAULT_PROFILE_ID]: defaultProfile, ...profiles }
saveProfiles(updated)
return updated
}
const initialProfiles = ensureDefaultProfile(loadProfiles())
const initialActiveProfileId = loadActiveProfileId()
export const useSettingsStore = create<SettingsState>((set, get) => ({
scopeOrder: normalizeScopeOrder(stored.scopeOrder),
hiddenScopes: new Set<ScopeKind>(
@@ -161,6 +231,8 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
),
widthWeights: stored.widthWeights ?? { ...defaultWeights },
scopeSettings: mergeScopeSettings(stored.scopeSettings),
profiles: initialProfiles,
activeProfileId: initialActiveProfileId,
visibleScopes: () => {
const { scopeOrder, hiddenScopes } = get()
@@ -219,4 +291,117 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
return newState
})
},
saveProfile: (name: string) => {
const state = get()
const id = `profile_${Date.now()}`
const profile: Profile = {
name,
scopeOrder: [...state.scopeOrder],
hiddenScopes: Array.from(state.hiddenScopes),
widthWeights: { ...state.widthWeights },
scopeSettings: JSON.parse(JSON.stringify(state.scopeSettings)),
}
// Capture window bounds asynchronously
window.electronAPI.getWindowBounds().then((bounds) => {
if (bounds) {
const profiles = get().profiles
const updated = { ...profiles, [id]: { ...profiles[id], windowBounds: bounds } }
saveProfiles(updated)
set({ profiles: updated })
}
})
const profiles = { ...state.profiles, [id]: profile }
saveProfiles(profiles)
saveActiveProfileId(id)
set({ profiles, activeProfileId: id })
return id
},
saveProfileAs: (name: string) => {
// Same as saveProfile but always creates a new entry
return get().saveProfile(name)
},
updateActiveProfile: () => {
const state = get()
const id = state.activeProfileId
if (!id || !state.profiles[id]) return
const updated: Profile = {
...state.profiles[id],
scopeOrder: [...state.scopeOrder],
hiddenScopes: Array.from(state.hiddenScopes),
widthWeights: { ...state.widthWeights },
scopeSettings: JSON.parse(JSON.stringify(state.scopeSettings)),
}
// Capture window bounds asynchronously
window.electronAPI.getWindowBounds().then((bounds) => {
if (bounds) {
const profiles = get().profiles
const withBounds = { ...profiles, [id]: { ...profiles[id], windowBounds: bounds } }
saveProfiles(withBounds)
set({ profiles: withBounds })
}
})
const profiles = { ...state.profiles, [id]: updated }
saveProfiles(profiles)
set({ profiles })
},
loadProfile: (id: string) => {
const state = get()
const profile = state.profiles[id]
if (!profile) return
const newState = {
...state,
scopeOrder: normalizeScopeOrder(profile.scopeOrder),
hiddenScopes: new Set<ScopeKind>(normalizeHiddenScopes(profile.hiddenScopes)),
widthWeights: profile.widthWeights ?? { ...defaultWeights },
scopeSettings: mergeScopeSettings(profile.scopeSettings),
activeProfileId: id,
}
saveToStorage(newState as SettingsState)
saveActiveProfileId(id)
set(newState)
if (profile.windowBounds) {
window.electronAPI.setWindowBounds(profile.windowBounds)
}
},
deleteProfile: (id: string) => {
// Prevent deleting the default profile
if (id === DEFAULT_PROFILE_ID) return
const state = get()
const profiles = { ...state.profiles }
delete profiles[id]
saveProfiles(profiles)
const nextActiveId = state.activeProfileId === id ? null : state.activeProfileId
saveActiveProfileId(nextActiveId)
set({
profiles,
activeProfileId: nextActiveId,
})
},
renameProfile: (id: string, name: string) => {
if (id === DEFAULT_PROFILE_ID) return
const state = get()
const profile = state.profiles[id]
if (!profile) return
const profiles = { ...state.profiles, [id]: { ...profile, name } }
saveProfiles(profiles)
set({ profiles })
},
}))
// On startup, restore last active profile's settings (but not window bounds — those are handled by Electron)
if (initialActiveProfileId && initialProfiles[initialActiveProfileId]) {
const profile = initialProfiles[initialActiveProfileId]
useSettingsStore.setState({
scopeOrder: normalizeScopeOrder(profile.scopeOrder),
hiddenScopes: new Set<ScopeKind>(normalizeHiddenScopes(profile.hiddenScopes)),
widthWeights: profile.widthWeights ?? { ...defaultWeights },
scopeSettings: mergeScopeSettings(profile.scopeSettings),
})
}
+319 -4
View File
@@ -82,6 +82,13 @@ select {
min-height: 0;
}
.prism-settings-region {
flex-shrink: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.toolbar {
display: flex;
align-items: center;
@@ -97,6 +104,27 @@ select {
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.22);
}
.toolbar__grab {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 28px;
color: rgba(255, 255, 255, 0.3);
cursor: grab;
flex-shrink: 0;
-webkit-app-region: drag;
}
.toolbar__grab:active {
cursor: grabbing;
}
.toolbar__grab svg {
width: 14px;
height: 14px;
}
.toolbar__brand {
display: inline-flex;
align-items: center;
@@ -137,11 +165,229 @@ select {
font-size: 10px;
}
.toolbar__chips {
.toolbar__profile {
position: relative;
display: flex;
gap: 4px;
align-items: center;
}
.toolbar__profile-button {
display: flex;
align-items: center;
gap: 5px;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: transparent;
color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
letter-spacing: 0.12em;
text-transform: uppercase;
cursor: pointer;
outline: none;
transition: color 120ms ease, border-color 120ms ease;
}
.toolbar__profile-button:hover,
.toolbar__profile-button.is-active {
color: rgba(255, 255, 255, 0.78);
border-color: rgba(255, 255, 255, 0.14);
}
.toolbar__profile-name {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toolbar__profile-menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
min-width: 200px;
background: rgba(10, 10, 12, 0.96);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 4px 0;
z-index: 1000;
backdrop-filter: blur(12px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
}
.toolbar__profile-menu-section {
padding: 2px 0;
}
.toolbar__profile-menu-label {
padding: 4px 12px;
font-family: 'Inter', sans-serif;
font-size: 9px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.3);
}
.toolbar__profile-menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 4px 0 0;
}
.toolbar__profile-menu-item.is-active .toolbar__profile-menu-item-name {
color: rgba(255, 255, 255, 0.9);
}
.toolbar__profile-menu-item-name {
flex: 1;
min-width: 0;
padding: 5px 12px;
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
font-family: 'Inter', sans-serif;
font-size: 11px;
text-align: left;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toolbar__profile-menu-item-name:hover {
color: rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.04);
}
.toolbar__profile-check {
margin-right: 6px;
font-size: 10px;
color: var(--accent);
}
.toolbar__profile-menu-item-actions {
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 100ms ease;
}
.toolbar__profile-menu-item:hover .toolbar__profile-menu-item-actions {
opacity: 1;
}
.toolbar__profile-menu-action {
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
border-radius: 4px;
color: rgba(255, 255, 255, 0.4);
font-size: 12px;
cursor: pointer;
}
.toolbar__profile-menu-action:hover {
color: rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.06);
}
.toolbar__profile-menu-action--danger:hover {
color: #ff5f57;
background: rgba(255, 95, 87, 0.1);
}
.toolbar__profile-rename-input {
flex: 1;
margin: 2px 8px;
padding: 3px 8px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
color: rgba(255, 255, 255, 0.9);
font-family: 'Inter', sans-serif;
font-size: 11px;
outline: none;
}
.toolbar__profile-rename-input:focus {
border-color: var(--accent);
}
.toolbar__profile-menu-divider {
height: 1px;
margin: 4px 0;
background: rgba(255, 255, 255, 0.06);
}
.toolbar__profile-menu-action-row {
display: block;
width: 100%;
padding: 6px 12px;
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
font-family: 'Inter', sans-serif;
font-size: 11px;
text-align: left;
cursor: pointer;
}
.toolbar__profile-menu-action-row:hover {
color: rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.04);
}
.toolbar__spacer {
flex: 1;
min-width: 0;
height: 100%;
-webkit-app-region: drag;
}
.toolbar__reposition-wrap {
position: relative;
}
.toolbar__reposition-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
display: flex;
flex-direction: column;
background: rgba(8, 11, 16, 0.96);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
overflow: hidden;
z-index: 20;
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.4);
}
.toolbar__reposition-option {
padding: 6px 16px;
background: transparent;
border: none;
color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
letter-spacing: 0.12em;
text-transform: uppercase;
cursor: pointer;
white-space: nowrap;
transition: color 120ms ease, background-color 120ms ease;
}
.toolbar__reposition-option:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.06);
}
.toolbar__chip {
@@ -268,10 +514,13 @@ select {
display: flex;
flex-direction: column;
gap: 12px;
overflow: hidden;
flex-shrink: 0;
overflow-y: auto;
overflow-x: hidden;
flex: 1;
min-height: 0;
position: relative;
padding: 12px 0 14px;
flex-shrink: 1;
}
.settings-panel__utility-row {
@@ -591,6 +840,72 @@ select {
background: rgba(255, 255, 255, 0.05);
}
.bottom-bar {
display: flex;
align-items: center;
gap: 0;
padding: 8px 0;
background: linear-gradient(180deg, rgba(4, 5, 7, 0.98), rgba(2, 3, 4, 0.98));
border-top: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
}
.bottom-bar__section {
display: flex;
align-items: center;
gap: 10px;
padding: 0 14px;
min-width: 0;
}
.bottom-bar__section-title {
font-family: 'JetBrains Mono', monospace;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.72);
font-size: 10px;
white-space: nowrap;
flex-shrink: 0;
}
.bottom-bar__inline {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.bottom-bar__divider {
width: 1px;
align-self: stretch;
background: rgba(255, 255, 255, 0.08);
flex-shrink: 0;
}
.bottom-bar__trim-value {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--accent);
letter-spacing: 0.06em;
min-width: 48px;
text-align: center;
white-space: nowrap;
}
.bottom-bar__trim-slider {
width: 100px;
}
.bottom-bar .settings-control__select {
min-width: 180px;
}
.bottom-bar .settings-panel__close {
margin-left: auto;
flex-shrink: 0;
margin-right: 14px;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;