Merge remote-tracking branch 'origin/main' into bb/vscode-marketplace-themes

# Conflicts:
#	apps/desktop/electron/main.cjs
#	apps/desktop/src/app/command-palette/index.tsx
#	apps/desktop/src/themes/context.tsx
This commit is contained in:
Brooklyn Nicholson
2026-06-09 23:22:36 -05:00
37 changed files with 1211 additions and 415 deletions
+17 -2
View File
@@ -252,7 +252,15 @@ interface ThemeContextValue {
theme: DesktopTheme
themeName: string
mode: ThemeMode
/** The light/dark switch the user picked. */
resolvedMode: 'light' | 'dark'
/**
* The mode actually painted, derived from the active background's luminance.
* Differs from `resolvedMode` for skins that keep a bright surface in "dark"
* (or vice-versa). Surface-bound UI (e.g. the terminal palette) should key off
* this so it matches what's on screen instead of inverting.
*/
renderedMode: 'light' | 'dark'
availableThemes: Array<{ name: string; label: string; description: string }>
setTheme: (name: string) => void
setMode: (mode: ThemeMode) => void
@@ -265,6 +273,7 @@ const ThemeContext = createContext<ThemeContextValue>({
themeName: DEFAULT_SKIN_NAME,
mode: 'light',
resolvedMode: 'light',
renderedMode: 'light',
availableThemes: SKIN_LIST,
setTheme: () => {},
setMode: () => {}
@@ -310,6 +319,12 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const resolvedMode = resolveMode(mode, systemDark)
const activeTheme = useMemo(() => deriveTheme(themeName, resolvedMode), [themeName, resolvedMode])
// What actually gets painted (matches the `.dark` class applyTheme toggles).
const renderedMode = useMemo(
() => renderedModeFor(activeTheme.colors, resolvedMode),
[activeTheme, resolvedMode]
)
useEffect(() => applyTheme(activeTheme, resolvedMode), [activeTheme, resolvedMode])
// Assign to whichever profile is live right now (read fresh so the callbacks
@@ -331,8 +346,8 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
// (`appearance.toggleMode`) so it shows up in the hotkey map and is rebindable.
const value = useMemo<ThemeContextValue>(
() => ({ theme: activeTheme, themeName, mode, resolvedMode, availableThemes, setTheme, setMode }),
[activeTheme, themeName, mode, resolvedMode, availableThemes, setTheme, setMode]
() => ({ theme: activeTheme, themeName, mode, resolvedMode, renderedMode, availableThemes, setTheme, setMode }),
[activeTheme, themeName, mode, resolvedMode, renderedMode, availableThemes, setTheme, setMode]
)
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
+14 -6
View File
@@ -48,19 +48,27 @@ export function buildThemeFromMarketplace(result: DesktopMarketplaceThemeResult)
const label = file.label || raw.name || result.displayName
const { mode, theme } = convertVscodeColorTheme(raw, { label, source: result.extensionId })
return { mode, palette: theme.colors }
return { mode, palette: theme.colors, terminal: theme.terminal }
})
const fallback = variants[0].palette
const light = variants.find(variant => variant.mode === 'light')?.palette
const dark = variants.find(variant => variant.mode === 'dark')?.palette
const fallback = variants[0]
const light = variants.find(variant => variant.mode === 'light') ?? fallback
const dark = variants.find(variant => variant.mode === 'dark') ?? fallback
// The terminal ANSI palette tracks the painted variant the same way colors do
// (light → terminal, dark → darkTerminal); each falls back to the other so a
// single-variant import still themes the terminal in both modes.
const terminal = light.terminal ?? dark.terminal
const darkTerminal = dark.terminal ?? light.terminal
return {
name: vscodeThemeSlug(result.displayName),
label: result.displayName,
description: `VS Code · ${result.extensionId}`,
colors: light ?? dark ?? fallback,
darkColors: dark ?? light ?? fallback
colors: light.palette,
darkColors: dark.palette,
...(terminal ? { terminal } : {}),
...(darkTerminal ? { darkTerminal } : {})
}
}
+35
View File
@@ -54,6 +54,37 @@ export interface DesktopThemeTypography {
fontUrl?: string
}
/**
* Integrated-terminal ANSI palette (xterm `ITheme`, minus `background`).
*
* Populated only when a converted VS Code theme ships a full `terminal.ansi*`
* set; otherwise the terminal keeps its built-in VS Code default palette.
* `background` is intentionally absent — the pane always paints the live skin
* surface so it stays translucent.
*/
export interface DesktopTerminalPalette {
foreground?: string
cursor?: string
/** Keeps its source alpha — xterm blends it over the surface. */
selectionBackground?: string
black?: string
red?: string
green?: string
yellow?: string
blue?: string
magenta?: string
cyan?: string
white?: string
brightBlack?: string
brightRed?: string
brightGreen?: string
brightYellow?: string
brightBlue?: string
brightMagenta?: string
brightCyan?: string
brightWhite?: string
}
export interface DesktopTheme {
name: string
label: string
@@ -63,4 +94,8 @@ export interface DesktopTheme {
/** Hand-tuned dark palette. Skins like `nous` ship one. */
darkColors?: DesktopThemeColors
typography?: Partial<DesktopThemeTypography>
/** Light-variant terminal ANSI palette (also the fallback for dark). */
terminal?: DesktopTerminalPalette
/** Dark-variant terminal ANSI palette. Falls back to `terminal`. */
darkTerminal?: DesktopTerminalPalette
}
+85 -2
View File
@@ -16,7 +16,7 @@
*/
import { ensureContrast, luminance, mix, normalizeHex, readableOn } from './color'
import type { DesktopTheme, DesktopThemeColors } from './types'
import type { DesktopTerminalPalette, DesktopTheme, DesktopThemeColors } from './types'
// Section headers / sidebar labels render in --theme-primary directly on the
// sidebar surface as small (~10px) uppercase text, so the accent has to clear
@@ -101,6 +101,85 @@ const isDarkType = (raw: VscodeColorTheme, background: string): boolean => {
return luminance(background) < 0.4
}
// xterm ITheme ANSI slots ← VS Code `terminal.ansi*` tokens. Background is
// deliberately excluded — the pane keeps the live skin surface (transparency).
const ANSI_TOKENS: ReadonlyArray<readonly [keyof DesktopTerminalPalette, string]> = [
['black', 'terminal.ansiBlack'],
['red', 'terminal.ansiRed'],
['green', 'terminal.ansiGreen'],
['yellow', 'terminal.ansiYellow'],
['blue', 'terminal.ansiBlue'],
['magenta', 'terminal.ansiMagenta'],
['cyan', 'terminal.ansiCyan'],
['white', 'terminal.ansiWhite'],
['brightBlack', 'terminal.ansiBrightBlack'],
['brightRed', 'terminal.ansiBrightRed'],
['brightGreen', 'terminal.ansiBrightGreen'],
['brightYellow', 'terminal.ansiBrightYellow'],
['brightBlue', 'terminal.ansiBrightBlue'],
['brightMagenta', 'terminal.ansiBrightMagenta'],
['brightCyan', 'terminal.ansiBrightCyan'],
['brightWhite', 'terminal.ansiBrightWhite']
]
const BASE_ANSI: ReadonlyArray<keyof DesktopTerminalPalette> = [
'black',
'red',
'green',
'yellow',
'blue',
'magenta',
'cyan',
'white'
]
const HEX_RE = /^#[0-9a-f]{3,8}$/i
/**
* Lift a theme's integrated-terminal ANSI palette, if it ships one.
*
* All-or-nothing on the base-8 colors: a half-filled palette mixed with our
* defaults reads worse than just keeping the defaults, so we adopt the theme's
* palette only when the full base set is present. ANSI slots flatten alpha over
* the editor background; selection keeps its alpha so xterm can blend it.
*/
function extractTerminalPalette(colors: Record<string, unknown>, background: string): DesktopTerminalPalette | undefined {
const hex = (key: string): string | undefined =>
normalizeHex(typeof colors[key] === 'string' ? (colors[key] as string) : null, background) ?? undefined
const palette: DesktopTerminalPalette = {}
for (const [slot, token] of ANSI_TOKENS) {
const value = hex(token)
if (value) {
palette[slot] = value
}
}
if (!BASE_ANSI.every(slot => palette[slot])) {
return undefined
}
const foreground = hex('terminal.foreground')
const cursor = hex('terminalCursor.foreground') ?? hex('terminalCursor.background')
const selection = typeof colors['terminal.selectionBackground'] === 'string' ? colors['terminal.selectionBackground'].trim() : ''
if (foreground) {
palette.foreground = foreground
}
if (cursor) {
palette.cursor = cursor
}
if (HEX_RE.test(selection)) {
palette.selectionBackground = selection
}
return palette
}
/** First normalizable hex among `keys`, composited over `backdrop`. */
const pick = (
colors: Record<string, unknown>,
@@ -242,6 +321,7 @@ export function convertVscodeColorTheme(raw: VscodeColorTheme, opts: ConvertOpti
const label = (opts.label ?? raw.name ?? 'VS Code Theme').trim()
const slug = opts.slug ?? vscodeThemeSlug(label)
const terminal = extractTerminalPalette(colors, background)
return {
derived,
@@ -254,7 +334,10 @@ export function convertVscodeColorTheme(raw: VscodeColorTheme, opts: ConvertOpti
// that have both a light and dark variant (a Marketplace extension family)
// recombine them into proper colors/darkColors via buildThemeFromMarketplace.
colors: palette,
darkColors: palette
darkColors: palette,
// Only set when the theme ships a full ANSI palette — the terminal keeps
// its built-in VS Code defaults otherwise.
...(terminal ? { terminal } : {})
}
}
}