Skip to content

Commit dab78eb

Browse files
big-guyclaude
andcommitted
fix(theme): normalize theme-derived colors to canonical hex
Tailwind v4 / Lightning CSS minifies hex literals in the generated CSS (`#ffffff` -> `#fff`), so reading a `--color-*` token back with getComputedStyle can yield 3/4-digit shorthand. Monaco's token color map validates with a strict 6/8-hex regex and throws `Illegal value for token color: #fff` — and since the standalone theme service folds `editor.background` into a token rule, a minified white app background (Hub Delight) crashes the editor on theme switch. The same shorthand also leaked into the Settings "Copy as JSON" output. Add normalizeThemeColor() and apply it everywhere a color is derived from a theme: monaco-setup readVar (fixes the crash), XTerminal terminal theme, Settings copy-as-JSON (emits canonical long-form hex), and effectiveAppBg. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 18d49c7 commit dab78eb

6 files changed

Lines changed: 101 additions & 9 deletions

File tree

src/renderer/components/Settings.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { AgentIcon } from './AgentIcon'
1212
import { InterfaceToggle } from './InterfaceToggle'
1313
import { BUILT_IN_THEMES_BY_MODE, type ThemeOption } from '../themes'
1414
import { SEMANTIC_KEYS } from '../theme-apply'
15+
import { normalizeThemeColor } from '../theme-color'
1516
import type { CustomTheme, UiScale } from '../../shared/state/settings'
1617
import { SCALES, scaleSpec } from '../../shared/state/settings'
1718
import { QRCodeSVG } from 'qrcode.react'
@@ -3478,7 +3479,10 @@ function readBuiltInThemeJson(opt: ThemeOption): string {
34783479
const colors: Record<string, string> = {}
34793480
for (const key of SEMANTIC_KEYS) {
34803481
const v = cs.getPropertyValue(`--color-${key}`).trim()
3481-
if (v) colors[key] = v
3482+
// Emit canonical long-form hex so the copied theme round-trips cleanly:
3483+
// the CSS pipeline minifies `#ffffff` to `#fff`, but a copied theme
3484+
// should read as the author wrote it. Non-hex values pass through as-is.
3485+
if (v) colors[key] = normalizeThemeColor(v, v)
34823486
}
34833487
return JSON.stringify({ name: opt.label, mode: opt.mode, colors }, null, 2) + '\n'
34843488
} finally {

src/renderer/components/XTerminal.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import '@xterm/xterm/css/xterm.css'
88
import type { StateEvent } from '../../shared/state'
99
import { getClientId, subscribeActiveTransportReconnect, useSettings, useTerminalSession } from '../store'
1010
import { getBackend, useBackend } from '../backend'
11+
import { normalizeThemeColor } from '../theme-color'
1112
import {
1213
makeFileLinkProvider,
1314
loadWorktreeFiles,
@@ -230,11 +231,14 @@ function buildTerminalTheme(
230231
bgVar = '--color-app'
231232
): NonNullable<ConstructorParameters<typeof Terminal>[0]>['theme'] {
232233
const rootStyle = getComputedStyle(document.documentElement)
233-
const bg =
234-
rootStyle.getPropertyValue(bgVar).trim() ||
235-
rootStyle.getPropertyValue('--color-app').trim() ||
234+
// Normalize theme-derived colors: minified shorthand like `#fff` is valid
235+
// for xterm, but keeping every theme-derived color canonical avoids surprises
236+
// and matches the Monaco path. See theme-color.ts.
237+
const bg = normalizeThemeColor(
238+
rootStyle.getPropertyValue(bgVar).trim() || rootStyle.getPropertyValue('--color-app'),
236239
'#0a0a0a'
237-
const fg = rootStyle.getPropertyValue('--color-fg-bright').trim() || '#e5e5e5'
240+
)
241+
const fg = normalizeThemeColor(rootStyle.getPropertyValue('--color-fg-bright'), '#e5e5e5')
238242
return {
239243
background: bg,
240244
foreground: fg,

src/renderer/monaco-setup.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// standalone chunk and returns a Worker constructor.
44
import * as monaco from 'monaco-editor'
55
import { DEFAULT_LIGHT_THEME, DEFAULT_DARK_THEME } from '../shared/state/settings'
6+
import { normalizeThemeColor } from './theme-color'
67
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
78
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'
89
import CssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'
@@ -131,8 +132,10 @@ configureTypescriptDefaults()
131132
// Pull current Tailwind theme tokens from a source element and build a Monaco
132133
// theme that tracks them. Called once at boot and on theme changes.
133134
function readVar(source: HTMLElement, name: string, fallback: string): string {
134-
const v = getComputedStyle(source).getPropertyValue(name).trim()
135-
return v || fallback
135+
const v = getComputedStyle(source).getPropertyValue(name)
136+
// Normalize to canonical hex: the value may be minified shorthand (`#fff`)
137+
// that Monaco's strict token color parser would throw on. See theme-color.ts.
138+
return normalizeThemeColor(v, fallback)
136139
}
137140

138141
/** Resolve a CSS color (hex/oklch/named/var) to its luminance and decide if

src/renderer/theme-apply.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { DEFAULT_LIGHT_THEME, DEFAULT_DARK_THEME } from '../shared/state/settings'
22
import type { ResolvedTheme } from './hooks/useActiveTheme'
3+
import { normalizeThemeColor } from './theme-color'
34

45
// Tracks which `--color-*` properties we set inline on the documentElement
56
// during the previous apply, so the next apply can clear leftovers
@@ -66,9 +67,10 @@ export function applyTheme(theme: ResolvedTheme): void {
6667
* Used as `lastEffectiveAppBg` so main can choose a matching window
6768
* background on the next boot. */
6869
export function effectiveAppBg(theme: ResolvedTheme): string {
70+
const fallback = theme.mode === 'dark' ? '#0a0a0a' : '#fdf6e3'
6971
if (theme.kind === 'custom') {
70-
return theme.colors.app ?? (theme.mode === 'dark' ? '#0a0a0a' : '#fdf6e3')
72+
return normalizeThemeColor(theme.colors.app ?? fallback, fallback)
7173
}
7274
// First swatch on every built-in is its app background hex.
73-
return theme.swatches[0]
75+
return normalizeThemeColor(theme.swatches[0] ?? fallback, fallback)
7476
}

src/renderer/theme-color.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { normalizeThemeColor } from './theme-color'
3+
4+
describe('normalizeThemeColor', () => {
5+
const FB = '#0a0a0a'
6+
7+
it('expands 3-digit shorthand (the Lightning CSS minification case)', () => {
8+
expect(normalizeThemeColor('#fff', FB)).toBe('#ffffff')
9+
expect(normalizeThemeColor('#000', FB)).toBe('#000000')
10+
expect(normalizeThemeColor('#abc', FB)).toBe('#aabbcc')
11+
})
12+
13+
it('expands 4-digit shorthand with alpha', () => {
14+
expect(normalizeThemeColor('#fff8', FB)).toBe('#ffffff88')
15+
expect(normalizeThemeColor('#0000', FB)).toBe('#00000000')
16+
})
17+
18+
it('passes valid 6- and 8-digit hex through unchanged', () => {
19+
expect(normalizeThemeColor('#24292e', FB)).toBe('#24292e')
20+
expect(normalizeThemeColor('#3a424d55', FB)).toBe('#3a424d55')
21+
})
22+
23+
it('trims surrounding whitespace before matching', () => {
24+
expect(normalizeThemeColor(' #fff ', FB)).toBe('#ffffff')
25+
expect(normalizeThemeColor('\t#24292e\n', FB)).toBe('#24292e')
26+
})
27+
28+
it('falls back for empty / whitespace-only input', () => {
29+
expect(normalizeThemeColor('', FB)).toBe(FB)
30+
expect(normalizeThemeColor(' ', FB)).toBe(FB)
31+
})
32+
33+
it('falls back for malformed-length hex', () => {
34+
expect(normalizeThemeColor('#ff', FB)).toBe(FB)
35+
expect(normalizeThemeColor('#fffff', FB)).toBe(FB)
36+
expect(normalizeThemeColor('#fffffff', FB)).toBe(FB)
37+
})
38+
39+
it('falls back for non-hex color forms Monaco token colors reject', () => {
40+
expect(normalizeThemeColor('white', FB)).toBe(FB)
41+
expect(normalizeThemeColor('rgb(255, 255, 255)', FB)).toBe(FB)
42+
expect(normalizeThemeColor('oklch(1 0 0)', FB)).toBe(FB)
43+
expect(normalizeThemeColor('var(--x)', FB)).toBe(FB)
44+
})
45+
})

src/renderer/theme-color.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Guard for colors derived from theme CSS custom properties.
2+
//
3+
// Why this exists: Tailwind v4 (via Lightning CSS) minifies hex literals in
4+
// the generated stylesheet — `#ffffff` becomes `#fff`, `#000000` becomes
5+
// `#000`, etc. So reading a `--color-*` token back with getComputedStyle can
6+
// hand us a 3- or 4-digit shorthand even though the source CSS wrote the long
7+
// form. Most consumers cope, but Monaco's token color map does not: its
8+
// ColorMap.getId validates with `/^#?([0-9A-Fa-f]{6})([0-9A-Fa-f]{2})?$/` and
9+
// THROWS `Illegal value for token color: <c>` on anything that isn't 6/8 hex.
10+
// Since `editor.foreground` / `editor.background` get folded into a token rule
11+
// by the standalone theme service, a minified `#fff` background crashes the
12+
// whole editor the moment it tokenizes (e.g. switching to the Hub Delight
13+
// theme). Normalizing every theme-derived color to canonical long-form hex
14+
// before it reaches a consumer keeps the strict ones happy.
15+
16+
/** Expand shorthand hex (`#rgb`/`#rgba`) to long form (`#rrggbb`/`#rrggbbaa`),
17+
* pass valid long-form hex through unchanged, and return `fallback` for
18+
* anything we can't confidently canonicalize (named colors, `rgb()`/`oklch()`,
19+
* malformed 5/7-digit hex, empty). The result is always safe to hand to
20+
* Monaco's strict token color parser when `fallback` itself is valid hex. */
21+
export function normalizeThemeColor(raw: string, fallback: string): string {
22+
const c = raw.trim()
23+
if (!c) return fallback
24+
const m = /^#([0-9a-fA-F]+)$/.exec(c)
25+
if (!m) return fallback
26+
const digits = m[1]
27+
if (digits.length === 3 || digits.length === 4) {
28+
let out = '#'
29+
for (const ch of digits) out += ch + ch
30+
return out
31+
}
32+
if (digits.length === 6 || digits.length === 8) return c
33+
return fallback
34+
}

0 commit comments

Comments
 (0)