diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f144cc..d1d8a8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- [2026-07-28] the dashboard follows the terminal's own colors - [2026-07-23] an out-of-date dashboard says restart and refuses changes - [2026-07-23] claude routing keeps the native login, fixes #15 - [2026-07-22] session reset time on analytics, fixes #9 diff --git a/README.md b/README.md index 4b881a1..4354428 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,8 @@ tokenmaxx list | status | refresh | doctor Env: `TOKENMAXX_HOME`, `TOKENMAXX_PROXY_PORT`, `TOKENMAXX_THEME`. +The dashboard asks your terminal for its colors (OSC 4/10/11) and uses them, so it matches the theme you already run. Terminals that don't answer get a built-in dark or light palette instead. Settings → Display switches between `auto`, `dark` and `light` and stores the choice in `~/.tokenmaxx/preferences.json`. `TOKENMAXX_THEME` pins any of the three for one run and beats the stored choice. + ## Intended use tokenmaxx is for one person with accounts they pay for themselves. No account gets bigger limits, no limit gets bypassed, and your credentials stay between your Keychain and the provider. Don't share accounts, don't pool them, don't resell access. Provider terms change, and it's on you to check that yours allow this kind of switching. The software is provided as is, with no warranty. diff --git a/package.json b/package.json index 1e20747..4e156d2 100644 --- a/package.json +++ b/package.json @@ -59,5 +59,5 @@ "post-commit": "bun x @rubriclab/package post-commit" }, "type": "module", - "version": "0.0.58" + "version": "0.0.59" } diff --git a/src/preferences.test.ts b/src/preferences.test.ts new file mode 100644 index 0000000..4dad8dd --- /dev/null +++ b/src/preferences.test.ts @@ -0,0 +1,44 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { readPreferences, writePreferences } from './preferences.ts' + +let home: string +let environment: NodeJS.ProcessEnv + +beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'tokenmaxx-preferences-')) + environment = { TOKENMAXX_HOME: home } +}) + +afterEach(async () => { + await rm(home, { force: true, recursive: true }) +}) + +describe('preferences', () => { + test('defaults to auto before anything is written', async () => { + expect(await readPreferences(environment)).toEqual({ theme: 'auto' }) + }) + + test('round-trips a stored theme', async () => { + await writePreferences({ theme: 'light' }, environment) + expect(await readPreferences(environment)).toEqual({ theme: 'light' }) + }) + + test('falls back to auto when the file is corrupt', async () => { + await writeFile(join(home, 'preferences.json'), '{ not json') + expect(await readPreferences(environment)).toEqual({ theme: 'auto' }) + }) + + test('falls back to auto when the stored theme is not one we know', async () => { + await writeFile(join(home, 'preferences.json'), JSON.stringify({ theme: 'gruvbox' })) + expect(await readPreferences(environment)).toEqual({ theme: 'auto' }) + }) + + test('keeps the file private to the user', async () => { + await writePreferences({ theme: 'dark' }, environment) + const stat = await Bun.file(join(home, 'preferences.json')).stat() + expect(stat.mode & 0o777).toBe(0o600) + }) +}) diff --git a/src/preferences.ts b/src/preferences.ts new file mode 100644 index 0000000..da9dc0d --- /dev/null +++ b/src/preferences.ts @@ -0,0 +1,38 @@ +import { readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { z } from 'zod' +import { applicationPaths } from './paths.ts' + +const PreferencesSchema = z.object({ + theme: z.enum(['auto', 'dark', 'light']) +}) +export type Preferences = z.infer + +const DEFAULT_PREFERENCES: Preferences = { theme: 'auto' } + +function preferencesPath(environment: NodeJS.ProcessEnv): string { + return join(applicationPaths(environment).root, 'preferences.json') +} + +// Nothing here reaches the manager, so a missing or hand-mangled file falls back to the defaults +// instead of keeping the dashboard shut. +export async function readPreferences( + environment: NodeJS.ProcessEnv = process.env +): Promise { + try { + const raw = await readFile(preferencesPath(environment), 'utf8') + const parsed = PreferencesSchema.safeParse(JSON.parse(raw)) + return parsed.success ? parsed.data : DEFAULT_PREFERENCES + } catch { + return DEFAULT_PREFERENCES + } +} + +export async function writePreferences( + preferences: Preferences, + environment: NodeJS.ProcessEnv = process.env +): Promise { + await writeFile(preferencesPath(environment), `${JSON.stringify(preferences, null, '\t')}\n`, { + mode: 0o600 + }) +} diff --git a/src/tui/dashboard.ts b/src/tui/dashboard.ts index 375795f..bbc9239 100644 --- a/src/tui/dashboard.ts +++ b/src/tui/dashboard.ts @@ -20,6 +20,7 @@ import { requestResetCredits, requestSwitch } from '../ipc.ts' +import { readPreferences, writePreferences } from '../preferences.ts' import { availableUpdate, installedVersion, VERSION } from '../version.ts' import { buildScenario } from './fixtures.ts' import { @@ -37,8 +38,11 @@ import { shortWindow, type Theme, type ThemeName, + type ThemePreference, TIMEFRAMES, type Timeframe, + themeFromTerminal, + themeOverride, themes, throughputColumns } from './format.ts' @@ -85,6 +89,9 @@ interface Ctx { routing: Record cliPresent: Record switchFlagMs: number + themePreference: ThemePreference + themePinnedByEnvironment: boolean + themeFromTerminalActive: boolean } function labelWidth(ctx: Ctx): number { @@ -762,12 +769,17 @@ function dwellLabel(milliseconds: number): string { return minutes === 0 ? 'off' : `${minutes}m` } -interface SettingRow { - provider: ProviderId - key: 'routing' | 'auto' | 'threshold' | 'dwell' | 'window' - windowId?: string - windowLabel?: string -} +type SettingRow = + | { + scope: 'provider' + provider: ProviderId + key: 'routing' | 'auto' | 'threshold' | 'dwell' | 'window' + windowId?: string + windowLabel?: string + } + | { scope: 'display'; key: 'theme' } + +const THEME_CYCLE: readonly ThemePreference[] = ['auto', 'dark', 'light'] function providerWindows(snapshot: DashboardSnapshot, provider: ProviderId): UsageWindow[] { const seen = new Map() @@ -785,18 +797,49 @@ function providerWindows(snapshot: DashboardSnapshot, provider: ProviderId): Usa } function buildSettingRows(snapshot: DashboardSnapshot): SettingRow[] { - return providerOrder.flatMap(provider => [ - { key: 'routing' as const, provider }, - { key: 'auto' as const, provider }, - { key: 'threshold' as const, provider }, - { key: 'dwell' as const, provider }, - ...providerWindows(snapshot, provider).map(window => ({ - key: 'window' as const, - provider, - windowId: window.id, - windowLabel: window.label - })) - ]) + return [ + ...providerOrder.flatMap(provider => [ + { key: 'routing' as const, provider, scope: 'provider' as const }, + { key: 'auto' as const, provider, scope: 'provider' as const }, + { key: 'threshold' as const, provider, scope: 'provider' as const }, + { key: 'dwell' as const, provider, scope: 'provider' as const }, + ...providerWindows(snapshot, provider).map(window => ({ + key: 'window' as const, + provider, + scope: 'provider' as const, + windowId: window.id, + windowLabel: window.label + })) + ]), + { key: 'theme' as const, scope: 'display' as const } + ] +} + +type ProviderSettingRow = Extract + +interface SettingLine { + isSelected: boolean + label: string + value: string + valueColor: string + hint: string +} + +function settingLine(ctx: Ctx, line: SettingLine) { + return Box( + { + backgroundColor: line.isSelected ? rgb(ctx.theme.selected) : rgb(ctx.theme.bg), + flexDirection: 'row', + width: '100%' + }, + Text({ content: line.isSelected ? ' ▸ ' : ' ', fg: rgb(ctx.theme.accent) }), + Text({ + content: pad(line.label, 12), + fg: rgb(line.isSelected ? ctx.theme.fg : ctx.theme.dim) + }), + Text({ attributes: 1, content: pad(line.value, 7), fg: rgb(line.valueColor) }), + Text({ content: pad(line.hint, 40), fg: rgb(ctx.theme.faint) }) + ) } function settingsPanel( @@ -808,7 +851,12 @@ function settingsPanel( ) { const state = snapshot.providers.find(s => s.provider === provider) const policy = state?.policy - const rows = allRows.map((row, index) => ({ index, row })).filter(e => e.row.provider === provider) + const rows = allRows + .map((row, index) => ({ index, row })) + .filter( + (entry): entry is { index: number; row: ProviderSettingRow } => + entry.row.scope === 'provider' && entry.row.provider === provider + ) const lines = rows.map(entry => { const { row } = entry const isSelected = entry.index === selected @@ -865,17 +913,7 @@ function settingsPanel( ? ctx.theme.good : ctx.theme.dim : ctx.theme.fg - return Box( - { - backgroundColor: isSelected ? rgb(ctx.theme.selected) : rgb(ctx.theme.bg), - flexDirection: 'row', - width: '100%' - }, - Text({ content: isSelected ? ' ▸ ' : ' ', fg: rgb(ctx.theme.accent) }), - Text({ content: pad(label, 12), fg: rgb(isSelected ? ctx.theme.fg : ctx.theme.dim) }), - Text({ attributes: 1, content: pad(value, 7), fg: rgb(valueColor) }), - Text({ content: pad(hint, 40), fg: rgb(ctx.theme.faint) }) - ) + return settingLine(ctx, { hint, isSelected, label, value, valueColor }) }) const routed = ctx.routing[provider] const auto = policy?.enabled ? `⟳ auto ${policy.thresholdPercent}%` : 'auto off' @@ -896,12 +934,41 @@ function settingsPanel( ) } +function displayPanel(ctx: Ctx, allRows: SettingRow[], selected: number) { + const index = allRows.findIndex(row => row.scope === 'display' && row.key === 'theme') + const pinned = ctx.themePinnedByEnvironment + return Box( + { + border: true, + borderColor: rgb(ctx.theme.border), + borderStyle: 'rounded', + flexDirection: 'column', + flexShrink: 0, + title: ' Display ', + titleColor: rgb(ctx.theme.dim), + width: '100%' + }, + settingLine(ctx, { + hint: pinned + ? 'pinned by TOKENMAXX_THEME' + : ctx.themeFromTerminalActive + ? 'following your terminal colors' + : 'auto follows your terminal colors', + isSelected: index === selected, + label: 'theme', + value: ctx.themePreference, + valueColor: pinned ? ctx.theme.dim : ctx.theme.fg + }) + ) +} + function settingsBody(ctx: Ctx, snapshot: DashboardSnapshot, rows: SettingRow[], selected: number) { return column( ctx, [ settingsPanel(ctx, snapshot, rows, 'openai', selected), - settingsPanel(ctx, snapshot, rows, 'anthropic', selected) + settingsPanel(ctx, snapshot, rows, 'anthropic', selected), + displayPanel(ctx, rows, selected) ], 78 ) @@ -1235,8 +1302,21 @@ export async function runTuiDashboard( : { anthropic: true, openai: true } const renderer = await createCliRenderer({ exitOnCtrlC: false, targetFps: 30 }) await renderer.waitForThemeMode(400).catch(() => null) + const themeEnvironmentOverride = themeOverride(process.env) const envFallback: ThemeName = detectThemeName(process.env) - const currentTheme = (): Theme => themes[live ? (renderer.themeMode ?? envFallback) : envFallback] + // Fixtures pin a built-in palette so recorded screenshots do not drift with whatever terminal + // renders them. TOKENMAXX_THEME=auto opts a fixture run back into terminal colors. + let preference: ThemePreference = + themeEnvironmentOverride ?? (live ? (await readPreferences()).theme : envFallback) + // Fetched even when a built-in palette is pinned, so switching back to auto needs no restart. + const terminalTheme = + live || preference === 'auto' + ? themeFromTerminal(await renderer.getPalette({ size: 16, timeout: 400 }).catch(() => null)) + : null + const currentTheme = (): Theme => + preference === 'auto' + ? (terminalTheme ?? themes[live ? (renderer.themeMode ?? envFallback) : envFallback]) + : themes[preference] let simulatedNow = fixture?.now ?? Date.now() let analytics = fixture === undefined @@ -1286,6 +1366,9 @@ export async function runTuiDashboard( rows: process.stdout.rows ?? 24, switchFlagMs: fixture !== undefined && fixture.timewarp > 0 ? 24 * 60_000 : 120_000, theme: currentTheme(), + themeFromTerminalActive: preference === 'auto' && terminalTheme !== null, + themePinnedByEnvironment: themeEnvironmentOverride !== null, + themePreference: preference, tier: tierFor(columns) }, analytics, @@ -1472,6 +1555,22 @@ export async function runTuiDashboard( if (row === undefined) { return } + if (row.scope === 'display') { + if (themeEnvironmentOverride !== null) { + state.note = 'theme is pinned by TOKENMAXX_THEME' + paint() + return + } + const position = THEME_CYCLE.indexOf(preference) + preference = THEME_CYCLE[(position + delta + THEME_CYCLE.length) % THEME_CYCLE.length] ?? 'auto' + state.note = `theme ${preference}` + paint() + void writePreferences({ theme: preference }).catch(() => { + state.note = 'theme not saved; check ~/.tokenmaxx is writable' + paint() + }) + return + } const policy = currentPolicy(row.provider) if (row.key === 'routing') { toggleRouting(row.provider) diff --git a/src/tui/format.test.ts b/src/tui/format.test.ts new file mode 100644 index 0000000..d73c57c --- /dev/null +++ b/src/tui/format.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, test } from 'bun:test' +import { + detectThemeName, + type TerminalPaletteColors, + themeFromTerminal, + themeOverride, + themes +} from './format.ts' + +const gruvboxDark: TerminalPaletteColors = { + defaultBackground: '#282828', + defaultForeground: '#ebdbb2', + palette: [ + '#282828', + '#cc241d', + '#98971a', + '#d79921', + '#458588', + '#b16286', + '#689d6a', + '#a89984', + '#928374', + '#fb4934', + '#b8bb26', + '#fabd2f', + '#83a598', + '#d3869b', + '#8ec07c', + '#ebdbb2' + ] +} + +const solarizedLight: TerminalPaletteColors = { + defaultBackground: '#fdf6e3', + defaultForeground: '#657b83', + palette: [ + '#073642', + '#dc322f', + '#859900', + '#b58900', + '#268bd2', + '#d33682', + '#2aa198', + '#eee8d5', + '#002b36', + '#cb4b16', + '#586e75', + '#657b83', + '#839496', + '#6c71c4', + '#93a1a1', + '#fdf6e3' + ] +} + +const hex = /^#[0-9a-f]{6}$/ + +function channel(value: number): number { + const ratio = value / 255 + return ratio <= 0.04045 ? ratio / 12.92 : ((ratio + 0.055) / 1.055) ** 2.4 +} + +function contrast(left: string, right: string): number { + const luminance = (color: string) => { + const packed = Number.parseInt(color.slice(1), 16) + return ( + 0.2126 * channel((packed >> 16) & 0xff) + + 0.7152 * channel((packed >> 8) & 0xff) + + 0.0722 * channel(packed & 0xff) + ) + } + const a = luminance(left) + const b = luminance(right) + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) +} + +describe('themeOverride', () => { + test('is absent when the variable is unset, so the stored preference wins', () => { + expect(themeOverride({})).toBeNull() + }) + + test('honors an explicit override regardless of case or padding', () => { + expect(themeOverride({ TOKENMAXX_THEME: ' LIGHT ' })).toBe('light') + expect(themeOverride({ TOKENMAXX_THEME: 'dark' })).toBe('dark') + expect(themeOverride({ TOKENMAXX_THEME: 'auto' })).toBe('auto') + }) + + test('ignores unknown values instead of pinning something arbitrary', () => { + expect(themeOverride({ TOKENMAXX_THEME: 'gruvbox' })).toBeNull() + expect(themeOverride({ TOKENMAXX_THEME: '' })).toBeNull() + }) +}) + +describe('detectThemeName', () => { + test('reads the background slot of COLORFGBG', () => { + expect(detectThemeName({ COLORFGBG: '0;15' })).toBe('light') + expect(detectThemeName({ COLORFGBG: '15;0' })).toBe('dark') + }) + + test('defaults to dark when unset or unparseable', () => { + expect(detectThemeName({})).toBe('dark') + expect(detectThemeName({ COLORFGBG: 'default;default' })).toBe('dark') + }) + + test('no longer consumes the theme override', () => { + expect(detectThemeName({ TOKENMAXX_THEME: 'light' })).toBe('dark') + }) +}) + +describe('themeFromTerminal', () => { + test('adopts the terminal background and foreground verbatim', () => { + const theme = themeFromTerminal(gruvboxDark) + expect(theme?.bg).toBe('#282828') + expect(theme?.fg).toBe('#ebdbb2') + }) + + test('maps semantic slots to the terminal palette', () => { + const theme = themeFromTerminal(gruvboxDark) + expect(theme?.bad).toBe('#fb4934') + expect(theme?.good).toBe('#b8bb26') + expect(theme?.warn).toBe('#fabd2f') + expect(theme?.accent).toBe('#83a598') + }) + + test('picks the palette entry with more contrast against the background', () => { + const theme = themeFromTerminal(solarizedLight) + expect(theme?.good).toBe('#859900') + expect(theme?.bad).toBe('#dc322f') + }) + + test('produces every field as a six digit hex string', () => { + for (const colors of [gruvboxDark, solarizedLight]) { + const theme = themeFromTerminal(colors) + expect(theme).not.toBeNull() + for (const value of Object.values(theme ?? {})) { + expect(value).toMatch(hex) + } + } + }) + + test('keeps derived text colors above the contrast floor', () => { + for (const colors of [gruvboxDark, solarizedLight]) { + const theme = themeFromTerminal(colors) + expect(theme).not.toBeNull() + if (theme === null) { + continue + } + expect(contrast(theme.dim, theme.bg)).toBeGreaterThanOrEqual(3.5) + expect(contrast(theme.faint, theme.bg)).toBeGreaterThanOrEqual(2.2) + for (const key of ['accent', 'bad', 'good', 'warn'] as const) { + expect(contrast(theme[key], theme.bg)).toBeGreaterThanOrEqual(2.6) + } + } + }) + + test('lifts a washed out palette entry until it is readable', () => { + const theme = themeFromTerminal({ + ...gruvboxDark, + palette: gruvboxDark.palette.map((entry, index) => + index === 2 || index === 10 ? '#2b2b2b' : entry + ) + }) + expect(theme?.good).not.toBe('#2b2b2b') + expect(contrast(theme?.good ?? '#000000', '#282828')).toBeGreaterThanOrEqual(2.6) + }) + + test('falls back to the built-in palette when a slot is missing', () => { + const theme = themeFromTerminal({ + ...gruvboxDark, + palette: gruvboxDark.palette.map((entry, index) => (index === 4 || index === 12 ? null : entry)) + }) + expect(theme?.accent).toBe(themes.dark.accent) + }) + + test('returns null when the terminal did not answer', () => { + expect(themeFromTerminal(null)).toBeNull() + expect( + themeFromTerminal({ + defaultBackground: null, + defaultForeground: null, + palette: Array(16).fill(null) + }) + ).toBeNull() + }) + + test('returns null when foreground and background are too close to read', () => { + expect(themeFromTerminal({ ...gruvboxDark, defaultForeground: '#303030' })).toBeNull() + }) +}) diff --git a/src/tui/format.ts b/src/tui/format.ts index 82b6791..45b4295 100644 --- a/src/tui/format.ts +++ b/src/tui/format.ts @@ -45,11 +45,14 @@ const lightTheme: Theme = { export type ThemeName = 'dark' | 'light' export const themes: Record = { dark: darkTheme, light: lightTheme } +export type ThemePreference = 'auto' | ThemeName + +export function themeOverride(environment: NodeJS.ProcessEnv): ThemePreference | null { + const value = environment.TOKENMAXX_THEME?.trim().toLowerCase() + return value === 'light' || value === 'dark' || value === 'auto' ? value : null +} + export function detectThemeName(environment: NodeJS.ProcessEnv): ThemeName { - const override = environment.TOKENMAXX_THEME?.toLowerCase() - if (override === 'light' || override === 'dark') { - return override - } const colorFgBg = environment.COLORFGBG if (colorFgBg !== undefined) { const background = Number(colorFgBg.split(';').pop()) @@ -60,6 +63,142 @@ export function detectThemeName(environment: NodeJS.ProcessEnv): ThemeName { return 'dark' } +export interface TerminalPaletteColors { + palette: readonly (string | null)[] + defaultForeground: string | null + defaultBackground: string | null +} + +type Rgb = readonly [number, number, number] + +// Plenty of terminal themes put their mid-tones almost on top of the background, so derived colors +// get pushed back toward the foreground until they clear these ratios or the text disappears. +const MIN_BASE_CONTRAST = 2.5 +const MIN_DIM_CONTRAST = 3.5 +const MIN_FAINT_CONTRAST = 2.2 +const MIN_SEMANTIC_CONTRAST = 2.6 +const CONTRAST_STEP = 0.08 +const BLEND = { border: 0.22, dim: 0.65, faint: 0.4, panel: 0.05, selected: 0.12 } as const +// Slots 8-15 are a brighter take on 0-7 only by convention; Solarized reuses them as greys. Trust a +// bright slot only while it keeps its base hue, or `good` comes out grey. +const GREY_CHROMA = 0.1 +const MAX_HUE_DRIFT = 45 + +function parseHex(value: string | null | undefined): Rgb | null { + const match = value?.trim().match(/^#?([0-9a-f]{6})$/i) + if (match?.[1] === undefined) { + return null + } + const packed = Number.parseInt(match[1], 16) + return [(packed >> 16) & 0xff, (packed >> 8) & 0xff, packed & 0xff] +} + +function toHex([red, green, blue]: Rgb): string { + return `#${((red << 16) | (green << 8) | blue).toString(16).padStart(6, '0')}` +} + +function mix(from: Rgb, to: Rgb, weight: number): Rgb { + const at = (index: 0 | 1 | 2) => Math.round(from[index] + (to[index] - from[index]) * weight) + return [at(0), at(1), at(2)] +} + +function luminance([red, green, blue]: Rgb): number { + const channel = (value: number) => { + const ratio = value / 255 + return ratio <= 0.04045 ? ratio / 12.92 : ((ratio + 0.055) / 1.055) ** 2.4 + } + return 0.2126 * channel(red) + 0.7152 * channel(green) + 0.0722 * channel(blue) +} + +function contrastRatio(left: Rgb, right: Rgb): number { + const a = luminance(left) + const b = luminance(right) + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) +} + +function chroma([red, green, blue]: Rgb): number { + return (Math.max(red, green, blue) - Math.min(red, green, blue)) / 255 +} + +function hue(color: Rgb): number { + const [red, green, blue] = color + const span = chroma(color) * 255 + if (span === 0) { + return 0 + } + const max = Math.max(red, green, blue) + const degrees = + max === red + ? ((green - blue) / span) % 6 + : max === green + ? (blue - red) / span + 2 + : (red - green) / span + 4 + return (degrees * 60 + 360) % 360 +} + +function hueDistance(left: Rgb, right: Rgb): number { + const delta = Math.abs(hue(left) - hue(right)) + return Math.min(delta, 360 - delta) +} + +function keepsMeaning(base: Rgb, bright: Rgb): boolean { + if (chroma(base) < GREY_CHROMA) { + return true + } + return chroma(bright) >= GREY_CHROMA && hueDistance(base, bright) <= MAX_HUE_DRIFT +} + +function ensureContrast(color: Rgb, background: Rgb, foreground: Rgb, minimum: number): Rgb { + let candidate = color + for (let weight = CONTRAST_STEP; weight <= 1; weight += CONTRAST_STEP) { + if (contrastRatio(candidate, background) >= minimum) { + return candidate + } + candidate = mix(color, foreground, weight) + } + return foreground +} + +export function themeFromTerminal(colors: TerminalPaletteColors | null | undefined): Theme | null { + const bg = parseHex(colors?.defaultBackground) + const fg = parseHex(colors?.defaultForeground) + if (bg === null || fg === null || contrastRatio(fg, bg) < MIN_BASE_CONTRAST) { + return null + } + const builtIn = themes[luminance(bg) > 0.5 ? 'light' : 'dark'] + const blend = (weight: number) => mix(bg, fg, weight) + const semantic = (normalIndex: number, brightIndex: number, fallback: Rgb): string => { + const normal = parseHex(colors?.palette[normalIndex]) + const bright = parseHex(colors?.palette[brightIndex]) + const candidates = + normal === null + ? bright === null + ? [fallback] + : [bright] + : bright !== null && keepsMeaning(normal, bright) + ? [normal, bright] + : [normal] + const [best = fallback] = candidates.sort( + (left, right) => contrastRatio(right, bg) - contrastRatio(left, bg) + ) + return toHex(ensureContrast(best, bg, fg, MIN_SEMANTIC_CONTRAST)) + } + const builtInColor = (value: string): Rgb => parseHex(value) ?? fg + return { + accent: semantic(4, 12, builtInColor(builtIn.accent)), + bad: semantic(1, 9, builtInColor(builtIn.bad)), + bg: toHex(bg), + border: toHex(blend(BLEND.border)), + dim: toHex(ensureContrast(blend(BLEND.dim), bg, fg, MIN_DIM_CONTRAST)), + faint: toHex(ensureContrast(blend(BLEND.faint), bg, fg, MIN_FAINT_CONTRAST)), + fg: toHex(fg), + good: semantic(2, 10, builtInColor(builtIn.good)), + panel: toHex(blend(BLEND.panel)), + selected: toHex(blend(BLEND.selected)), + warn: semantic(3, 11, builtInColor(builtIn.warn)) + } +} + export function pressureColor(theme: Theme, usedPercent: number | null): string { if (usedPercent === null) { return theme.dim