Files
prism/src/shared/themeState.ts
T

1512 lines
48 KiB
TypeScript

import {
DEFAULT_THEME_NAME,
LEGACY_THEME_MIGRATION_VERSION,
THEME_FILE_FORMAT,
THEME_FILE_VERSION,
THEME_LOCAL_STATE_FORMAT,
THEME_LOCAL_STATE_VERSION,
type LegacyThemeMigrationPayload,
type PrismResolvedTheme,
type PrismTheme,
type PrismThemeLocalStateV1,
type ResolvedNowPlayingTheme,
type ResolvedInterfaceTheme,
type ResolvedLUFSMeterTheme,
type ResolvedOscilloscopeTheme,
type ResolvedSpectrogramTheme,
type ResolvedSpectrumTheme,
type ResolvedVectorscopeTheme,
type ResolvedVUMeterTheme,
type ResolvedWaveformTheme,
type ThemeAppTokens,
type ThemeControlsTokens,
type ThemeLUFSMeterTokens,
type ThemeOscilloscopeTokens,
type ThemeScopesTokens,
type ThemeSectionName,
type ThemeSpectrogramTokens,
type ThemeSpectrumTokens,
type ThemeVectorscopeTokens,
type ThemeVUMeterTokens,
type ThemeWaveformTokens,
type ThemeNowPlayingTokens,
} from '../types/theme'
const DEFAULT_ACCENT = '#38bdf8'
const DEFAULT_SUCCESS = '#22c55e'
const DEFAULT_WARNING = 'rgb(255, 191, 0)'
const DEFAULT_DANGER = '#f87171'
const DEFAULT_SCOPE_GUIDES = 'rgba(255, 255, 255, 0.1)'
const DEFAULT_SCOPE_OVERLAY_BG = 'rgba(8, 12, 18, 0.88)'
const DEFAULT_SCOPE_OVERLAY_TEXT = 'rgba(255, 255, 255, 0.76)'
const DEFAULT_SCOPE_OVERLAY_BORDER = 'rgba(255, 255, 255, 0.12)'
const DEFAULT_SCOPE_RESIZE_HANDLE = 'rgba(255, 255, 255, 0.12)'
const DEFAULT_BAND_LOW = '#ff4444'
const DEFAULT_BAND_MID = '#44dd44'
const DEFAULT_BAND_HIGH = '#4488ff'
const DEFAULT_HEAT_LOW = 'rgb(15, 7, 33)'
const DEFAULT_HEAT_MID = 'rgb(163, 26, 121)'
const DEFAULT_HEAT_HIGH = 'rgb(255, 241, 209)'
type SectionTokenMap = Partial<Record<string, string>>
type SectionSchema<T extends object> = Record<string, Extract<keyof T, string>>
const APP_SCHEMA = {
accent: 'accent',
success: 'success',
warning: 'warning',
danger: 'danger',
background: 'background',
surface: 'surface',
surface_alt: 'surfaceAlt',
border: 'border',
text: 'text',
text_muted: 'textMuted',
toolbar_bg: 'toolbarBg',
settings_bg_top: 'settingsBgTop',
settings_bg_bottom: 'settingsBgBottom',
bottom_bar_bg: 'bottomBarBg',
} as const satisfies SectionSchema<ThemeAppTokens>
const CONTROLS_SCHEMA = {
surface: 'surface',
surface_hover: 'surfaceHover',
surface_active: 'surfaceActive',
border: 'border',
border_active: 'borderActive',
text: 'text',
input_surface: 'inputSurface',
input_border: 'inputBorder',
menu_surface: 'menuSurface',
menu_border: 'menuBorder',
slider: 'slider',
flat_controls: 'flatControls',
} as const satisfies SectionSchema<ThemeControlsTokens>
const SCOPES_SCHEMA = {
background: 'background',
guides: 'guides',
overlay_surface: 'overlaySurface',
overlay_text: 'overlayText',
overlay_border: 'overlayBorder',
resize_handle: 'resizeHandle',
} as const satisfies SectionSchema<ThemeScopesTokens>
const SPECTRUM_SCHEMA = {
background: 'background',
line: 'line',
side_line: 'sideLine',
fill: 'fill',
heat_low: 'heatLow',
heat_mid: 'heatMid',
heat_high: 'heatHigh',
heat_base: 'heatBase',
guides: 'guides',
labels: 'labels',
} as const satisfies SectionSchema<ThemeSpectrumTokens>
const OSCILLOSCOPE_SCHEMA = {
background: 'background',
line: 'line',
fill: 'fill',
guides: 'guides',
} as const satisfies SectionSchema<ThemeOscilloscopeTokens>
const VECTORSCOPE_SCHEMA = {
background: 'background',
trace: 'trace',
band_low: 'bandLow',
band_mid: 'bandMid',
band_high: 'bandHigh',
guides: 'guides',
labels: 'labels',
} as const satisfies SectionSchema<ThemeVectorscopeTokens>
const SPECTROGRAM_SCHEMA = {
background: 'background',
mono: 'mono',
heat_low: 'heatLow',
heat_mid: 'heatMid',
heat_high: 'heatHigh',
} as const satisfies SectionSchema<ThemeSpectrogramTokens>
const VUMETER_SCHEMA = {
background: 'background',
level: 'level',
track: 'track',
peak: 'peak',
clip: 'clip',
scale: 'scale',
labels: 'labels',
} as const satisfies SectionSchema<ThemeVUMeterTokens>
const LUFSMETER_SCHEMA = {
background: 'background',
level: 'level',
track: 'track',
target: 'target',
scale: 'scale',
labels: 'labels',
} as const satisfies SectionSchema<ThemeLUFSMeterTokens>
const WAVEFORM_SCHEMA = {
background: 'background',
line: 'line',
band_low: 'bandLow',
band_mid: 'bandMid',
band_high: 'bandHigh',
guides: 'guides',
} as const satisfies SectionSchema<ThemeWaveformTokens>
const NOW_PLAYING_SCHEMA = {
accent: 'accent',
background: 'background',
surface: 'surface',
border: 'border',
text: 'text',
progress_track: 'progressTrack',
progress_fill: 'progressFill',
button_surface: 'buttonSurface',
button_border: 'buttonBorder',
status_ok: 'statusOk',
status_error: 'statusError',
} as const satisfies SectionSchema<ThemeNowPlayingTokens>
const SECTION_KEY_MAP: Record<string, ThemeSectionName> = {
app: 'app',
controls: 'controls',
scopes: 'scopes',
spectrum: 'spectrum',
oscilloscope: 'oscilloscope',
vectorscope: 'vectorscope',
spectrogram: 'spectrogram',
vumeter: 'vumeter',
lufsmeter: 'lufsmeter',
waveform: 'waveform',
now_playing: 'nowPlaying',
astra: 'nowPlaying',
}
const SECTION_LABEL_MAP: Record<ThemeSectionName, string> = {
app: 'App',
controls: 'Controls',
scopes: 'Scopes',
spectrum: 'Spectrum',
oscilloscope: 'Oscilloscope',
vectorscope: 'Vectorscope',
spectrogram: 'Spectrogram',
vumeter: 'VUMeter',
lufsmeter: 'LUFSMeter',
waveform: 'Waveform',
nowPlaying: 'Now Playing',
}
const SECTION_SCHEMAS = {
app: APP_SCHEMA,
controls: CONTROLS_SCHEMA,
scopes: SCOPES_SCHEMA,
spectrum: SPECTRUM_SCHEMA,
oscilloscope: OSCILLOSCOPE_SCHEMA,
vectorscope: VECTORSCOPE_SCHEMA,
spectrogram: SPECTROGRAM_SCHEMA,
vumeter: VUMETER_SCHEMA,
lufsmeter: LUFSMETER_SCHEMA,
waveform: WAVEFORM_SCHEMA,
nowPlaying: NOW_PLAYING_SCHEMA,
} as const
interface RgbaColor {
r: number
g: number
b: number
a: number
}
function clampByte(value: number): number {
return Math.max(0, Math.min(255, Math.round(value)))
}
function clampAlpha(value: number): number {
return Math.max(0, Math.min(1, value))
}
function normalizeKey(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^\w]+/g, '_')
.replace(/^_+|_+$/g, '')
}
function parseByte(token: string): number | null {
const value = Number.parseFloat(token.trim())
if (!Number.isFinite(value)) return null
return clampByte(value)
}
function parseThemeChannelColor(value: string): RgbaColor | null {
const parts = value.split(',').map((token) => token.trim()).filter(Boolean)
if (parts.length < 3 || parts.length > 4) return null
const r = parseByte(parts[0])
const g = parseByte(parts[1])
const b = parseByte(parts[2])
if (r === null || g === null || b === null) return null
const a = parts.length === 4 ? parseByte(parts[3]) : 255
if (a === null) return null
return {
r,
g,
b,
a: clampAlpha(a / 255),
}
}
function parseCssToken(token: string): number | null {
const trimmed = token.trim()
if (!trimmed) return null
if (trimmed.endsWith('%')) {
const percent = Number.parseFloat(trimmed.slice(0, -1))
if (!Number.isFinite(percent)) return null
return clampByte((percent / 100) * 255)
}
const value = Number.parseFloat(trimmed)
if (!Number.isFinite(value)) return null
return clampByte(value)
}
function parseCssAlpha(token: string): number | null {
const trimmed = token.trim()
if (!trimmed) return null
if (trimmed.endsWith('%')) {
const percent = Number.parseFloat(trimmed.slice(0, -1))
if (!Number.isFinite(percent)) return null
return clampAlpha(percent / 100)
}
const value = Number.parseFloat(trimmed)
if (!Number.isFinite(value)) return null
return value > 1 ? clampAlpha(value / 255) : clampAlpha(value)
}
function parseCssColor(value: string): RgbaColor | null {
const normalized = value.trim()
if (!normalized) return null
if (normalized.startsWith('#')) {
const raw = normalized.slice(1)
const expanded = raw.length === 3 || raw.length === 4
? raw.split('').map((part) => `${part}${part}`).join('')
: raw
if (expanded.length !== 6 && expanded.length !== 8) return null
const r = Number.parseInt(expanded.slice(0, 2), 16)
const g = Number.parseInt(expanded.slice(2, 4), 16)
const b = Number.parseInt(expanded.slice(4, 6), 16)
const a = expanded.length === 8
? Number.parseInt(expanded.slice(6, 8), 16) / 255
: 1
if ([r, g, b].some((channel) => Number.isNaN(channel))) return null
return { r, g, b, a: clampAlpha(a) }
}
const match = /^rgba?\((.*)\)$/i.exec(normalized)
if (!match) return null
const body = match[1]?.trim() ?? ''
if (!body) return null
const [colorPart, alphaPart] = body.includes('/')
? body.split('/', 2)
: [body, undefined]
const colorTokens = colorPart.includes(',')
? colorPart.split(',').map((token) => token.trim())
: colorPart.split(/\s+/).filter(Boolean)
if (colorTokens.length < 3) return null
const r = parseCssToken(colorTokens[0])
const g = parseCssToken(colorTokens[1])
const b = parseCssToken(colorTokens[2])
if (r === null || g === null || b === null) return null
const rawAlpha = alphaPart ?? colorTokens[3]
const a = rawAlpha ? parseCssAlpha(rawAlpha) : 1
if (a === null) return null
return { r, g, b, a }
}
function getPerceivedBrightness(color: RgbaColor): number {
return ((color.r * 299) + (color.g * 587) + (color.b * 114)) / 1000
}
function toCssColor(color: RgbaColor): string {
if (Math.abs(color.a - 1) < 0.001) {
return `rgb(${color.r}, ${color.g}, ${color.b})`
}
return `rgba(${color.r}, ${color.g}, ${color.b}, ${Number(color.a.toFixed(3))})`
}
function quantizeThemeColor(color: RgbaColor): RgbaColor {
return {
r: clampByte(color.r),
g: clampByte(color.g),
b: clampByte(color.b),
a: clampAlpha(clampByte(color.a * 255) / 255),
}
}
function toThemeChannels(color: string): string {
const parsed = parseCssColor(color)
if (!parsed) return '0, 0, 0'
if (Math.abs(parsed.a - 1) < 0.001) {
return `${parsed.r}, ${parsed.g}, ${parsed.b}`
}
return `${parsed.r}, ${parsed.g}, ${parsed.b}, ${clampByte(parsed.a * 255)}`
}
function withAlpha(color: string, alpha: number): string {
const parsed = parseCssColor(color)
if (!parsed) return color
return toCssColor({ ...parsed, a: clampAlpha(alpha) })
}
function multiplyAlpha(color: string, factor: number): string {
const parsed = parseCssColor(color)
if (!parsed) return color
return toCssColor({ ...parsed, a: clampAlpha(parsed.a * factor) })
}
function mixColors(left: string, right: string, amount: number): string {
const leftColor = parseCssColor(left)
const rightColor = parseCssColor(right)
if (!leftColor) return right
if (!rightColor) return left
const t = clampAlpha(amount)
return toCssColor({
r: clampByte(leftColor.r + (rightColor.r - leftColor.r) * t),
g: clampByte(leftColor.g + (rightColor.g - leftColor.g) * t),
b: clampByte(leftColor.b + (rightColor.b - leftColor.b) * t),
a: clampAlpha(leftColor.a + (rightColor.a - leftColor.a) * t),
})
}
function lighten(color: string, amount: number): string {
return mixColors(color, 'rgb(255, 255, 255)', amount)
}
function darken(color: string, amount: number): string {
return mixColors(color, 'rgb(0, 0, 0)', amount)
}
function colorToRgbChannels(color: string): string {
const parsed = parseCssColor(color)
if (!parsed) return '0, 0, 0'
return `${parsed.r}, ${parsed.g}, ${parsed.b}`
}
function blendText(primary: string, muted: string, amount: number): string {
return mixColors(primary, muted, clampAlpha(amount))
}
function createEmptyTheme(): PrismTheme {
return {
name: DEFAULT_THEME_NAME,
app: {},
controls: {},
scopes: {},
spectrum: {},
oscilloscope: {},
vectorscope: {},
spectrogram: {},
vumeter: {},
lufsmeter: {},
waveform: {},
nowPlaying: {},
}
}
const PASSTHROUGH_KEYS: ReadonlySet<string> = new Set(['flatControls'])
function normalizeSectionTokens<T extends SectionTokenMap>(
raw: unknown,
schema: SectionSchema<T>,
): T {
if (typeof raw !== 'object' || raw === null) {
return {} as T
}
const parsed = raw as Record<string, unknown>
const next = {} as T
for (const [rawKey, rawValue] of Object.entries(parsed)) {
if (typeof rawValue !== 'string') continue
const key = Object.entries(schema).find(([serializedKey, propertyKey]) => {
return propertyKey === rawKey || serializedKey === normalizeKey(rawKey)
})?.[1] as keyof T | undefined
if (!key) continue
if (PASSTHROUGH_KEYS.has(key as string)) {
next[key] = rawValue as T[typeof key]
continue
}
const parsedColor = parseCssColor(rawValue) ?? parseThemeChannelColor(rawValue)
if (!parsedColor) continue
next[key] = toCssColor(quantizeThemeColor(parsedColor)) as T[typeof key]
}
return next
}
export function createDefaultTheme(): PrismTheme {
return normalizeTheme({
name: DEFAULT_THEME_NAME,
credit: 'Prism',
app: {
accent: DEFAULT_ACCENT,
success: DEFAULT_SUCCESS,
warning: DEFAULT_WARNING,
danger: DEFAULT_DANGER,
background: 'rgb(0, 0, 0)',
surface: 'rgba(8, 11, 16, 0.92)',
surfaceAlt: 'rgba(4, 8, 12, 0.98)',
border: 'rgba(255, 255, 255, 0.09)',
text: 'rgb(255, 255, 255)',
textMuted: 'rgba(255, 255, 255, 0.42)',
},
controls: {
surface: 'rgba(255, 255, 255, 0.03)',
surfaceHover: 'rgba(255, 255, 255, 0.06)',
surfaceActive: 'rgba(56, 189, 248, 0.12)',
border: 'rgba(255, 255, 255, 0.08)',
borderActive: 'rgba(56, 189, 248, 0.28)',
text: 'rgba(255, 255, 255, 0.92)',
inputSurface: 'rgba(10, 14, 20, 0.96)',
inputBorder: 'rgba(255, 255, 255, 0.09)',
menuSurface: 'rgba(8, 11, 16, 0.96)',
menuBorder: 'rgba(255, 255, 255, 0.1)',
slider: 'rgba(56, 189, 248, 0.82)',
},
scopes: {
background: 'rgb(0, 0, 0)',
guides: DEFAULT_SCOPE_GUIDES,
overlaySurface: DEFAULT_SCOPE_OVERLAY_BG,
overlayText: DEFAULT_SCOPE_OVERLAY_TEXT,
overlayBorder: DEFAULT_SCOPE_OVERLAY_BORDER,
resizeHandle: DEFAULT_SCOPE_RESIZE_HANDLE,
},
spectrum: {
line: DEFAULT_ACCENT,
sideLine: 'rgba(56, 189, 248, 0.5)',
fill: 'rgba(56, 189, 248, 0.34)',
heatLow: DEFAULT_HEAT_LOW,
heatMid: DEFAULT_HEAT_MID,
heatHigh: DEFAULT_HEAT_HIGH,
},
oscilloscope: {
line: DEFAULT_ACCENT,
fill: 'rgba(245, 248, 252, 0.18)',
},
vectorscope: {
trace: DEFAULT_ACCENT,
bandLow: DEFAULT_BAND_LOW,
bandMid: DEFAULT_BAND_MID,
bandHigh: DEFAULT_BAND_HIGH,
},
spectrogram: {
mono: DEFAULT_ACCENT,
heatLow: DEFAULT_HEAT_LOW,
heatMid: DEFAULT_HEAT_MID,
heatHigh: DEFAULT_HEAT_HIGH,
},
vumeter: {
level: DEFAULT_ACCENT,
track: 'rgba(56, 189, 248, 0.08)',
peak: 'rgb(255, 127, 0)',
clip: 'rgba(255, 120, 80, 0.9)',
},
lufsmeter: {
level: DEFAULT_ACCENT,
track: 'rgba(56, 189, 248, 0.08)',
target: 'rgba(56, 189, 248, 0.25)',
},
waveform: {
line: DEFAULT_ACCENT,
bandLow: DEFAULT_BAND_LOW,
bandMid: DEFAULT_BAND_MID,
bandHigh: DEFAULT_BAND_HIGH,
},
nowPlaying: {
accent: DEFAULT_ACCENT,
background: 'rgba(4, 8, 12, 0.9)',
surface: 'rgba(10, 16, 24, 0.92)',
border: 'rgba(255, 255, 255, 0.12)',
text: 'rgb(248, 250, 252)',
progressTrack: 'rgba(255, 255, 255, 0.18)',
progressFill: DEFAULT_ACCENT,
buttonSurface: 'rgba(255, 255, 255, 0.05)',
buttonBorder: 'rgba(255, 255, 255, 0.12)',
statusOk: DEFAULT_SUCCESS,
statusError: DEFAULT_DANGER,
},
}, DEFAULT_THEME_NAME)
}
function cloneTheme(theme: PrismTheme): PrismTheme {
return JSON.parse(JSON.stringify(theme)) as PrismTheme
}
function createPresetTheme(name: string, accent: string): PrismTheme {
const base = cloneTheme(createDefaultTheme())
base.name = name
base.app.accent = accent
base.controls.surfaceActive = withAlpha(accent, 0.12)
base.controls.borderActive = withAlpha(accent, 0.28)
base.controls.slider = withAlpha(accent, 0.82)
base.spectrum.line = accent
base.spectrum.sideLine = withAlpha(accent, 0.5)
base.spectrum.fill = withAlpha(accent, 0.34)
base.oscilloscope.line = accent
base.vectorscope.trace = accent
base.spectrogram.mono = accent
base.vumeter.level = accent
base.vumeter.track = withAlpha(accent, 0.08)
base.lufsmeter.level = accent
base.lufsmeter.track = withAlpha(accent, 0.08)
base.lufsmeter.target = withAlpha(accent, 0.25)
base.waveform.line = accent
base.nowPlaying.accent = accent
base.nowPlaying.progressFill = accent
return normalizeTheme(base, name)
}
export function createBundledThemes(): PrismTheme[] {
return [
createDefaultTheme(),
createPresetTheme('Graphite', '#4fc3f7'),
createPresetTheme('Midnight', '#4f9bff'),
createPresetTheme('Green', '#4ade80'),
createPresetTheme('Purple', '#a78bfa'),
createPresetTheme('Rose', '#fb7185'),
]
}
export function normalizeTheme(
raw: unknown,
fallbackName = DEFAULT_THEME_NAME,
): PrismTheme {
const parsed = typeof raw === 'object' && raw !== null
? raw as Partial<PrismTheme>
: {}
const name = typeof parsed.name === 'string' && parsed.name.trim()
? parsed.name.trim()
: fallbackName
const normalized = createEmptyTheme()
normalized.name = name
normalized.credit = typeof parsed.credit === 'string' && parsed.credit.trim()
? parsed.credit.trim()
: undefined
normalized.website = typeof parsed.website === 'string' && parsed.website.trim()
? parsed.website.trim()
: undefined
normalized.description = typeof parsed.description === 'string' && parsed.description.trim()
? parsed.description.trim()
: undefined
normalized.app = normalizeSectionTokens(parsed.app, APP_SCHEMA)
normalized.controls = normalizeSectionTokens(parsed.controls, CONTROLS_SCHEMA)
normalized.scopes = normalizeSectionTokens(parsed.scopes, SCOPES_SCHEMA)
normalized.spectrum = normalizeSectionTokens(parsed.spectrum, SPECTRUM_SCHEMA)
normalized.oscilloscope = normalizeSectionTokens(parsed.oscilloscope, OSCILLOSCOPE_SCHEMA)
normalized.vectorscope = normalizeSectionTokens(parsed.vectorscope, VECTORSCOPE_SCHEMA)
normalized.spectrogram = normalizeSectionTokens(parsed.spectrogram, SPECTROGRAM_SCHEMA)
normalized.vumeter = normalizeSectionTokens(parsed.vumeter, VUMETER_SCHEMA)
normalized.lufsmeter = normalizeSectionTokens(parsed.lufsmeter, LUFSMETER_SCHEMA)
normalized.waveform = normalizeSectionTokens(parsed.waveform, WAVEFORM_SCHEMA)
normalized.nowPlaying = normalizeSectionTokens(
(parsed as { nowPlaying?: unknown; astra?: unknown }).nowPlaying
?? (parsed as { astra?: unknown }).astra,
NOW_PLAYING_SCHEMA,
)
return normalized
}
export function createEmptyThemeLocalState(): PrismThemeLocalStateV1 {
return {
format: THEME_LOCAL_STATE_FORMAT,
version: THEME_LOCAL_STATE_VERSION,
migrationVersion: 0,
activeThemeId: null,
}
}
export function normalizeThemeLocalState(raw: unknown): PrismThemeLocalStateV1 {
const parsed = typeof raw === 'object' && raw !== null
? raw as Partial<PrismThemeLocalStateV1>
: {}
return {
format: THEME_LOCAL_STATE_FORMAT,
version: THEME_LOCAL_STATE_VERSION,
migrationVersion: typeof parsed.migrationVersion === 'number' && Number.isFinite(parsed.migrationVersion)
? Math.max(0, Math.trunc(parsed.migrationVersion))
: 0,
activeThemeId: typeof parsed.activeThemeId === 'string'
? parsed.activeThemeId
: null,
}
}
export function normalizeLegacyThemePayload(raw: unknown): LegacyThemeMigrationPayload {
if (typeof raw !== 'object' || raw === null) {
return { presetId: null, customAccent: null }
}
const parsed = raw as Partial<LegacyThemeMigrationPayload>
return {
presetId: typeof parsed.presetId === 'string' ? parsed.presetId : null,
customAccent: typeof parsed.customAccent === 'string' ? parsed.customAccent : null,
}
}
function getSectionTokenRecord(
theme: PrismTheme,
section: ThemeSectionName,
): SectionTokenMap {
return theme[section] as SectionTokenMap
}
function parseSectionValue(section: ThemeSectionName, key: string): string | null {
const schema = SECTION_SCHEMAS[section] as Record<string, string>
const mapped = schema[key]
return mapped ?? null
}
function parseThemeContent(content: string, fallbackName: string): PrismTheme {
const nextTheme = createEmptyTheme()
nextTheme.name = fallbackName
let currentSection: ThemeSectionName | 'theme' | null = null
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || line.startsWith('#') || line.startsWith(';')) continue
const sectionMatch = /^\[(.+)\]$/.exec(line)
if (sectionMatch) {
const sectionKey = normalizeKey(sectionMatch[1] ?? '')
currentSection = sectionKey === 'theme'
? 'theme'
: (SECTION_KEY_MAP[sectionKey] ?? null)
continue
}
const equalsIndex = line.indexOf('=')
if (equalsIndex === -1 || !currentSection) continue
const key = normalizeKey(line.slice(0, equalsIndex))
const value = line.slice(equalsIndex + 1).trim()
if (!value) continue
if (currentSection === 'theme') {
switch (key) {
case 'format':
if (value !== THEME_FILE_FORMAT) {
throw new Error(`Unsupported theme format "${value}".`)
}
break
case 'version': {
const version = Number.parseInt(value, 10)
if (version !== THEME_FILE_VERSION) {
throw new Error(`Unsupported theme version "${value}".`)
}
break
}
case 'credit':
nextTheme.credit = value
break
case 'website':
nextTheme.website = value
break
case 'description':
nextTheme.description = value
break
default:
break
}
continue
}
const tokenKey = parseSectionValue(currentSection, key)
if (!tokenKey) continue
if (PASSTHROUGH_KEYS.has(tokenKey)) {
getSectionTokenRecord(nextTheme, currentSection)[tokenKey] = value
continue
}
const parsedColor = parseCssColor(value) ?? parseThemeChannelColor(value)
if (!parsedColor) continue
getSectionTokenRecord(nextTheme, currentSection)[tokenKey] = toCssColor(quantizeThemeColor(parsedColor))
}
return normalizeTheme(nextTheme, fallbackName)
}
export function extractLegacyThemeFileId(content: string): string | null {
let currentSection: ThemeSectionName | 'theme' | null = null
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || line.startsWith('#') || line.startsWith(';')) continue
const sectionMatch = /^\[(.+)\]$/.exec(line)
if (sectionMatch) {
const sectionKey = normalizeKey(sectionMatch[1] ?? '')
currentSection = sectionKey === 'theme'
? 'theme'
: (SECTION_KEY_MAP[sectionKey] ?? null)
continue
}
if (currentSection !== 'theme') continue
const equalsIndex = line.indexOf('=')
if (equalsIndex === -1) continue
const key = normalizeKey(line.slice(0, equalsIndex))
const value = line.slice(equalsIndex + 1).trim()
if (key !== 'id' || !value) continue
return value
}
return null
}
export function parseThemeFileContent(
content: string,
fallbackName = DEFAULT_THEME_NAME,
): PrismTheme {
return parseThemeContent(content, fallbackName)
}
function serializeSection<T extends SectionTokenMap>(
sectionName: string,
tokens: T,
schema: SectionSchema<T>,
): string[] {
const lines: string[] = [`[${sectionName}]`]
for (const [serializedKey, propertyKey] of Object.entries(schema)) {
const value = tokens[propertyKey as keyof T]
if (!value) continue
if (PASSTHROUGH_KEYS.has(propertyKey)) {
lines.push(`${serializedKey} = ${value}`)
} else {
lines.push(`${serializedKey} = ${toThemeChannels(value)}`)
}
}
return lines
}
function commentExampleTokens(lines: string[]): string[] {
return lines.map((line) => {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith(';') || /^\[.+\]$/.test(trimmed)) {
return line
}
return `# ${line}`
})
}
export function serializeThemeFile(theme: PrismTheme): string {
const normalized = normalizeTheme(theme, theme.name)
const sections: string[] = [
'[Theme]',
`format = ${THEME_FILE_FORMAT}`,
`version = ${THEME_FILE_VERSION}`,
]
if (normalized.credit) sections.push(`credit = ${normalized.credit}`)
if (normalized.website) sections.push(`website = ${normalized.website}`)
if (normalized.description) sections.push(`description = ${normalized.description}`)
const output = [sections.join('\n')]
for (const section of Object.keys(SECTION_SCHEMAS) as ThemeSectionName[]) {
const tokens = normalized[section] as Record<string, string | undefined>
if (!Object.values(tokens).some(Boolean)) continue
const schema = SECTION_SCHEMAS[section] as SectionSchema<Record<string, string | undefined>>
output.push(serializeSection(SECTION_LABEL_MAP[section], tokens, schema).join('\n'))
}
return `${output.join('\n\n')}\n`
}
export function createTemplateThemeFile(): string {
const base = createDefaultTheme()
const resolved = resolveTheme(base)
const themeSection = [
'[Theme]',
`format = ${THEME_FILE_FORMAT}`,
`version = ${THEME_FILE_VERSION}`,
'# Optional metadata:',
'# credit = Your Name',
'# website = https://example.com',
'# description = Custom Prism theme',
]
const appSection = [
'[App]',
'# Start here. These are the main palette tokens.',
`accent = ${toThemeChannels(base.app.accent ?? DEFAULT_ACCENT)}`,
`background = ${toThemeChannels(base.app.background ?? 'rgb(0, 0, 0)')}`,
`surface = ${toThemeChannels(base.app.surface ?? 'rgba(8, 11, 16, 0.92)')}`,
`surface_alt = ${toThemeChannels(base.app.surfaceAlt ?? 'rgba(4, 8, 12, 0.98)')}`,
`border = ${toThemeChannels(base.app.border ?? 'rgba(255, 255, 255, 0.09)')}`,
`text = ${toThemeChannels(base.app.text ?? 'rgb(255, 255, 255)')}`,
'',
'# Optional palette extras:',
...commentExampleTokens([
`text_muted = ${toThemeChannels(base.app.textMuted ?? 'rgba(255, 255, 255, 0.42)')}`,
`success = ${toThemeChannels(base.app.success ?? DEFAULT_SUCCESS)}`,
`warning = ${toThemeChannels(base.app.warning ?? DEFAULT_WARNING)}`,
`danger = ${toThemeChannels(base.app.danger ?? DEFAULT_DANGER)}`,
]),
'',
'# Optional shell overrides:',
...commentExampleTokens([
`toolbar_bg = ${toThemeChannels(withAlpha(base.app.surfaceAlt ?? 'rgba(4, 8, 12, 0.98)', 0.78))}`,
`settings_bg_top = ${toThemeChannels(base.app.surface ?? 'rgba(8, 11, 16, 0.92)')}`,
`settings_bg_bottom = ${toThemeChannels(base.app.surfaceAlt ?? 'rgba(4, 8, 12, 0.98)')}`,
`bottom_bar_bg = ${toThemeChannels(withAlpha(base.app.surfaceAlt ?? 'rgba(4, 8, 12, 0.98)', 0.98))}`,
]),
]
const controlsSection = commentExampleTokens(serializeSection('Controls', {
...base.controls,
flatControls: 'false',
}, CONTROLS_SCHEMA as SectionSchema<Record<string, string | undefined>>))
controlsSection.splice(1, 0, '# Entire section optional. Uncomment tokens here only if you want to override Prism defaults.')
const scopesSection = commentExampleTokens(
serializeSection('Scopes', { ...base.scopes }, SCOPES_SCHEMA as SectionSchema<Record<string, string | undefined>>),
)
scopesSection.splice(1, 0, '# Entire section optional. Uncomment tokens here only if you want to override Prism defaults.')
const spectrumSection = commentExampleTokens(serializeSection('Spectrum', {
...base.spectrum,
background: resolved.spectrum.background,
guides: resolved.spectrum.guides,
labels: resolved.spectrum.labels,
heatBase: resolved.spectrum.heatBase,
}, SPECTRUM_SCHEMA as SectionSchema<Record<string, string | undefined>>))
const oscilloscopeSection = commentExampleTokens(serializeSection('Oscilloscope', {
...base.oscilloscope,
background: resolved.oscilloscope.background,
guides: resolved.oscilloscope.guides,
}, OSCILLOSCOPE_SCHEMA as SectionSchema<Record<string, string | undefined>>))
const vectorscopeSection = commentExampleTokens(serializeSection('Vectorscope', {
...base.vectorscope,
background: resolved.vectorscope.background,
guides: resolved.vectorscope.guides,
labels: resolved.vectorscope.labels,
}, VECTORSCOPE_SCHEMA as SectionSchema<Record<string, string | undefined>>))
const spectrogramSection = commentExampleTokens(serializeSection('Spectrogram', {
...base.spectrogram,
background: resolved.spectrogram.background,
}, SPECTROGRAM_SCHEMA as SectionSchema<Record<string, string | undefined>>))
const vumeterSection = commentExampleTokens(serializeSection('VUMeter', {
...base.vumeter,
background: resolved.vumeter.background,
scale: resolved.vumeter.scale,
labels: resolved.vumeter.labels,
}, VUMETER_SCHEMA as SectionSchema<Record<string, string | undefined>>))
const lufsmeterSection = commentExampleTokens(serializeSection('LUFSMeter', {
...base.lufsmeter,
background: resolved.lufsmeter.background,
scale: resolved.lufsmeter.scale,
labels: resolved.lufsmeter.labels,
}, LUFSMETER_SCHEMA as SectionSchema<Record<string, string | undefined>>))
const waveformSection = commentExampleTokens(serializeSection('Waveform', {
...base.waveform,
background: resolved.waveform.background,
guides: resolved.waveform.guides,
}, WAVEFORM_SCHEMA as SectionSchema<Record<string, string | undefined>>))
const nowPlayingSection = commentExampleTokens(serializeSection(
'Now Playing',
{ ...base.nowPlaying },
NOW_PLAYING_SCHEMA as SectionSchema<Record<string, string | undefined>>,
))
return `# Prism theme template
#
# Colors use R, G, B or R, G, B, A (0-255)
# CSS colors like #hex, rgb(), and rgba() also work
#
# Start with [App].
# Everything else below is optional and starts commented out.
# Uncomment the tokens you want to customize and leave the rest commented to inherit defaults.
#
# [Controls] and [Scopes] are shared override groups.
# Module sections show the full set of supported tokens for each module.
#
# Comment out any optional token to let Prism inherit or derive it.
# Leave an entire optional section commented if that area should use Prism's defaults.
#
${[
themeSection.join('\n'),
appSection.join('\n'),
controlsSection.join('\n'),
scopesSection.join('\n'),
'# Module sections below are optional overrides.',
'# Uncomment the tokens you want to customize and leave the rest as examples.',
spectrumSection.join('\n'),
oscilloscopeSection.join('\n'),
vectorscopeSection.join('\n'),
spectrogramSection.join('\n'),
vumeterSection.join('\n'),
lufsmeterSection.join('\n'),
waveformSection.join('\n'),
nowPlayingSection.join('\n'),
].join('\n\n')}
`
}
interface ResolvedAppTokens {
accent: string
success: string
warning: string
danger: string
background: string
surface: string
surfaceAlt: string
border: string
text: string
textMuted: string
toolbarBg: string | null
settingsBgTop: string | null
settingsBgBottom: string | null
bottomBarBg: string | null
}
interface ResolvedControlsTokens {
surface: string
surfaceHover: string
surfaceActive: string
border: string
borderActive: string
text: string
inputSurface: string
inputBorder: string
menuSurface: string
menuBorder: string
slider: string
flatControls: boolean
}
interface ResolvedScopesTokens {
background: string
guides: string
guidesSecondary: string
labels: string
overlaySurface: string
overlayText: string
overlayBorder: string
resizeHandle: string
}
function resolveAppTokens(tokens: ThemeAppTokens): ResolvedAppTokens {
const accent = tokens.accent ?? DEFAULT_ACCENT
const text = tokens.text ?? 'rgb(255, 255, 255)'
const textMuted = tokens.textMuted ?? withAlpha(text, 0.42)
const background = tokens.background ?? 'rgb(0, 0, 0)'
const surface = tokens.surface ?? 'rgba(8, 11, 16, 0.92)'
const surfaceAlt = tokens.surfaceAlt ?? 'rgba(4, 8, 12, 0.98)'
const border = tokens.border ?? 'rgba(255, 255, 255, 0.09)'
return {
accent,
success: tokens.success ?? DEFAULT_SUCCESS,
warning: tokens.warning ?? DEFAULT_WARNING,
danger: tokens.danger ?? DEFAULT_DANGER,
background,
surface,
surfaceAlt,
border,
text,
textMuted,
toolbarBg: tokens.toolbarBg ?? null,
settingsBgTop: tokens.settingsBgTop ?? null,
settingsBgBottom: tokens.settingsBgBottom ?? null,
bottomBarBg: tokens.bottomBarBg ?? null,
}
}
function resolveControlsTokens(tokens: ThemeControlsTokens, app: ResolvedAppTokens): ResolvedControlsTokens {
const surface = tokens.surface ?? 'rgba(255, 255, 255, 0.03)'
return {
surface,
surfaceHover: tokens.surfaceHover ?? mixColors(surface, app.accent, 0.12),
surfaceActive: tokens.surfaceActive ?? withAlpha(app.accent, 0.12),
border: tokens.border ?? 'rgba(255, 255, 255, 0.08)',
borderActive: tokens.borderActive ?? withAlpha(app.accent, 0.28),
text: tokens.text ?? app.text,
inputSurface: tokens.inputSurface ?? 'rgba(10, 14, 20, 0.96)',
inputBorder: tokens.inputBorder ?? 'rgba(255, 255, 255, 0.09)',
menuSurface: tokens.menuSurface ?? app.surface,
menuBorder: tokens.menuBorder ?? 'rgba(255, 255, 255, 0.1)',
slider: tokens.slider ?? withAlpha(app.accent, 0.82),
flatControls: tokens.flatControls?.trim().toLowerCase() === 'true',
}
}
function resolveScopesTokens(tokens: ThemeScopesTokens, app: ResolvedAppTokens): ResolvedScopesTokens {
const guides = tokens.guides ?? DEFAULT_SCOPE_GUIDES
return {
background: tokens.background ?? app.background,
guides,
guidesSecondary: multiplyAlpha(guides, 0.5),
labels: guides,
overlaySurface: tokens.overlaySurface ?? DEFAULT_SCOPE_OVERLAY_BG,
overlayText: tokens.overlayText ?? DEFAULT_SCOPE_OVERLAY_TEXT,
overlayBorder: tokens.overlayBorder ?? DEFAULT_SCOPE_OVERLAY_BORDER,
resizeHandle: tokens.resizeHandle ?? DEFAULT_SCOPE_RESIZE_HANDLE,
}
}
function resolveInterfaceTheme(
app: ResolvedAppTokens,
controls: ResolvedControlsTokens,
scopes: ResolvedScopesTokens,
): ResolvedInterfaceTheme {
const bgParsed = parseCssColor(app.background)
const colorScheme: 'light' | 'dark' = bgParsed && getPerceivedBrightness(bgParsed) >= 156 ? 'light' : 'dark'
const glassHighlight = controls.flatControls ? 'transparent' : withAlpha(app.text, 0.05)
const glassBg = controls.flatControls ? 'transparent' : withAlpha(app.surface, 0.18)
return {
primary: app.accent,
secondary: app.surfaceAlt,
border: app.border,
text: app.text,
background: app.background,
accent: app.accent,
accentHover: lighten(app.accent, 0.2),
accentGlow: withAlpha(app.accent, 0.3),
accentRgb: colorToRgbChannels(app.accent),
colorScheme,
bgPrimary: app.background,
bgSecondary: darken(app.background, 0.04),
bgTertiary: darken(app.background, 0.08),
panelSurface: app.surface,
panelSurfaceSoft: app.surfaceAlt,
panelOutline: app.border,
panelOutlineStrong: withAlpha(app.border, 0.9),
glassBg,
glassBorder: withAlpha(app.border, 0.85),
glassHighlight,
glassHighlightStrong: controls.flatControls ? 'transparent' : withAlpha(app.text, 0.14),
textPrimary: app.text,
textSecondary: blendText(app.text, app.textMuted, 0.45),
textTertiary: blendText(app.text, app.textMuted, 0.7),
textMuted: app.textMuted,
toolbarBg: app.toolbarBg ?? withAlpha(app.surfaceAlt, 0.78),
settingsBgTop: app.settingsBgTop ?? app.surface,
settingsBgBottom: app.settingsBgBottom ?? app.surfaceAlt,
bottomBarBg: app.bottomBarBg ?? withAlpha(app.surfaceAlt, 0.98),
menuBg: controls.menuSurface,
menuBorder: controls.menuBorder,
controlBg: controls.surface,
controlBgHover: controls.surfaceHover,
controlBgActive: controls.surfaceActive,
controlBorder: controls.border,
controlBorderActive: controls.borderActive,
controlText: controls.text,
inputBg: controls.inputSurface,
inputBgFocus: lighten(controls.inputSurface, 0.05),
inputBorder: controls.inputBorder,
inputBorderFocus: controls.borderActive,
optionBg: controls.menuSurface,
optionText: controls.text,
sliderTrack: withAlpha(app.border, 0.6),
sliderFill: controls.slider,
sliderThumb: lighten(controls.slider, 0.18),
divider: app.border,
scopeBackground: scopes.background,
scopeGuides: scopes.guides,
scopeOverlayBg: scopes.overlaySurface,
scopeOverlayText: scopes.overlayText,
scopeOverlayBorder: scopes.overlayBorder,
scopeResizeHandle: scopes.resizeHandle,
success: app.success,
warning: app.warning,
danger: app.danger,
}
}
function resolveSpectrumTheme(
theme: PrismTheme,
app: ResolvedAppTokens,
scopes: ResolvedScopesTokens,
): ResolvedSpectrumTheme {
const section = theme.spectrum
const line = section.line ?? app.accent
const sideLine = section.sideLine ?? withAlpha(line, 0.5)
const fill = section.fill ?? withAlpha(line, 0.34)
const guides = section.guides ?? scopes.guides
const background = section.background ?? scopes.background
return {
line,
sideLine,
guides,
guidesSecondary: multiplyAlpha(guides, 0.5),
labels: section.labels ?? guides,
background,
fill,
fillGradient: [
withAlpha(fill, 0),
multiplyAlpha(fill, 0.72),
fill,
],
heatColors: [
section.heatLow ?? DEFAULT_HEAT_LOW,
section.heatMid ?? DEFAULT_HEAT_MID,
section.heatHigh ?? DEFAULT_HEAT_HIGH,
],
heatBase: section.heatBase ?? background,
}
}
function resolveOscilloscopeTheme(
theme: PrismTheme,
app: ResolvedAppTokens,
scopes: ResolvedScopesTokens,
): ResolvedOscilloscopeTheme {
const guides = theme.oscilloscope.guides ?? scopes.guides
return {
line: theme.oscilloscope.line ?? app.accent,
guides,
guidesSecondary: multiplyAlpha(guides, 0.5),
background: theme.oscilloscope.background ?? scopes.background,
fill: theme.oscilloscope.fill ?? 'rgba(245, 248, 252, 0.18)',
}
}
function resolveVectorscopeTheme(
theme: PrismTheme,
app: ResolvedAppTokens,
scopes: ResolvedScopesTokens,
): ResolvedVectorscopeTheme {
const guides = theme.vectorscope.guides ?? scopes.guides
return {
trace: theme.vectorscope.trace ?? app.accent,
guides,
guidesSecondary: multiplyAlpha(guides, 0.5),
labels: theme.vectorscope.labels ?? guides,
background: theme.vectorscope.background ?? scopes.background,
bandLow: theme.vectorscope.bandLow ?? DEFAULT_BAND_LOW,
bandMid: theme.vectorscope.bandMid ?? DEFAULT_BAND_MID,
bandHigh: theme.vectorscope.bandHigh ?? DEFAULT_BAND_HIGH,
}
}
function resolveSpectrogramTheme(
theme: PrismTheme,
app: ResolvedAppTokens,
scopes: ResolvedScopesTokens,
): ResolvedSpectrogramTheme {
return {
mono: theme.spectrogram.mono ?? app.accent,
background: theme.spectrogram.background ?? scopes.background,
heatColors: [
theme.spectrogram.heatLow ?? DEFAULT_HEAT_LOW,
theme.spectrogram.heatMid ?? DEFAULT_HEAT_MID,
theme.spectrogram.heatHigh ?? DEFAULT_HEAT_HIGH,
],
}
}
function resolveVUMeterTheme(
theme: PrismTheme,
app: ResolvedAppTokens,
scopes: ResolvedScopesTokens,
): ResolvedVUMeterTheme {
const level = theme.vumeter.level ?? app.accent
return {
level,
track: theme.vumeter.track ?? withAlpha(level, 0.08),
peak: theme.vumeter.peak ?? 'rgb(255, 127, 0)',
clip: theme.vumeter.clip ?? 'rgba(255, 120, 80, 0.9)',
scale: theme.vumeter.scale ?? scopes.guides,
labels: theme.vumeter.labels ?? blendText(app.text, app.textMuted, 0.35),
background: theme.vumeter.background ?? scopes.background,
}
}
function resolveLUFSMeterTheme(
theme: PrismTheme,
app: ResolvedAppTokens,
scopes: ResolvedScopesTokens,
): ResolvedLUFSMeterTheme {
const level = theme.lufsmeter.level ?? app.accent
return {
level,
track: theme.lufsmeter.track ?? withAlpha(level, 0.08),
target: theme.lufsmeter.target ?? withAlpha(level, 0.25),
scale: theme.lufsmeter.scale ?? scopes.guides,
labels: theme.lufsmeter.labels ?? blendText(app.text, app.textMuted, 0.2),
background: theme.lufsmeter.background ?? scopes.background,
}
}
function resolveWaveformTheme(
theme: PrismTheme,
app: ResolvedAppTokens,
scopes: ResolvedScopesTokens,
): ResolvedWaveformTheme {
const guides = theme.waveform.guides ?? scopes.guides
return {
line: theme.waveform.line ?? app.accent,
guides,
guidesSecondary: multiplyAlpha(guides, 0.5),
background: theme.waveform.background ?? scopes.background,
bandLow: theme.waveform.bandLow ?? DEFAULT_BAND_LOW,
bandMid: theme.waveform.bandMid ?? DEFAULT_BAND_MID,
bandHigh: theme.waveform.bandHigh ?? DEFAULT_BAND_HIGH,
}
}
function resolveAstraTheme(
theme: PrismTheme,
app: ResolvedAppTokens,
controls: ResolvedControlsTokens,
scopes: ResolvedScopesTokens,
): ResolvedNowPlayingTheme {
const accent = theme.nowPlaying.accent ?? app.accent
const text = theme.nowPlaying.text ?? app.text
const buttonBg = theme.nowPlaying.buttonSurface ?? controls.surface
return {
accent,
text,
subtext: withAlpha(text, 0.7),
background: theme.nowPlaying.background ?? scopes.background,
surface: theme.nowPlaying.surface ?? 'rgba(10, 16, 24, 0.92)',
border: theme.nowPlaying.border ?? withAlpha(app.border, 0.9),
progressTrack: theme.nowPlaying.progressTrack ?? withAlpha(text, 0.18),
progressFill: theme.nowPlaying.progressFill ?? accent,
buttonBg,
buttonBgHover: withAlpha(mixColors(buttonBg, accent, 0.14), 0.94),
buttonBgActive: withAlpha(accent, 0.16),
buttonBorder: theme.nowPlaying.buttonBorder ?? controls.border,
buttonText: text,
statusOk: theme.nowPlaying.statusOk ?? app.success,
statusError: theme.nowPlaying.statusError ?? app.danger,
}
}
export function resolveTheme(theme: PrismTheme): PrismResolvedTheme {
const normalized = normalizeTheme(theme, theme.name)
const app = resolveAppTokens(normalized.app)
const controls = resolveControlsTokens(normalized.controls, app)
const scopes = resolveScopesTokens(normalized.scopes, app)
return {
name: normalized.name,
credit: normalized.credit,
website: normalized.website,
description: normalized.description,
interface: resolveInterfaceTheme(app, controls, scopes),
spectrum: resolveSpectrumTheme(normalized, app, scopes),
oscilloscope: resolveOscilloscopeTheme(normalized, app, scopes),
vectorscope: resolveVectorscopeTheme(normalized, app, scopes),
spectrogram: resolveSpectrogramTheme(normalized, app, scopes),
vumeter: resolveVUMeterTheme(normalized, app, scopes),
lufsmeter: resolveLUFSMeterTheme(normalized, app, scopes),
waveform: resolveWaveformTheme(normalized, app, scopes),
nowPlaying: resolveAstraTheme(normalized, app, controls, scopes),
}
}
export function resolveNativeThemeSource(theme: PrismTheme | null | undefined): 'dark' | 'light' {
const resolved = resolveTheme(theme ?? createDefaultTheme())
const parsed = parseCssColor(resolved.interface.menuBg) ?? parseThemeChannelColor(resolved.interface.menuBg)
if (!parsed) {
return 'dark'
}
return getPerceivedBrightness(parsed) >= 156 ? 'light' : 'dark'
}
export function resolveLegacyThemeToPresetId(payload: LegacyThemeMigrationPayload): string | null {
switch (payload.presetId) {
case 'default':
return DEFAULT_THEME_NAME
case 'graphite':
return 'Graphite'
case 'midnight':
return 'Midnight'
case 'green':
return 'Green'
case 'purple':
return 'Purple'
case 'rose':
return 'Rose'
default:
return null
}
}
export function resolveLegacyThemeFileId(value: string | null | undefined): string | null {
if (typeof value !== 'string') {
return null
}
switch (value.trim().toLowerCase()) {
case 'default':
case 'theme_default':
return DEFAULT_THEME_NAME
case 'graphite':
case 'theme_graphite':
return 'Graphite'
case 'midnight':
case 'theme_midnight':
return 'Midnight'
case 'green':
case 'theme_green':
return 'Green'
case 'purple':
case 'theme_purple':
return 'Purple'
case 'rose':
case 'theme_rose':
return 'Rose'
default:
return null
}
}
export function createMigratedAccentTheme(accent: string): PrismTheme | null {
const parsed = parseCssColor(accent)
if (!parsed) return null
const base = cloneTheme(createDefaultTheme())
base.name = 'Migrated Accent'
base.app.accent = toCssColor(parsed)
base.controls.surfaceActive = withAlpha(base.app.accent, 0.12)
base.controls.borderActive = withAlpha(base.app.accent, 0.28)
base.controls.slider = withAlpha(base.app.accent, 0.82)
base.spectrum.line = base.app.accent
base.spectrum.sideLine = withAlpha(base.app.accent, 0.5)
base.spectrum.fill = withAlpha(base.app.accent, 0.34)
base.oscilloscope.line = base.app.accent
base.vectorscope.trace = base.app.accent
base.spectrogram.mono = base.app.accent
base.vumeter.level = base.app.accent
base.vumeter.track = withAlpha(base.app.accent, 0.08)
base.lufsmeter.level = base.app.accent
base.lufsmeter.track = withAlpha(base.app.accent, 0.08)
base.lufsmeter.target = withAlpha(base.app.accent, 0.25)
base.waveform.line = base.app.accent
base.nowPlaying.accent = base.app.accent
base.nowPlaying.progressFill = base.app.accent
return normalizeTheme(base, base.name)
}
export function themeToCssVariables(theme: Pick<PrismResolvedTheme, 'interface'>): Record<string, string> {
const ui = theme.interface
return {
'--bg-primary': ui.bgPrimary,
'--bg-secondary': ui.bgSecondary,
'--bg-tertiary': ui.bgTertiary,
'--panel-surface': ui.panelSurface,
'--panel-surface-soft': ui.panelSurfaceSoft,
'--panel-outline': ui.panelOutline,
'--panel-outline-strong': ui.panelOutlineStrong,
'--glass-bg': ui.glassBg,
'--glass-border': ui.glassBorder,
'--glass-highlight': ui.glassHighlight,
'--glass-highlight-strong': ui.glassHighlightStrong,
'--text-primary': ui.textPrimary,
'--text-secondary': ui.textSecondary,
'--text-tertiary': ui.textTertiary,
'--text-muted': ui.textMuted,
'--danger': ui.danger,
'--warning': ui.warning,
'--success': ui.success,
'--accent': ui.accent,
'--accent-hover': ui.accentHover,
'--accent-glow': ui.accentGlow,
'--accent-rgb': ui.accentRgb,
'--toolbar-bg': ui.toolbarBg,
'--settings-bg-top': ui.settingsBgTop,
'--settings-bg-bottom': ui.settingsBgBottom,
'--bottom-bar-bg': ui.bottomBarBg,
'--menu-bg': ui.menuBg,
'--menu-border': ui.menuBorder,
'--control-bg': ui.controlBg,
'--control-bg-hover': ui.controlBgHover,
'--control-bg-active': ui.controlBgActive,
'--control-border': ui.controlBorder,
'--control-border-active': ui.controlBorderActive,
'--control-text': ui.controlText,
'--input-bg': ui.inputBg,
'--input-bg-focus': ui.inputBgFocus,
'--input-border': ui.inputBorder,
'--input-border-focus': ui.inputBorderFocus,
'--option-bg': ui.optionBg,
'--option-text': ui.optionText,
'--slider-track': ui.sliderTrack,
'--slider-fill': ui.sliderFill,
'--slider-thumb': ui.sliderThumb,
'--divider': ui.divider,
'--scope-bg': ui.scopeBackground,
'--scope-guides': ui.scopeGuides,
'--scope-overlay-bg': ui.scopeOverlayBg,
'--scope-overlay-text': ui.scopeOverlayText,
'--scope-overlay-border': ui.scopeOverlayBorder,
'--scope-resize-handle': ui.scopeResizeHandle,
}
}
export function applyResolvedThemeToDocument(
theme: Pick<PrismResolvedTheme, 'interface'>,
root: Pick<CSSStyleDeclaration, 'setProperty'>,
): void {
const variables = themeToCssVariables(theme)
for (const [name, value] of Object.entries(variables)) {
root.setProperty(name, value)
}
root.setProperty('color-scheme', theme.interface.colorScheme)
}
export function getDefaultThemeIdForLocalState(): string {
return DEFAULT_THEME_NAME
}
export function getLegacyThemeMigrationVersion(): number {
return LEGACY_THEME_MIGRATION_VERSION
}