Skip to content

Commit 2ad2e8e

Browse files
committed
feat(editor): follow the theme color for the editor background
The Monaco editor canvas was a fixed white / #171717 regardless of the selected theme color. Derive its background (editor, gutter, and peek surfaces) from the active theme's --card token so it reads as the app's card surface: the neutral and gray presets are unchanged and the accent presets carry their hue. Each Monaco theme name now encodes both light/dark and the color, and the sync hook watches data-theme so switching either axis re-applies through the theme prop.
1 parent eef28ed commit 2ad2e8e

2 files changed

Lines changed: 132 additions & 19 deletions

File tree

src/lib/monaco-themes.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@ import { describe, expect, it, vi } from "vitest"
33
import {
44
configureLanguageValidation,
55
defineMonacoThemes,
6+
EDITOR_CANVAS_BG,
7+
monacoThemeName,
68
MONACO_UNICODE_HIGHLIGHT_OPTIONS,
79
} from "./monaco-themes"
10+
import { THEME_COLORS } from "./theme-presets"
811

912
// Regression guard for issue #329: Monaco boxed ordinary CJK full-width
1013
// punctuation (`:` `;` `,` `!` `?` `(` `)` …) because its unicode-highlight
@@ -109,4 +112,50 @@ describe("configureLanguageValidation", () => {
109112
expect(tsDefaults.setDiagnosticsOptions).toHaveBeenCalled()
110113
expect(jsonDefaults.setDiagnosticsOptions).toHaveBeenCalled()
111114
})
115+
116+
it("registers one theme per (color, mode) with the canvas background applied", () => {
117+
const { monaco } = makeMonaco()
118+
const defineTheme = monaco.editor.defineTheme
119+
120+
defineMonacoThemes(
121+
monaco as unknown as Parameters<typeof defineMonacoThemes>[0]
122+
)
123+
124+
// 12 theme colors x {light, dark}.
125+
expect(defineTheme).toHaveBeenCalledTimes(THEME_COLORS.length * 2)
126+
127+
const call = defineTheme.mock.calls.find(
128+
(c) => c[0] === monacoThemeName("blue", true)
129+
)
130+
expect(call).toBeDefined()
131+
expect(call?.[1].colors["editor.background"]).toBe(
132+
EDITOR_CANVAS_BG.blue.dark
133+
)
134+
expect(call?.[1].colors["editorGutter.background"]).toBe(
135+
EDITOR_CANVAS_BG.blue.dark
136+
)
137+
})
138+
})
139+
140+
// The file editor is a card surface: its canvas background tracks each theme's
141+
// `--card` token, so it is no longer a fixed pure-white / #171717 regardless of theme.
142+
describe("EDITOR_CANVAS_BG", () => {
143+
it("has a valid light + dark hex for every theme color", () => {
144+
for (const color of THEME_COLORS) {
145+
expect(EDITOR_CANVAS_BG[color].light).toMatch(/^#[0-9a-f]{6}$/)
146+
expect(EDITOR_CANVAS_BG[color].dark).toMatch(/^#[0-9a-f]{6}$/)
147+
}
148+
})
149+
150+
it("keeps neutral identical to the previous fixed editor background", () => {
151+
// #ffffff / #171717 (= oklch(0.205 0 0), the --card dark) — a zero-visual-diff
152+
// baseline so only the accent presets change appearance.
153+
expect(EDITOR_CANVAS_BG.neutral.light).toBe("#ffffff")
154+
expect(EDITOR_CANVAS_BG.neutral.dark).toBe("#171717")
155+
})
156+
157+
it("encodes color and mode in the theme name", () => {
158+
expect(monacoThemeName("neutral", false)).toBe("codeg-light-neutral")
159+
expect(monacoThemeName("blue", true)).toBe("codeg-dark-blue")
160+
})
112161
})

src/lib/monaco-themes.ts

Lines changed: 83 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,46 @@
33
import { useEffect, useState } from "react"
44
import type { BeforeMount } from "@monaco-editor/react"
55
import type { editor as MonacoEditorNs } from "monaco-editor"
6+
import {
7+
THEME_COLORS,
8+
DEFAULT_THEME_COLOR,
9+
type ThemeColor,
10+
} from "./theme-presets"
611

7-
export const MONACO_LIGHT_THEME = "codeg-light"
8-
export const MONACO_DARK_THEME = "codeg-dark"
12+
// Editor canvas background per theme color = that theme's `--card` token (see the
13+
// [data-theme="…"] blocks in globals.css). The file editor is a card surface, so it
14+
// stays pure white for the neutral/gray presets in light mode and #171717
15+
// (= oklch(0.205 0 0)) for neutral dark — exactly today's values — while the accent
16+
// presets carry their hue. Keep in sync with the --card values in globals.css.
17+
export const EDITOR_CANVAS_BG: Record<
18+
ThemeColor,
19+
{ light: string; dark: string }
20+
> = {
21+
neutral: { light: "#ffffff", dark: "#171717" },
22+
zinc: { light: "#ffffff", dark: "#18181b" },
23+
slate: { light: "#ffffff", dark: "#0f172b" },
24+
stone: { light: "#ffffff", dark: "#1c1917" },
25+
gray: { light: "#ffffff", dark: "#101828" },
26+
red: { light: "#fffcfc", dark: "#1d1514" },
27+
rose: { light: "#fffcfc", dark: "#1d1515" },
28+
orange: { light: "#fffcfb", dark: "#1d1512" },
29+
green: { light: "#fbfefc", dark: "#131914" },
30+
blue: { light: "#fcfdff", dark: "#14171e" },
31+
yellow: { light: "#fefdfa", dark: "#1a1710" },
32+
violet: { light: "#fdfdff", dark: "#17161d" },
33+
}
34+
35+
// A Monaco theme name encodes both axes: light/dark mode and the active theme color.
36+
// `defineMonacoThemes` registers one per (color, mode); `useMonacoThemeSync` returns
37+
// the matching name so flipping either axis re-applies through the editor `theme` prop.
38+
export function monacoThemeName(color: ThemeColor, dark: boolean): string {
39+
return `codeg-${dark ? "dark" : "light"}-${color}`
40+
}
41+
42+
// Neutral-preset names, exported as the stable defaults (e.g. the initial value
43+
// before the DOM is read).
44+
export const MONACO_LIGHT_THEME = monacoThemeName(DEFAULT_THEME_COLOR, false)
45+
export const MONACO_DARK_THEME = monacoThemeName(DEFAULT_THEME_COLOR, true)
946

1047
// Monaco's "unicode highlight" feature boxes characters it deems ambiguous with
1148
// ASCII or non-basic-ASCII. Its default flags ordinary CJK full-width
@@ -442,24 +479,46 @@ export const configureLanguageValidation: BeforeMount = (monaco) => {
442479
}
443480
}
444481

482+
// Overlay the theme color's canvas background onto the base surface colors so the
483+
// editor / gutter / peek surfaces read as the app's card instead of a fixed
484+
// white/black. Only the opaque canvas is themed; widgets, selection and the
485+
// line-highlight band stay on their neutral shadcn values.
486+
function withCanvasBackground(
487+
base: (typeof monacoThemeColors)["light"],
488+
color: ThemeColor,
489+
dark: boolean
490+
): Record<string, string> {
491+
const bg = EDITOR_CANVAS_BG[color][dark ? "dark" : "light"]
492+
return {
493+
...base,
494+
"editor.background": bg,
495+
"editorGutter.background": bg,
496+
"peekViewEditor.background": bg,
497+
"peekViewEditorGutter.background": bg,
498+
}
499+
}
500+
445501
export const defineMonacoThemes: BeforeMount = (monaco) => {
446502
defineDiffLanguage(monaco)
447503
fixPythonTripleQuotes(monaco)
448504
configureLanguageValidation(monaco)
449505

450-
monaco.editor.defineTheme(MONACO_LIGHT_THEME, {
451-
base: "vs",
452-
inherit: true,
453-
rules: monacoTokenRules.light,
454-
colors: monacoThemeColors.light,
455-
})
456-
457-
monaco.editor.defineTheme(MONACO_DARK_THEME, {
458-
base: "vs-dark",
459-
inherit: true,
460-
rules: monacoTokenRules.dark,
461-
colors: monacoThemeColors.dark,
462-
})
506+
// One theme per (color, mode). `defineTheme` just stores a config, so this is
507+
// cheap and idempotent even re-run on every editor mount.
508+
for (const color of THEME_COLORS) {
509+
monaco.editor.defineTheme(monacoThemeName(color, false), {
510+
base: "vs",
511+
inherit: true,
512+
rules: monacoTokenRules.light,
513+
colors: withCanvasBackground(monacoThemeColors.light, color, false),
514+
})
515+
monaco.editor.defineTheme(monacoThemeName(color, true), {
516+
base: "vs-dark",
517+
inherit: true,
518+
rules: monacoTokenRules.dark,
519+
colors: withCanvasBackground(monacoThemeColors.dark, color, true),
520+
})
521+
}
463522
}
464523

465524
export function useMonacoThemeSync() {
@@ -470,17 +529,22 @@ export function useMonacoThemeSync() {
470529
const root = document.documentElement
471530

472531
const syncTheme = () => {
473-
setTheme(
474-
root.classList.contains("dark") ? MONACO_DARK_THEME : MONACO_LIGHT_THEME
475-
)
532+
const dark = root.classList.contains("dark")
533+
const attr = root.getAttribute("data-theme")
534+
const color = (THEME_COLORS as readonly string[]).includes(attr ?? "")
535+
? (attr as ThemeColor)
536+
: DEFAULT_THEME_COLOR
537+
setTheme(monacoThemeName(color, dark))
476538
}
477539

478540
syncTheme()
479541

542+
// Watch both axes: `class` (light/dark via next-themes) and `data-theme`
543+
// (theme color via AppearanceProvider). Either flip re-applies the matching theme.
480544
const observer = new MutationObserver(syncTheme)
481545
observer.observe(root, {
482546
attributes: true,
483-
attributeFilter: ["class"],
547+
attributeFilter: ["class", "data-theme"],
484548
})
485549
return () => {
486550
observer.disconnect()

0 commit comments

Comments
 (0)