improve ui/ux

This commit is contained in:
Boof2015
2026-03-21 17:19:38 -04:00
parent 311f35f795
commit 72dac16983
9 changed files with 1192 additions and 511 deletions
+20
View File
@@ -2,6 +2,7 @@ import { app, BrowserWindow, desktopCapturer, ipcMain, session } from 'electron'
import { join } from 'path'
let mainWindow: BrowserWindow | null = null
let currentSettingsHeight = 0
const WINDOW_DEFAULTS = {
width: 900,
@@ -33,6 +34,7 @@ function createWindow(): void {
mainWindow.on('closed', () => {
mainWindow = null
currentSettingsHeight = 0
})
// Load the renderer
@@ -86,6 +88,7 @@ function setupIPC(): void {
const [minW] = mainWindow.getMinimumSize()
mainWindow.setMinimumSize(minW, WINDOW_DEFAULTS.minHeight + panelHeight)
mainWindow.setSize(width, height + panelHeight, true)
currentSettingsHeight = Math.max(0, currentSettingsHeight + Math.round(panelHeight))
})
ipcMain.on('window:collapse-settings', (_event, panelHeight: number) => {
@@ -94,6 +97,23 @@ function setupIPC(): void {
const [minW] = mainWindow.getMinimumSize()
mainWindow.setMinimumSize(minW, WINDOW_DEFAULTS.minHeight)
mainWindow.setSize(width, Math.max(WINDOW_DEFAULTS.minHeight, height - panelHeight), true)
currentSettingsHeight = Math.max(0, currentSettingsHeight - Math.round(panelHeight))
})
ipcMain.on('window:set-settings-height', (_event, panelHeight: number) => {
if (!mainWindow) return
const nextHeight = Math.max(0, Math.round(panelHeight))
const delta = nextHeight - currentSettingsHeight
const [width, height] = mainWindow.getSize()
const [minW] = mainWindow.getMinimumSize()
mainWindow.setMinimumSize(minW, WINDOW_DEFAULTS.minHeight + nextHeight)
if (delta !== 0) {
mainWindow.setSize(width, Math.max(WINDOW_DEFAULTS.minHeight, height + delta), true)
}
currentSettingsHeight = nextHeight
})
}
+1
View File
@@ -10,6 +10,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
getDesktopSources: () => ipcRenderer.invoke('audio:get-desktop-sources') as Promise<{ id: string; name: string }[]>,
expandSettings: (panelHeight: number) => ipcRenderer.send('window:expand-settings', panelHeight),
collapseSettings: (panelHeight: number) => ipcRenderer.send('window:collapse-settings', panelHeight),
setSettingsHeight: (panelHeight: number) => ipcRenderer.send('window:set-settings-height', panelHeight),
onAlwaysOnTopChanged: (callback: (isOnTop: boolean) => void) => {
const handler = (_event: Electron.IpcRendererEvent, isOnTop: boolean): void => callback(isOnTop)
ipcRenderer.on('window:always-on-top-changed', handler)
+18 -27
View File
@@ -1,4 +1,4 @@
import { useState, useRef, useCallback, useEffect } from 'react'
import { useState, useRef, useCallback, useEffect, type JSX } from 'react'
import Strip from './components/Strip'
import Toolbar from './components/Toolbar'
import SettingsPanel from './components/SettingsPanel'
@@ -6,13 +6,14 @@ import { useSettingsStore } from './stores/settingsStore'
import { useAudioStore } from './stores/audioStore'
import { SCOPE_KINDS } from '../types/scope'
const SETTINGS_PANEL_HEIGHT = 200
const DEFAULT_SETTINGS_PANEL_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 settingsExpandedRef = useRef(false)
const appliedSettingsHeightRef = useRef(0)
const toggleScope = useSettingsStore((s) => s.toggleScope)
@@ -21,16 +22,13 @@ export default function App(): JSX.Element {
useAudioStore.getState().startCapture()
}, [])
// Settings panel window resize — single stable effect, no double-fire
useEffect(() => {
if (settingsOpen && !settingsExpandedRef.current) {
settingsExpandedRef.current = true
window.electronAPI.expandSettings(SETTINGS_PANEL_HEIGHT)
} else if (!settingsOpen && settingsExpandedRef.current) {
settingsExpandedRef.current = false
window.electronAPI.collapseSettings(SETTINGS_PANEL_HEIGHT)
const nextHeight = settingsOpen ? settingsPanelHeight : 0
if (appliedSettingsHeightRef.current !== nextHeight) {
window.electronAPI.setSettingsHeight(nextHeight)
appliedSettingsHeightRef.current = nextHeight
}
}, [settingsOpen])
}, [settingsOpen, settingsPanelHeight])
const showToolbar = useCallback(() => {
if (hideTimeoutRef.current) {
@@ -80,33 +78,26 @@ export default function App(): JSX.Element {
return (
<div
style={{ width: '100vw', height: '100vh', display: 'flex', flexDirection: 'column', position: 'relative' }}
className="prism-app"
onMouseEnter={showToolbar}
onMouseLeave={scheduleHide}
>
{/* Toolbar overlay — fades in on hover */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 10,
opacity: toolbarVisible ? 1 : 0,
transition: 'opacity 150ms ease',
pointerEvents: toolbarVisible ? 'auto' : 'none',
}}
className={`prism-toolbar-layer ${toolbarVisible ? 'is-visible' : ''}`.trim()}
>
<Toolbar onOpenSettings={handleToggleSettings} settingsOpen={settingsOpen} />
</div>
{/* Scope strip — fills all available space */}
<div style={{ flex: 1, minHeight: 0 }}>
<div className="prism-strip-region">
<Strip />
</div>
{/* Settings panel — expands below strip */}
{settingsOpen && <SettingsPanel onClose={handleCloseSettings} />}
{settingsOpen && (
<SettingsPanel
onClose={handleCloseSettings}
onHeightChange={setSettingsPanelHeight}
/>
)}
</div>
)
}
+19 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react'
import { useEffect, useRef, type JSX } from 'react'
import type { ScopeKind } from '../../types/scope'
import { useSettingsStore, type ScopeSettings } from '../stores/settingsStore'
import { SpectrumAnalyzer } from '../visualizers/SpectrumAnalyzer'
@@ -29,7 +29,16 @@ function scopeSettingsToOptions(kind: ScopeKind, settings: ScopeSettings[ScopeKi
switch (kind) {
case 'spectrum': {
const s = settings as ScopeSettings['spectrum']
return { ...base, fftSize: s.fftSize, tiltDbPerOctave: s.tiltDbPerOctave, heatmapFill: s.heatmap, heatmapTiltDbPerOctave: s.heatmapTiltDbPerOctave, showGrid: s.showGrid, fillGradient: s.fillGradient }
return {
...base,
fftSize: s.fftSize,
tiltDbPerOctave: s.tiltDbPerOctave,
heatmapFill: s.heatmap,
heatmapTiltDbPerOctave: s.heatmapTiltDbPerOctave,
showGrid: s.showGrid,
fillGradient: s.fillGradient,
smoothing: s.smoothing,
}
}
case 'oscilloscope': {
const s = settings as ScopeSettings['oscilloscope']
@@ -37,7 +46,14 @@ function scopeSettingsToOptions(kind: ScopeKind, settings: ScopeSettings[ScopeKi
}
case 'vectorscope': {
const s = settings as ScopeSettings['vectorscope']
return { ...base, mode: s.mode, multiband: s.multiband, showGrid: s.showGrid, persistence: s.persistence }
return {
...base,
mode: s.mode,
multiband: s.multiband,
showGrid: s.showGrid,
persistence: s.persistence,
lineWidth: s.lineWidth,
}
}
case 'spectrogram': {
const s = settings as ScopeSettings['spectrogram']
+550 -360
View File
@@ -1,11 +1,9 @@
import { useEffect } from 'react'
import { useEffect, useRef, type CSSProperties, type JSX, type ReactNode } from 'react'
import { useAudioStore } from '../stores/audioStore'
import { useSettingsStore, type ScopeSettings } from '../stores/settingsStore'
import { useThemeStore, PRESETS, PRESET_IDS } from '../stores/themeStore'
import type { ScopeKind } from '../../types/scope'
const PANEL_HEIGHT = 200
const SCOPE_LABELS: Record<ScopeKind, string> = {
spectrum: 'Spectrum',
oscilloscope: 'Oscilloscope',
@@ -16,42 +14,467 @@ const SCOPE_LABELS: Record<ScopeKind, string> = {
waveform: 'Waveform',
}
const labelStyle: React.CSSProperties = {
fontSize: '9px',
fontFamily: "'JetBrains Mono', monospace",
color: 'rgba(255, 255, 255, 0.45)',
textTransform: 'uppercase',
letterSpacing: '0.08em',
marginBottom: '4px',
function vectorscopeModeLabel(mode: ScopeSettings['vectorscope']['mode']): string {
switch (mode) {
case 'lissajous':
return 'Lissajous'
case 'polar-unipolar':
return 'Polar Uni'
case 'polar-bipolar':
return 'Polar Bi'
case 'linear-unipolar':
return 'Linear Uni'
case 'linear-bipolar':
return 'Linear Bi'
}
}
const selectStyle: React.CSSProperties = {
backgroundColor: '#0a0a0a',
color: 'rgba(255, 255, 255, 0.8)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: '3px',
padding: '4px 6px',
fontSize: '11px',
fontFamily: 'Inter, sans-serif',
outline: 'none',
width: '100%',
function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind]): string {
switch (kind) {
case 'spectrum': {
const scopeSettings = settings as ScopeSettings['spectrum']
return `${scopeSettings.heatmap ? 'Heat' : 'Fill'} · FFT ${scopeSettings.fftSize}`
}
case 'oscilloscope': {
const scopeSettings = settings as ScopeSettings['oscilloscope']
const mode = scopeSettings.pitchLock ? 'Pitch Lock' : 'Free Run'
return scopeSettings.underfillEnabled ? `${mode} · Fill` : mode
}
case 'vectorscope': {
const scopeSettings = settings as ScopeSettings['vectorscope']
return scopeSettings.multiband
? `${vectorscopeModeLabel(scopeSettings.mode)} · RGB`
: vectorscopeModeLabel(scopeSettings.mode)
}
case 'spectrogram': {
const scopeSettings = settings as ScopeSettings['spectrogram']
return `${scopeSettings.scaleMode.toUpperCase()} · ${scopeSettings.clarityMode}`
}
case 'vumeter': {
const scopeSettings = settings as ScopeSettings['vumeter']
return `${scopeSettings.mode.toUpperCase()} · ${scopeSettings.orientation.toUpperCase()}`
}
case 'lufsmeter':
return 'Bar Meter'
case 'waveform': {
const scopeSettings = settings as ScopeSettings['waveform']
return scopeSettings.multiband
? `${scopeSettings.gainDb > 0 ? '+' : ''}${scopeSettings.gainDb} dB · RGB`
: `${scopeSettings.gainDb > 0 ? '+' : ''}${scopeSettings.gainDb} dB`
}
}
}
const checkboxRowStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '11px',
color: 'rgba(255, 255, 255, 0.7)',
fontFamily: 'Inter, sans-serif',
cursor: 'pointer',
function ToggleChip({
label,
active,
onClick,
}: {
label: string
active: boolean
onClick: () => void
}): JSX.Element {
return (
<button
type="button"
className={`settings-chip ${active ? 'is-active' : ''}`.trim()}
onClick={onClick}
>
{label}
</button>
)
}
function SelectControl({
label,
value,
children,
onChange,
}: {
label: string
value: string | number
children: ReactNode
onChange: (value: string) => void
}): JSX.Element {
return (
<label className="settings-control">
<span className="settings-control__label">{label}</span>
<select
className="settings-control__select"
value={value}
onChange={(event) => onChange(event.target.value)}
>
{children}
</select>
</label>
)
}
function RangeControl({
label,
value,
valueLabel,
min,
max,
step,
fullWidth = true,
disabled = false,
onChange,
}: {
label: string
value: number
valueLabel: string
min: number
max: number
step: number
fullWidth?: boolean
disabled?: boolean
onChange: (value: number) => void
}): JSX.Element {
return (
<label className={`settings-control ${fullWidth ? 'settings-control--full' : ''} ${disabled ? 'is-disabled' : ''}`.trim()}>
<span className="settings-control__label">
{label}
<span className="settings-control__value">{valueLabel}</span>
</span>
<input
className="settings-control__range"
type="range"
min={min}
max={max}
step={step}
value={value}
disabled={disabled}
onChange={(event) => onChange(Number(event.target.value))}
/>
</label>
)
}
function ScopeSettingsCard({
kind,
settings,
onUpdate,
}: {
kind: ScopeKind
settings: ScopeSettings
onUpdate: <K extends ScopeKind>(kind: K, partial: Partial<ScopeSettings[K]>) => void
}): JSX.Element {
const scopeSettings = settings[kind]
return (
<section className="settings-card">
<div className="settings-card__header">
<div className="settings-card__title">{SCOPE_LABELS[kind]}</div>
<div className="settings-card__summary">{scopeSummary(kind, scopeSettings)}</div>
</div>
<div className="settings-card__controls">
{kind === 'spectrum' && (() => {
const current = scopeSettings as ScopeSettings['spectrum']
return (
<>
<SelectControl
label="FFT Size"
value={current.fftSize}
onChange={(value) => onUpdate('spectrum', { fftSize: Number(value) })}
>
{[1024, 2048, 4096, 8192, 16384].map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</SelectControl>
<div className="settings-chip-row settings-control--full">
<ToggleChip
label="Fill"
active={current.fillGradient}
onClick={() => onUpdate('spectrum', { fillGradient: !current.fillGradient })}
/>
<ToggleChip
label="Heatmap"
active={current.heatmap}
onClick={() => onUpdate('spectrum', { heatmap: !current.heatmap })}
/>
<ToggleChip
label="Grid"
active={current.showGrid}
onClick={() => onUpdate('spectrum', { showGrid: !current.showGrid })}
/>
</div>
<RangeControl
label="Tilt"
value={current.tiltDbPerOctave}
valueLabel={`${current.tiltDbPerOctave.toFixed(1)} dB/oct`}
min={0}
max={6}
step={0.5}
fullWidth={false}
onChange={(value) => onUpdate('spectrum', { tiltDbPerOctave: value })}
/>
<RangeControl
label="Heat Tilt"
value={current.heatmapTiltDbPerOctave}
valueLabel={`${current.heatmapTiltDbPerOctave.toFixed(1)} dB/oct`}
min={0}
max={6}
step={0.5}
fullWidth={false}
disabled={!current.heatmap}
onChange={(value) => onUpdate('spectrum', { heatmapTiltDbPerOctave: value })}
/>
<RangeControl
label="Smoothing"
value={current.smoothing}
valueLabel={current.smoothing.toFixed(2)}
min={0}
max={0.99}
step={0.01}
fullWidth={false}
onChange={(value) => onUpdate('spectrum', { smoothing: value })}
/>
</>
)
})()}
{kind === 'oscilloscope' && (() => {
const current = scopeSettings as ScopeSettings['oscilloscope']
return (
<>
<div className="settings-chip-row settings-control--full">
<ToggleChip
label="Pitch Lock"
active={current.pitchLock}
onClick={() => onUpdate('oscilloscope', { pitchLock: !current.pitchLock })}
/>
<ToggleChip
label="Underfill"
active={current.underfillEnabled}
onClick={() => onUpdate('oscilloscope', { underfillEnabled: !current.underfillEnabled })}
/>
<ToggleChip
label="Grid"
active={current.showGrid}
onClick={() => onUpdate('oscilloscope', { showGrid: !current.showGrid })}
/>
</div>
<RangeControl
label="Line Width"
value={current.lineWidth}
valueLabel={`${current.lineWidth.toFixed(1)} px`}
min={0.5}
max={4}
step={0.5}
fullWidth={false}
onChange={(value) => onUpdate('oscilloscope', { lineWidth: value })}
/>
</>
)
})()}
{kind === 'vectorscope' && (() => {
const current = scopeSettings as ScopeSettings['vectorscope']
return (
<>
<SelectControl
label="Mode"
value={current.mode}
onChange={(value) => onUpdate('vectorscope', { mode: value as ScopeSettings['vectorscope']['mode'] })}
>
<option value="lissajous">Lissajous</option>
<option value="polar-unipolar">Polar (Uni)</option>
<option value="polar-bipolar">Polar (Bi)</option>
<option value="linear-unipolar">Linear (Uni)</option>
<option value="linear-bipolar">Linear (Bi)</option>
</SelectControl>
<div className="settings-chip-row settings-control--full">
<ToggleChip
label="RGB"
active={current.multiband}
onClick={() => onUpdate('vectorscope', { multiband: !current.multiband })}
/>
<ToggleChip
label="Grid"
active={current.showGrid}
onClick={() => onUpdate('vectorscope', { showGrid: !current.showGrid })}
/>
</div>
<RangeControl
label="Persistence"
value={current.persistence}
valueLabel={current.persistence.toFixed(2)}
min={0}
max={0.5}
step={0.01}
fullWidth={false}
onChange={(value) => onUpdate('vectorscope', { persistence: value })}
/>
<RangeControl
label="Line Width"
value={current.lineWidth}
valueLabel={`${current.lineWidth.toFixed(1)} px`}
min={0.5}
max={4}
step={0.5}
fullWidth={false}
onChange={(value) => onUpdate('vectorscope', { lineWidth: value })}
/>
</>
)
})()}
{kind === 'spectrogram' && (() => {
const current = scopeSettings as ScopeSettings['spectrogram']
return (
<>
<SelectControl
label="FFT Size"
value={current.fftSize}
onChange={(value) => onUpdate('spectrogram', { fftSize: Number(value) })}
>
{[512, 1024, 2048, 4096].map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</SelectControl>
<SelectControl
label="Scale"
value={current.scaleMode}
onChange={(value) => onUpdate('spectrogram', { scaleMode: value as ScopeSettings['spectrogram']['scaleMode'] })}
>
<option value="log">Log</option>
<option value="mel">Mel</option>
<option value="linear">Linear</option>
</SelectControl>
<SelectControl
label="Clarity"
value={current.clarityMode}
onChange={(value) => onUpdate('spectrogram', { clarityMode: value as ScopeSettings['spectrogram']['clarityMode'] })}
>
<option value="classic">Classic</option>
<option value="sharp">Sharp</option>
<option value="sharper">Sharper</option>
</SelectControl>
<SelectControl
label="Color"
value={current.colorScheme}
onChange={(value) => onUpdate('spectrogram', { colorScheme: value as ScopeSettings['spectrogram']['colorScheme'] })}
>
<option value="heat">Heat</option>
<option value="mono">Mono</option>
</SelectControl>
<RangeControl
label="Speed"
value={current.scrollSpeed}
valueLabel={`x${current.scrollSpeed.toFixed(0)}`}
min={1}
max={8}
step={1}
fullWidth={false}
onChange={(value) => onUpdate('spectrogram', { scrollSpeed: value })}
/>
</>
)
})()}
{kind === 'vumeter' && (() => {
const current = scopeSettings as ScopeSettings['vumeter']
return (
<>
<SelectControl
label="Mode"
value={current.mode}
onChange={(value) => onUpdate('vumeter', { mode: value as ScopeSettings['vumeter']['mode'] })}
>
<option value="bar">Bar</option>
<option value="needle">Needle</option>
</SelectControl>
<SelectControl
label="Orientation"
value={current.orientation}
onChange={(value) => onUpdate('vumeter', { orientation: value as ScopeSettings['vumeter']['orientation'] })}
>
<option value="horizontal">Horizontal</option>
<option value="vertical">Vertical</option>
</SelectControl>
</>
)
})()}
{kind === 'lufsmeter' && (() => {
const current = scopeSettings as ScopeSettings['lufsmeter']
return (
<SelectControl
label="Mode"
value={current.mode}
onChange={(value) => onUpdate('lufsmeter', { mode: value as ScopeSettings['lufsmeter']['mode'] })}
>
<option value="bar">Bar</option>
</SelectControl>
)
})()}
{kind === 'waveform' && (() => {
const current = scopeSettings as ScopeSettings['waveform']
return (
<>
<div className="settings-chip-row settings-control--full">
<ToggleChip
label="Multiband"
active={current.multiband}
onClick={() => onUpdate('waveform', { multiband: !current.multiband })}
/>
</div>
<RangeControl
label="Gain"
value={current.gainDb}
valueLabel={`${current.gainDb > 0 ? '+' : ''}${current.gainDb.toFixed(0)} dB`}
min={-12}
max={12}
step={1}
fullWidth={false}
onChange={(value) => onUpdate('waveform', { gainDb: value })}
/>
<RangeControl
label="Speed"
value={current.scrollSpeed}
valueLabel={`x${current.scrollSpeed.toFixed(0)}`}
min={1}
max={8}
step={1}
fullWidth={false}
onChange={(value) => onUpdate('waveform', { scrollSpeed: value })}
/>
</>
)
})()}
</div>
</section>
)
}
interface SettingsPanelProps {
onClose: () => void
onHeightChange?: (height: number) => void
}
export default function SettingsPanel({ onClose }: SettingsPanelProps): JSX.Element {
export default function SettingsPanel({ onClose, onHeightChange }: SettingsPanelProps): JSX.Element {
const {
devices,
selectedDeviceId,
@@ -66,386 +489,153 @@ export default function SettingsPanel({ onClose }: SettingsPanelProps): JSX.Elem
} = useAudioStore()
const { scopeSettings, updateScopeSettings, hiddenScopes, scopeOrder } = useSettingsStore()
const { presetId, accent, setPreset, setCustomAccent, customAccent } = useThemeStore()
const panelRef = useRef<HTMLDivElement | null>(null)
const visibleScopes = scopeOrder.filter((k) => !hiddenScopes.has(k))
const visibleScopes = scopeOrder.filter((kind) => !hiddenScopes.has(kind))
useEffect(() => {
refreshDevices()
}, [])
void refreshDevices()
}, [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 === '__system__') {
setCaptureMode('system')
await startCapture()
} else {
await selectDevice(value)
await startCapture()
return
}
await selectDevice(value)
await startCapture()
}
const indicatorColor = isCapturing
? '#22c55e'
: captureStatus === 'error'
? '#ef4444'
: '#71717a'
const indicatorLabel = isCapturing
? 'Capturing'
: captureStatus === 'connecting'
? 'Connecting...'
? 'Connecting'
: captureStatus === 'error'
? 'Capture Failed'
: 'Idle'
return (
<div
style={{
height: `${PANEL_HEIGHT}px`,
backgroundColor: '#050505',
borderTop: '1px solid rgba(255, 255, 255, 0.08)',
display: 'flex',
flexDirection: 'row',
overflow: 'hidden',
flexShrink: 0,
}}
>
{/* Audio Source section */}
<div
style={{
width: '200px',
padding: '12px',
borderRight: '1px solid rgba(255, 255, 255, 0.06)',
display: 'flex',
flexDirection: 'column',
gap: '10px',
flexShrink: 0,
}}
>
<div style={{ ...labelStyle, marginBottom: 0, fontSize: '10px', color: 'rgba(255, 255, 255, 0.55)' }}>
Audio Source
</div>
<div className="settings-panel" ref={panelRef}>
<div className="settings-panel__utility">
<section className="settings-utility-card">
<div className="settings-section-title">Audio Source</div>
<div>
<div style={labelStyle}>Source</div>
<select
value={captureMode === 'system' ? '__system__' : selectedDeviceId ?? ''}
onChange={(e) => {
void handleSourceChange(e.target.value)
}}
style={selectStyle}
>
<option value="__system__">System Audio</option>
<optgroup label="Devices">
{devices.map((d) => (
<option key={d.deviceId} value={d.deviceId}>
{d.label || `Input ${d.deviceId.slice(0, 8)}`}
</option>
))}
</optgroup>
</select>
</div>
<label className="settings-control settings-control--stack">
<span className="settings-control__label">Source</span>
<select
className="settings-control__select"
value={captureMode === 'system' ? '__system__' : selectedDeviceId ?? ''}
onChange={(event) => {
void handleSourceChange(event.target.value)
}}
>
<option value="__system__">System Audio</option>
<optgroup label="Devices">
{devices.map((device) => (
<option key={device.deviceId} value={device.deviceId}>
{device.label || `Input ${device.deviceId.slice(0, 8)}`}
</option>
))}
</optgroup>
</select>
</label>
{/* Signal indicator */}
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '10px', color: 'rgba(255, 255, 255, 0.5)' }}>
<div
style={{
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: indicatorColor,
boxShadow: isCapturing ? '0 0 6px rgba(34, 197, 94, 0.4)' : 'none',
}}
/>
{indicatorLabel}
</div>
{captureError ? (
<div style={{ fontSize: '10px', color: 'rgba(239, 68, 68, 0.8)', lineHeight: 1.4 }}>
{captureError}
<div className={`settings-status-pill is-${captureStatus}`.trim()}>
<span className="settings-status-pill__dot" />
<span>{indicatorLabel}</span>
</div>
) : null}
{/* Theme section */}
<div style={{ borderTop: '1px solid rgba(255, 255, 255, 0.06)', paddingTop: '10px', marginTop: '2px' }}>
<div style={{ ...labelStyle, marginBottom: '6px', fontSize: '10px', color: 'rgba(255, 255, 255, 0.55)' }}>
Theme
</div>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{captureError ? (
<div className="settings-error-text">{captureError}</div>
) : null}
</section>
<section className="settings-utility-card">
<div className="settings-section-title">Theme</div>
<div className="settings-theme-swatches">
{PRESET_IDS.map((id) => {
const p = PRESETS[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={p.name}
style={{
width: '18px',
height: '18px',
borderRadius: '50%',
backgroundColor: p.accent,
border: active ? '2px solid #fff' : '2px solid transparent',
cursor: 'pointer',
padding: 0,
outline: 'none',
transition: 'border-color 120ms',
}}
title={preset.name}
aria-label={preset.name}
/>
)
})}
</div>
<div style={{ marginTop: '6px' }}>
<div style={labelStyle}>Custom</div>
<input
type="color"
value={accent}
onChange={(e) => setCustomAccent(e.target.value)}
style={{
width: '100%',
height: '24px',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: '3px',
backgroundColor: '#0a0a0a',
cursor: 'pointer',
padding: '2px',
}}
/>
</div>
</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>
</div>
{/* Per-scope settings */}
<div
style={{
flex: 1,
padding: '12px',
overflowX: 'auto',
overflowY: 'hidden',
display: 'flex',
gap: '16px',
}}
>
<div className="settings-panel__scopes">
{visibleScopes.map((kind) => (
<ScopeSettingsColumn
<ScopeSettingsCard
key={kind}
kind={kind}
settings={scopeSettings}
onUpdate={updateScopeSettings}
accent={accent}
/>
))}
</div>
{/* Close button */}
<button
type="button"
className="settings-panel__close"
onClick={onClose}
style={{
position: 'absolute',
right: '8px',
bottom: '8px',
background: 'transparent',
border: '1px solid rgba(255, 255, 255, 0.1)',
color: 'rgba(255, 255, 255, 0.4)',
borderRadius: '3px',
padding: '2px 8px',
fontSize: '9px',
fontFamily: "'JetBrains Mono', monospace",
cursor: 'pointer',
textTransform: 'uppercase',
}}
>
Close
</button>
</div>
)
}
function ScopeSettingsColumn({ kind, settings, onUpdate, accent }: {
kind: ScopeKind
settings: ScopeSettings
onUpdate: <K extends ScopeKind>(kind: K, s: Partial<ScopeSettings[K]>) => void
accent: string
}): JSX.Element {
const s = settings[kind]
return (
<div style={{ minWidth: '140px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ ...labelStyle, fontSize: '10px', color: accent, marginBottom: 0 }}>
{SCOPE_LABELS[kind]}
</div>
{kind === 'spectrum' && (() => {
const ss = s as ScopeSettings['spectrum']
return (
<>
<div>
<div style={labelStyle}>FFT Size</div>
<select value={ss.fftSize} onChange={(e) => onUpdate('spectrum', { fftSize: Number(e.target.value) })} style={selectStyle}>
{[1024, 2048, 4096, 8192, 16384].map((v) => <option key={v} value={v}>{v}</option>)}
</select>
</div>
<div>
<div style={labelStyle}>Tilt (dB/oct)</div>
<input type="range" min="0" max="6" step="0.5" value={ss.tiltDbPerOctave} onChange={(e) => onUpdate('spectrum', { tiltDbPerOctave: Number(e.target.value) })} style={{ width: '100%' }} />
</div>
<label style={checkboxRowStyle}>
<input type="checkbox" checked={ss.fillGradient} onChange={(e) => onUpdate('spectrum', { fillGradient: e.target.checked })} />
Fill
</label>
<label style={checkboxRowStyle}>
<input type="checkbox" checked={ss.heatmap} onChange={(e) => onUpdate('spectrum', { heatmap: e.target.checked })} />
Heatmap
</label>
<label style={checkboxRowStyle}>
<input type="checkbox" checked={ss.showGrid} onChange={(e) => onUpdate('spectrum', { showGrid: e.target.checked })} />
Grid
</label>
</>
)
})()}
{kind === 'oscilloscope' && (() => {
const ss = s as ScopeSettings['oscilloscope']
return (
<>
<label style={checkboxRowStyle}>
<input type="checkbox" checked={ss.pitchLock} onChange={(e) => onUpdate('oscilloscope', { pitchLock: e.target.checked })} />
Pitch Lock
</label>
<label style={checkboxRowStyle}>
<input type="checkbox" checked={ss.showGrid} onChange={(e) => onUpdate('oscilloscope', { showGrid: e.target.checked })} />
Grid
</label>
<div>
<div style={labelStyle}>Line Width</div>
<input type="range" min="0.5" max="4" step="0.5" value={ss.lineWidth} onChange={(e) => onUpdate('oscilloscope', { lineWidth: Number(e.target.value) })} style={{ width: '100%' }} />
</div>
</>
)
})()}
{kind === 'vectorscope' && (() => {
const ss = s as ScopeSettings['vectorscope']
return (
<>
<div>
<div style={labelStyle}>Mode</div>
<select value={ss.mode} onChange={(e) => onUpdate('vectorscope', { mode: e.target.value as ScopeSettings['vectorscope']['mode'] })} style={selectStyle}>
<option value="lissajous">Lissajous</option>
<option value="polar-unipolar">Polar (Uni)</option>
<option value="polar-bipolar">Polar (Bi)</option>
<option value="linear-unipolar">Linear (Uni)</option>
<option value="linear-bipolar">Linear (Bi)</option>
</select>
</div>
<div>
<div style={labelStyle}>Persistence</div>
<input type="range" min="0" max="0.5" step="0.01" value={ss.persistence} onChange={(e) => onUpdate('vectorscope', { persistence: Number(e.target.value) })} style={{ width: '100%' }} />
</div>
<label style={checkboxRowStyle}>
<input type="checkbox" checked={ss.multiband} onChange={(e) => onUpdate('vectorscope', { multiband: e.target.checked })} />
Multiband
</label>
<label style={checkboxRowStyle}>
<input type="checkbox" checked={ss.showGrid} onChange={(e) => onUpdate('vectorscope', { showGrid: e.target.checked })} />
Grid
</label>
</>
)
})()}
{kind === 'spectrogram' && (() => {
const ss = s as ScopeSettings['spectrogram']
return (
<>
<div>
<div style={labelStyle}>FFT Size</div>
<select value={ss.fftSize} onChange={(e) => onUpdate('spectrogram', { fftSize: Number(e.target.value) })} style={selectStyle}>
{[512, 1024, 2048, 4096].map((v) => <option key={v} value={v}>{v}</option>)}
</select>
</div>
<div>
<div style={labelStyle}>Scale</div>
<select value={ss.scaleMode} onChange={(e) => onUpdate('spectrogram', { scaleMode: e.target.value as ScopeSettings['spectrogram']['scaleMode'] })} style={selectStyle}>
<option value="log">Log</option>
<option value="mel">Mel</option>
<option value="linear">Linear</option>
</select>
</div>
<div>
<div style={labelStyle}>Clarity</div>
<select value={ss.clarityMode} onChange={(e) => onUpdate('spectrogram', { clarityMode: e.target.value as ScopeSettings['spectrogram']['clarityMode'] })} style={selectStyle}>
<option value="classic">Classic</option>
<option value="sharp">Sharp</option>
<option value="sharper">Sharper</option>
</select>
</div>
<div>
<div style={labelStyle}>Color</div>
<select value={ss.colorScheme} onChange={(e) => onUpdate('spectrogram', { colorScheme: e.target.value as 'heat' | 'mono' })} style={selectStyle}>
<option value="heat">Heat</option>
<option value="mono">Mono</option>
</select>
</div>
<div>
<div style={labelStyle}>Speed</div>
<input type="range" min="1" max="8" step="1" value={ss.scrollSpeed} onChange={(e) => onUpdate('spectrogram', { scrollSpeed: Number(e.target.value) })} style={{ width: '100%' }} />
</div>
</>
)
})()}
{kind === 'vumeter' && (() => {
const ss = s as ScopeSettings['vumeter']
return (
<>
<div>
<div style={labelStyle}>Mode</div>
<select value={ss.mode} onChange={(e) => onUpdate('vumeter', { mode: e.target.value as ScopeSettings['vumeter']['mode'] })} style={selectStyle}>
<option value="bar">Bar</option>
<option value="needle">Needle</option>
</select>
</div>
<div>
<div style={labelStyle}>Orientation</div>
<select value={ss.orientation} onChange={(e) => onUpdate('vumeter', { orientation: e.target.value as ScopeSettings['vumeter']['orientation'] })} style={selectStyle}>
<option value="horizontal">Horizontal</option>
<option value="vertical">Vertical</option>
</select>
</div>
</>
)
})()}
{kind === 'lufsmeter' && (() => {
const ss = s as ScopeSettings['lufsmeter']
return (
<div>
<div style={labelStyle}>Mode</div>
<select value={ss.mode} onChange={(e) => onUpdate('lufsmeter', { mode: e.target.value as ScopeSettings['lufsmeter']['mode'] })} style={selectStyle}>
<option value="bar">Bar</option>
</select>
</div>
)
})()}
{kind === 'waveform' && (() => {
const ss = s as ScopeSettings['waveform']
return (
<>
<div>
<div style={labelStyle}>Gain (dB)</div>
<input type="range" min="-12" max="12" step="1" value={ss.gainDb} onChange={(e) => onUpdate('waveform', { gainDb: Number(e.target.value) })} style={{ width: '100%' }} />
</div>
<div>
<div style={labelStyle}>Speed</div>
<input type="range" min="1" max="8" step="1" value={ss.scrollSpeed} onChange={(e) => onUpdate('waveform', { scrollSpeed: Number(e.target.value) })} style={{ width: '100%' }} />
</div>
<label style={checkboxRowStyle}>
<input type="checkbox" checked={ss.multiband} onChange={(e) => onUpdate('waveform', { multiband: e.target.checked })} />
Multiband
</label>
</>
)
})()}
</div>
)
}
+4 -24
View File
@@ -1,4 +1,4 @@
import { Fragment, useCallback, useRef } from 'react'
import { Fragment, useCallback, useRef, type JSX } from 'react'
import { useSettingsStore } from '../stores/settingsStore'
import { useThemeStore } from '../stores/themeStore'
import type { ScopeKind } from '../../types/scope'
@@ -43,20 +43,8 @@ function ResizeHandle({ leftKind, rightKind }: { leftKind: ScopeKind; rightKind:
}, [leftKind, rightKind, setScopeWidthWeight])
return (
<div
ref={handleRef}
onMouseDown={onMouseDown}
style={{
width: '5px',
flexShrink: 0,
cursor: 'col-resize',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2,
}}
>
<div style={{ width: '1px', height: '100%', backgroundColor: 'rgba(255, 255, 255, 0.08)' }} />
<div ref={handleRef} onMouseDown={onMouseDown} className="scope-strip__handle">
<div className="scope-strip__handle-line" />
</div>
)
}
@@ -70,15 +58,7 @@ export default function Strip(): JSX.Element {
const visibleScopes = scopeOrder.filter((k) => !hiddenScopes.has(k))
return (
<div
style={{
display: 'flex',
flexDirection: 'row',
width: '100%',
height: '100%',
backgroundColor: 'var(--bg-primary)',
}}
>
<div className="scope-strip">
{visibleScopes.map((kind, i) => (
<Fragment key={kind}>
{i > 0 && (
+51 -92
View File
@@ -1,8 +1,7 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useState, useEffect, useCallback, type CSSProperties, type JSX } from 'react'
import type { ScopeKind } from '../../types/scope'
import { SCOPE_KINDS } from '../../types/scope'
import { useSettingsStore } from '../stores/settingsStore'
import { useThemeStore } from '../stores/themeStore'
const SCOPE_LABELS: Record<ScopeKind, string> = {
spectrum: 'SPEC',
@@ -14,12 +13,29 @@ const SCOPE_LABELS: Record<ScopeKind, string> = {
waveform: 'WAVE',
}
function hexToRgba(hex: string, alpha: number): string {
const h = hex.replace('#', '')
const r = parseInt(h.substring(0, 2), 16)
const g = parseInt(h.substring(2, 4), 16)
const b = parseInt(h.substring(4, 6), 16)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
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" />
</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>
)
}
function CloseIcon(): JSX.Element {
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M3.5 3.5 12.5 12.5M12.5 3.5 3.5 12.5" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg>
)
}
interface ToolbarProps {
@@ -28,18 +44,14 @@ interface ToolbarProps {
}
export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps): JSX.Element {
const hiddenScopes = useSettingsStore((s) => s.hiddenScopes)
const toggleScope = useSettingsStore((s) => s.toggleScope)
const accent = useThemeStore((s) => s.accent)
const hiddenScopes = useSettingsStore((state) => state.hiddenScopes)
const toggleScope = useSettingsStore((state) => state.toggleScope)
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(true)
const accentBg = useMemo(() => hexToRgba(accent, 0.15), [accent])
const accentBorder = useMemo(() => hexToRgba(accent, 0.3), [accent])
useEffect(() => {
window.electronAPI.isAlwaysOnTop().then(setIsAlwaysOnTop)
const unsub = window.electronAPI.onAlwaysOnTopChanged(setIsAlwaysOnTop)
return unsub
const unsubscribe = window.electronAPI.onAlwaysOnTopChanged(setIsAlwaysOnTop)
return unsubscribe
}, [])
const handlePin = useCallback(() => {
@@ -47,52 +59,24 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
}, [])
return (
<div
style={{
display: 'flex',
alignItems: 'center',
width: '100%',
height: '36px',
padding: '0 8px',
gap: '2px',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
backgroundColor: 'rgba(0, 0, 0, 0.6)',
borderBottom: '1px solid rgba(255, 255, 255, 0.06)',
}}
>
{/* Drag region */}
<div className="toolbar">
<div
style={{
WebkitAppRegion: 'drag',
flex: '0 0 40px',
height: '100%',
cursor: 'grab',
} as React.CSSProperties}
/>
className="toolbar__brand"
style={{ WebkitAppRegion: 'drag' } as CSSProperties}
>
<span className="toolbar__brand-mark" />
<span className="toolbar__brand-text">Prism</span>
</div>
{/* Scope toggles */}
<div style={{ display: 'flex', gap: '2px', flex: 1 }}>
<div className="toolbar__chips">
{SCOPE_KINDS.map((kind) => {
const active = !hiddenScopes.has(kind)
return (
<button
key={kind}
type="button"
className={`toolbar__chip ${active ? 'is-active' : ''}`.trim()}
onClick={() => toggleScope(kind)}
style={{
background: active ? accentBg : 'transparent',
border: `1px solid ${active ? accentBorder : 'rgba(255, 255, 255, 0.08)'}`,
borderRadius: '3px',
color: active ? accent : 'rgba(255, 255, 255, 0.35)',
fontSize: '9px',
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 400,
letterSpacing: '0.05em',
padding: '3px 6px',
cursor: 'pointer',
transition: 'all 120ms',
lineHeight: 1,
}}
>
{SCOPE_LABELS[kind]}
</button>
@@ -100,60 +84,35 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
})}
</div>
{/* Right side: settings, pin, close */}
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
<div className="toolbar__actions">
<button
type="button"
className={`toolbar__icon-button ${settingsOpen ? 'is-active' : ''}`.trim()}
onClick={onOpenSettings}
style={{
background: settingsOpen ? accentBg : 'transparent',
border: 'none',
color: settingsOpen ? accent : 'rgba(255, 255, 255, 0.5)',
fontSize: '14px',
cursor: 'pointer',
padding: '4px',
borderRadius: '3px',
lineHeight: 1,
transition: 'color 120ms',
}}
title="Settings"
aria-label="Settings"
>
<SettingsIcon />
</button>
<button
type="button"
className={`toolbar__icon-button ${isAlwaysOnTop ? 'is-active' : ''}`.trim()}
onClick={handlePin}
style={{
background: isAlwaysOnTop ? accentBg : 'transparent',
border: 'none',
color: isAlwaysOnTop ? accent : 'rgba(255, 255, 255, 0.5)',
fontSize: '12px',
cursor: 'pointer',
padding: '4px',
borderRadius: '3px',
lineHeight: 1,
transition: 'color 120ms',
}}
title={isAlwaysOnTop ? 'Unpin from top' : 'Pin to top'}
aria-label={isAlwaysOnTop ? 'Unpin from top' : 'Pin to top'}
>
📌
<PinIcon />
</button>
<button
type="button"
className="toolbar__icon-button toolbar__icon-button--danger"
onClick={() => window.electronAPI.close()}
style={{
background: 'transparent',
border: 'none',
color: 'rgba(255, 255, 255, 0.5)',
fontSize: '12px',
cursor: 'pointer',
padding: '4px',
borderRadius: '3px',
lineHeight: 1,
transition: 'color 120ms',
}}
title="Close"
aria-label="Close"
>
<CloseIcon />
</button>
</div>
</div>
+1
View File
@@ -14,6 +14,7 @@ declare global {
getDesktopSources: () => Promise<{ id: string; name: string }[]>
expandSettings: (panelHeight: number) => void
collapseSettings: (panelHeight: number) => void
setSettingsHeight: (panelHeight: number) => void
onAlwaysOnTopChanged: (callback: (isOnTop: boolean) => void) => () => void
onToggleScope: (callback: (index: number) => void) => () => void
onToggleCapture: (callback: () => void) => () => void
+528 -5
View File
@@ -4,14 +4,22 @@
--bg-primary: #000000;
--bg-secondary: #050505;
--bg-tertiary: #0a0a0a;
--panel-surface: rgba(8, 11, 16, 0.92);
--panel-surface-soft: rgba(12, 16, 22, 0.84);
--panel-outline: rgba(255, 255, 255, 0.09);
--panel-outline-strong: rgba(255, 255, 255, 0.16);
--panel-shadow: 0 14px 34px rgba(0, 0, 0, 0.34);
--glass-bg: rgba(255, 255, 255, 0.03);
--glass-border: rgba(255, 255, 255, 0.08);
--glass-highlight: rgba(255, 255, 255, 0.045);
--text-primary: rgba(255, 255, 255, 0.95);
--text-secondary: rgba(255, 255, 255, 0.6);
--text-tertiary: rgba(255, 255, 255, 0.4);
--text-secondary: rgba(255, 255, 255, 0.62);
--text-tertiary: rgba(255, 255, 255, 0.42);
--text-muted: rgba(255, 255, 255, 0.3);
--danger: #f87171;
--success: #22c55e;
--accent: #38bdf8;
--accent-hover: #7dd3fc;
@@ -25,19 +33,534 @@
box-sizing: border-box;
}
html, body, #root {
html,
body,
#root {
width: 100%;
height: 100%;
overflow: hidden;
background-color: var(--bg-primary);
background:
radial-gradient(circle at top, rgba(var(--accent-rgb), 0.09), transparent 28%),
linear-gradient(180deg, #040506 0%, #000000 24%, #000000 100%);
color: var(--text-primary);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
-webkit-font-smoothing: antialiased;
}
/* Scrollbar styling */
button,
input,
select {
font: inherit;
}
.prism-app {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
position: relative;
}
.prism-toolbar-layer {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
opacity: 0;
pointer-events: none;
transition: opacity 150ms ease;
}
.prism-toolbar-layer.is-visible {
opacity: 1;
pointer-events: auto;
}
.prism-strip-region {
flex: 1;
min-height: 0;
}
.toolbar {
display: flex;
align-items: center;
width: 100%;
min-height: 38px;
padding: 4px 10px 0;
gap: 10px;
background:
linear-gradient(180deg, rgba(0, 0, 0, 0.78), rgba(0, 0, 0, 0.58));
backdrop-filter: blur(16px) saturate(1.05);
-webkit-backdrop-filter: blur(16px) saturate(1.05);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.22);
}
.toolbar__brand {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 86px;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.025);
}
.toolbar__brand-mark {
width: 6px;
height: 6px;
border-radius: 999px;
background: rgba(var(--accent-rgb), 0.92);
box-shadow: 0 0 10px rgba(var(--accent-rgb), 0.32);
}
.toolbar__brand-text,
.toolbar__chip,
.toolbar__icon-button,
.settings-section-title,
.settings-card__title,
.settings-card__summary,
.settings-control__label,
.settings-chip,
.settings-status-pill,
.settings-panel__close {
font-family: 'JetBrains Mono', monospace;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.toolbar__brand-text {
color: rgba(255, 255, 255, 0.72);
font-size: 10px;
}
.toolbar__chips {
display: flex;
gap: 4px;
flex: 1;
min-width: 0;
}
.toolbar__chip {
min-height: 28px;
padding: 0 9px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: transparent;
color: var(--text-tertiary);
font-size: 9px;
cursor: pointer;
transition:
color 120ms ease,
border-color 120ms ease,
background-color 120ms ease,
transform 120ms ease;
}
.toolbar__chip:hover {
color: rgba(255, 255, 255, 0.78);
border-color: rgba(255, 255, 255, 0.14);
}
.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);
}
.toolbar__actions {
display: flex;
align-items: center;
gap: 6px;
}
.toolbar__icon-button {
width: 28px;
height: 28px;
border-radius: 999px;
border: 1px solid transparent;
background: transparent;
color: rgba(255, 255, 255, 0.56);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition:
color 120ms ease,
border-color 120ms ease,
background-color 120ms ease,
transform 120ms ease;
}
.toolbar__icon-button svg {
width: 14px;
height: 14px;
}
.toolbar__icon-button:hover {
color: rgba(255, 255, 255, 0.82);
border-color: rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
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);
}
.toolbar__icon-button--danger:hover {
color: var(--danger);
border-color: rgba(248, 113, 113, 0.22);
background: rgba(248, 113, 113, 0.1);
}
.scope-strip {
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
background-color: var(--bg-primary);
}
.scope-strip__handle {
width: 6px;
flex-shrink: 0;
cursor: col-resize;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.scope-strip__handle-line {
width: 1px;
height: 100%;
background: linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.12), transparent);
}
.settings-panel {
background: linear-gradient(180deg, rgba(6, 8, 11, 0.98), rgba(4, 5, 7, 0.98));
border-top: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
flex-direction: row;
overflow: hidden;
flex-shrink: 0;
position: relative;
padding-bottom: 12px;
}
.settings-panel__utility {
width: 224px;
padding: 12px;
border-right: 1px solid rgba(255, 255, 255, 0.06);
display: flex;
flex-direction: column;
gap: 12px;
flex-shrink: 0;
}
.settings-utility-card,
.settings-card {
border-radius: 16px;
border: 1px solid var(--panel-outline);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.018), rgba(255, 255, 255, 0.008)),
var(--panel-surface-soft);
box-shadow: var(--panel-shadow);
}
.settings-utility-card {
padding: 12px;
}
.settings-section-title {
color: rgba(255, 255, 255, 0.72);
font-size: 10px;
}
.settings-panel__scopes {
flex: 1;
min-width: 0;
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(240px, 1fr);
gap: 12px;
padding: 12px 12px 36px;
overflow-x: auto;
overflow-y: visible;
align-items: start;
}
.settings-card {
min-width: 240px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.settings-card__header {
display: flex;
flex-direction: column;
gap: 4px;
}
.settings-card__title {
color: var(--accent);
font-size: 10px;
}
.settings-card__summary {
color: var(--text-tertiary);
font-size: 9px;
letter-spacing: 0.08em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.settings-card__controls {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 12px;
align-content: start;
}
.settings-control {
display: flex;
flex-direction: column;
gap: 7px;
min-width: 0;
}
.settings-control--full,
.settings-control--stack {
grid-column: 1 / -1;
}
.settings-control.is-disabled {
opacity: 0.46;
}
.settings-control__label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--text-secondary);
font-size: 9px;
}
.settings-control__value {
color: var(--text-tertiary);
letter-spacing: 0.06em;
}
.settings-control__select {
width: 100%;
min-height: 32px;
padding: 0 12px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: var(--panel-surface);
color: rgba(255, 255, 255, 0.86);
font-size: 11px;
outline: none;
transition: border-color 140ms ease, background-color 140ms ease;
}
.settings-control__select:hover,
.settings-control__select:focus {
border-color: rgba(255, 255, 255, 0.16);
background: rgba(10, 14, 20, 0.98);
}
.settings-control__range {
width: 100%;
height: 18px;
margin: 0;
appearance: none;
background: transparent;
}
.settings-control__range::-webkit-slider-runnable-track {
height: 3px;
border-radius: 999px;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.12), rgba(var(--accent-rgb), 0.36));
}
.settings-control__range::-webkit-slider-thumb {
appearance: none;
width: 12px;
height: 12px;
margin-top: -4.5px;
border-radius: 50%;
border: 1px solid rgba(var(--accent-rgb), 0.5);
background: rgba(7, 10, 14, 0.98);
box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.14);
}
.settings-control__range::-moz-range-track {
height: 3px;
border-radius: 999px;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.12), rgba(var(--accent-rgb), 0.36));
}
.settings-control__range::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
border: 1px solid rgba(var(--accent-rgb), 0.5);
background: rgba(7, 10, 14, 0.98);
box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.14);
}
.settings-chip-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.settings-chip {
min-height: 30px;
padding: 0 11px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.03);
color: var(--text-secondary);
font-size: 10px;
cursor: pointer;
transition:
border-color 140ms ease,
color 140ms ease,
background-color 140ms ease,
transform 140ms ease;
}
.settings-chip:hover {
transform: translateY(-1px);
color: rgba(255, 255, 255, 0.82);
border-color: rgba(255, 255, 255, 0.16);
}
.settings-chip.is-active {
color: var(--accent);
border-color: rgba(var(--accent-rgb), 0.28);
background: rgba(var(--accent-rgb), 0.12);
}
.settings-status-pill {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 30px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
color: var(--text-secondary);
font-size: 10px;
}
.settings-status-pill__dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.26);
}
.settings-status-pill.is-capturing .settings-status-pill__dot {
background: var(--success);
box-shadow: 0 0 8px rgba(34, 197, 94, 0.4);
}
.settings-status-pill.is-error .settings-status-pill__dot {
background: var(--danger);
box-shadow: 0 0 8px rgba(248, 113, 113, 0.36);
}
.settings-error-text {
margin-top: 8px;
color: rgba(248, 113, 113, 0.88);
font-size: 11px;
line-height: 1.4;
}
.settings-theme-swatches {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.settings-swatch {
width: 18px;
height: 18px;
border-radius: 999px;
border: 2px solid transparent;
background: var(--swatch-color);
cursor: pointer;
transition: transform 120ms ease, border-color 120ms ease;
}
.settings-swatch:hover {
transform: translateY(-1px);
}
.settings-swatch.is-active {
border-color: rgba(255, 255, 255, 0.9);
}
.settings-accent-row {
display: flex;
align-items: center;
gap: 8px;
}
.settings-accent-input {
flex: 1;
min-width: 0;
height: 32px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: var(--panel-surface);
cursor: pointer;
padding: 4px;
}
.settings-panel__close {
position: absolute;
right: 12px;
bottom: 10px;
min-height: 28px;
padding: 0 12px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.03);
color: var(--text-tertiary);
font-size: 9px;
cursor: pointer;
transition:
border-color 140ms ease,
color 140ms ease,
background-color 140ms ease;
}
.settings-panel__close:hover {
color: rgba(255, 255, 255, 0.82);
border-color: rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.05);
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {