Skip to content

Commit 470d3ef

Browse files
shivaamYour friendly bot
authored andcommitted
[v3-2-test] UI: Rework Monaco editor theme to match Chakra UI palette (#64748)
* UI: Rework Monaco editor theme to match Chakra UI palette Register custom airflow-light and airflow-dark Monaco themes derived from Chakra UI color tokens so that the DAG Code viewer, diff viewer, and JSON editors visually integrate with the rest of the app instead of using Monaco's default vs/vs-dark themes. The new useMonacoTheme hook rasterizes a single pixel through a 2D canvas and reads it back via getImageData to convert Chakra's OKLCH color values into the #rrggbb strings that Monaco's defineTheme accepts — ctx.fillStyle readback cannot be used because modern Chrome preserves the original OKLCH string. Themes are registered once via a module-level flag and then passed to every Monaco editor via the beforeMount callback. closes: #64253 * UI: Remove unnecessary useCallback in useMonacoTheme Address review feedback: React Compiler handles memoization, so the useCallback wrapper around defineAirflowMonacoThemes is redundant. Pass the function reference directly instead. Also fix prettier formatting in tests. * UI: Use culori for Monaco theme color conversion Address review feedback: the canvas-based cssVarToHex was brittle (depended on Canvas2D rendering and browser-specific fillStyle behavior, and required canvas mocking in happy-dom tests). Replace it with culori's parse/formatHex, which handles OKLCH and other modern color spaces directly with no DOM rasterization. Also add afterEach(vi.restoreAllMocks()) to the tests so the getComputedStyle spy does not leak between runs. * UI: Fix codespell typo in useMonacoTheme comment unparseable -> unparsable. (cherry picked from commit bfe46f6) Co-authored-by: Shivam Rastogi <6463385+shivaam@users.noreply.github.com>
1 parent a17d6b2 commit 470d3ef

File tree

9 files changed

+258
-15
lines changed

9 files changed

+258
-15
lines changed

airflow-core/src/airflow/ui/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"chart.js": "^4.5.1",
4444
"chartjs-adapter-dayjs-4": "^1.0.4",
4545
"chartjs-plugin-annotation": "^3.1.0",
46+
"culori": "^4.0.2",
4647
"dayjs": "^1.11.19",
4748
"elkjs": "^0.11.1",
4849
"html-to-image": "^1.11.13",
@@ -78,6 +79,7 @@
7879
"@testing-library/jest-dom": "^6.9.1",
7980
"@testing-library/react": "^16.3.2",
8081
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
82+
"@types/culori": "^4.0.1",
8183
"@types/node": "^24.10.1",
8284
"@types/react": "^19.2.14",
8385
"@types/react-dom": "^19.2.3",

airflow-core/src/airflow/ui/pnpm-lock.yaml

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

airflow-core/src/airflow/ui/src/components/JsonEditor.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import Editor, { type EditorProps } from "@monaco-editor/react";
2020
import { useRef } from "react";
2121

22-
import { useColorMode } from "src/context/colorMode";
22+
import { useMonacoTheme } from "src/context/colorMode";
2323

2424
type JsonEditorProps = {
2525
readonly editable?: boolean;
@@ -39,7 +39,7 @@ export const JsonEditor = ({
3939
value,
4040
...rest
4141
}: JsonEditorProps) => {
42-
const { colorMode } = useColorMode();
42+
const { beforeMount, theme } = useMonacoTheme();
4343
const onBlurRef = useRef(onBlur);
4444

4545
onBlurRef.current = onBlur;
@@ -55,8 +55,6 @@ export const JsonEditor = ({
5555
scrollBeyondLastLine: false,
5656
};
5757

58-
const theme = colorMode === "dark" ? "vs-dark" : "vs-light";
59-
6058
const handleChange = (val: string | undefined) => {
6159
onChange?.(val ?? "");
6260
};
@@ -72,6 +70,7 @@ export const JsonEditor = ({
7270
{...rest}
7371
>
7472
<Editor
73+
beforeMount={beforeMount}
7574
height={height}
7675
language="json"
7776
onChange={handleChange}

airflow-core/src/airflow/ui/src/components/RenderedJsonField.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import Editor, { type OnMount } from "@monaco-editor/react";
2121
import { useCallback, useState } from "react";
2222

2323
import { ClipboardRoot, ClipboardIconButton } from "src/components/ui";
24-
import { useColorMode } from "src/context/colorMode";
24+
import { useMonacoTheme } from "src/context/colorMode";
2525

2626
const MAX_HEIGHT = 300;
2727
const MIN_HEIGHT = 40;
@@ -34,12 +34,11 @@ type Props = {
3434

3535
const RenderedJsonField = ({ collapsed = false, content, enableClipboard = true, ...rest }: Props) => {
3636
const contentFormatted = JSON.stringify(content, undefined, 2);
37-
const { colorMode } = useColorMode();
37+
const { beforeMount, theme } = useMonacoTheme();
3838
const lineCount = contentFormatted.split("\n").length;
3939
const expandedHeight = Math.min(Math.max(lineCount * 19 + 10, MIN_HEIGHT), MAX_HEIGHT);
4040
const [editorHeight, setEditorHeight] = useState(collapsed ? MIN_HEIGHT : expandedHeight);
4141
const [isReady, setIsReady] = useState(!collapsed);
42-
const theme = colorMode === "dark" ? "vs-dark" : "vs-light";
4342

4443
const handleMount: OnMount = useCallback(
4544
(editorInstance) => {
@@ -75,6 +74,7 @@ const RenderedJsonField = ({ collapsed = false, content, enableClipboard = true,
7574
{...rest}
7675
>
7776
<Editor
77+
beforeMount={beforeMount}
7878
height={`${editorHeight}px`}
7979
language="json"
8080
onMount={handleMount}

airflow-core/src/airflow/ui/src/context/colorMode/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@
1919

2020
export * from "./ColorModeProvider";
2121
export * from "./useColorMode";
22+
export * from "./useMonacoTheme";
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/*!
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
import type { Monaco } from "@monaco-editor/react";
20+
import { renderHook } from "@testing-library/react";
21+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22+
23+
// `useColorMode` is the only dependency of the hook we want to test. We mock
24+
// it with a mutable return so individual tests can drive light/dark behaviour.
25+
const colorModeMock = vi.fn<() => { colorMode: "dark" | "light" | undefined }>();
26+
27+
vi.mock("./useColorMode", () => ({
28+
useColorMode: () => colorModeMock(),
29+
}));
30+
31+
// The hook registers Monaco themes exactly once via a module-level flag. We
32+
// reset modules between tests so each test starts with a fresh flag state.
33+
const loadHook = async () => {
34+
const module = await import("./useMonacoTheme");
35+
36+
return module.useMonacoTheme;
37+
};
38+
39+
const createFakeMonaco = () => {
40+
const defineTheme = vi.fn();
41+
42+
return { defineTheme, monaco: { editor: { defineTheme } } as unknown as Monaco };
43+
};
44+
45+
// happy-dom does not resolve Chakra's CSS custom properties, so the hook's
46+
// `getPropertyValue` calls would return empty strings. Stub it to return a
47+
// parseable value — culori accepts plain hex, so the exact string doesn't
48+
// matter as long as it's a valid CSS color the parser recognizes.
49+
const stubComputedStyle = () => {
50+
vi.spyOn(globalThis, "getComputedStyle").mockReturnValue({
51+
getPropertyValue: () => "#abcdef",
52+
} as unknown as CSSStyleDeclaration);
53+
};
54+
55+
describe("useMonacoTheme", () => {
56+
beforeEach(() => {
57+
vi.resetModules();
58+
colorModeMock.mockReturnValue({ colorMode: "light" });
59+
stubComputedStyle();
60+
});
61+
62+
afterEach(() => {
63+
vi.restoreAllMocks();
64+
});
65+
66+
it("returns the airflow-light theme name when color mode is light", async () => {
67+
const useMonacoTheme = await loadHook();
68+
const { result } = renderHook(() => useMonacoTheme());
69+
70+
expect(result.current.theme).toBe("airflow-light");
71+
});
72+
73+
it("returns the airflow-dark theme name when color mode is dark", async () => {
74+
colorModeMock.mockReturnValue({ colorMode: "dark" });
75+
const useMonacoTheme = await loadHook();
76+
const { result } = renderHook(() => useMonacoTheme());
77+
78+
expect(result.current.theme).toBe("airflow-dark");
79+
});
80+
81+
it("falls back to airflow-light when color mode is undefined", async () => {
82+
colorModeMock.mockReturnValue({ colorMode: undefined });
83+
const useMonacoTheme = await loadHook();
84+
const { result } = renderHook(() => useMonacoTheme());
85+
86+
expect(result.current.theme).toBe("airflow-light");
87+
});
88+
89+
it("registers both airflow themes when beforeMount runs for the first time", async () => {
90+
const useMonacoTheme = await loadHook();
91+
const { result } = renderHook(() => useMonacoTheme());
92+
const { defineTheme, monaco } = createFakeMonaco();
93+
94+
result.current.beforeMount(monaco);
95+
96+
expect(defineTheme).toHaveBeenCalledTimes(2);
97+
expect(defineTheme).toHaveBeenCalledWith("airflow-light", expect.objectContaining({ base: "vs" }));
98+
expect(defineTheme).toHaveBeenCalledWith("airflow-dark", expect.objectContaining({ base: "vs-dark" }));
99+
});
100+
101+
it("does not re-register themes on subsequent beforeMount calls", async () => {
102+
const useMonacoTheme = await loadHook();
103+
const { result } = renderHook(() => useMonacoTheme());
104+
const first = createFakeMonaco();
105+
const second = createFakeMonaco();
106+
107+
result.current.beforeMount(first.monaco);
108+
result.current.beforeMount(second.monaco);
109+
110+
expect(first.defineTheme).toHaveBeenCalledTimes(2);
111+
expect(second.defineTheme).not.toHaveBeenCalled();
112+
});
113+
114+
it("inherits from the base theme and adds no syntax token rules", async () => {
115+
const useMonacoTheme = await loadHook();
116+
const { result } = renderHook(() => useMonacoTheme());
117+
const { defineTheme, monaco } = createFakeMonaco();
118+
119+
result.current.beforeMount(monaco);
120+
121+
for (const call of defineTheme.mock.calls) {
122+
const [, themeData] = call as [string, { inherit: boolean; rules: Array<unknown> }];
123+
124+
expect(themeData.inherit).toBe(true);
125+
expect(themeData.rules).toEqual([]);
126+
}
127+
});
128+
});
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*!
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
import type { Monaco } from "@monaco-editor/react";
20+
import { formatHex, parse } from "culori";
21+
22+
import { useColorMode } from "./useColorMode";
23+
24+
const LIGHT_THEME_NAME = "airflow-light";
25+
const DARK_THEME_NAME = "airflow-dark";
26+
27+
let themesRegistered = false;
28+
29+
// Convert any CSS color (including modern color spaces like OKLCH that Chakra
30+
// UI uses) to a #rrggbb string that Monaco's `defineTheme` accepts. culori
31+
// handles parsing and gamut mapping; we fall back to black for unset or
32+
// unparsable values so Monaco never sees an invalid color.
33+
const toHex = (cssVar: string): string => {
34+
const value = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim();
35+
36+
return formatHex(parse(value)) ?? "#000000";
37+
};
38+
39+
const defineAirflowMonacoThemes = (monaco: Monaco) => {
40+
if (themesRegistered) {
41+
return;
42+
}
43+
44+
monaco.editor.defineTheme(LIGHT_THEME_NAME, {
45+
base: "vs",
46+
colors: {
47+
"editor.background": toHex("--chakra-colors-gray-50"),
48+
"editor.foreground": toHex("--chakra-colors-gray-900"),
49+
"editor.inactiveSelectionBackground": toHex("--chakra-colors-gray-200"),
50+
"editor.lineHighlightBackground": toHex("--chakra-colors-gray-100"),
51+
"editor.selectionBackground": toHex("--chakra-colors-brand-200"),
52+
"editorGutter.background": toHex("--chakra-colors-gray-50"),
53+
"editorLineNumber.activeForeground": toHex("--chakra-colors-gray-700"),
54+
"editorLineNumber.foreground": toHex("--chakra-colors-gray-400"),
55+
"editorSuggestWidget.background": toHex("--chakra-colors-gray-50"),
56+
"editorWidget.background": toHex("--chakra-colors-gray-50"),
57+
"editorWidget.border": toHex("--chakra-colors-gray-300"),
58+
"scrollbarSlider.activeBackground": `${toHex("--chakra-colors-gray-500")}c0`,
59+
"scrollbarSlider.background": `${toHex("--chakra-colors-gray-300")}80`,
60+
"scrollbarSlider.hoverBackground": `${toHex("--chakra-colors-gray-400")}a0`,
61+
},
62+
inherit: true,
63+
rules: [],
64+
});
65+
66+
monaco.editor.defineTheme(DARK_THEME_NAME, {
67+
base: "vs-dark",
68+
colors: {
69+
"editor.background": toHex("--chakra-colors-gray-900"),
70+
"editor.foreground": toHex("--chakra-colors-gray-100"),
71+
"editor.inactiveSelectionBackground": toHex("--chakra-colors-gray-800"),
72+
"editor.lineHighlightBackground": toHex("--chakra-colors-gray-800"),
73+
"editor.selectionBackground": toHex("--chakra-colors-brand-800"),
74+
"editorGutter.background": toHex("--chakra-colors-gray-900"),
75+
"editorLineNumber.activeForeground": toHex("--chakra-colors-gray-300"),
76+
"editorLineNumber.foreground": toHex("--chakra-colors-gray-600"),
77+
"editorSuggestWidget.background": toHex("--chakra-colors-gray-900"),
78+
"editorWidget.background": toHex("--chakra-colors-gray-900"),
79+
"editorWidget.border": toHex("--chakra-colors-gray-700"),
80+
"scrollbarSlider.activeBackground": `${toHex("--chakra-colors-gray-500")}c0`,
81+
"scrollbarSlider.background": `${toHex("--chakra-colors-gray-700")}80`,
82+
"scrollbarSlider.hoverBackground": `${toHex("--chakra-colors-gray-600")}a0`,
83+
},
84+
inherit: true,
85+
rules: [],
86+
});
87+
88+
themesRegistered = true;
89+
};
90+
91+
export const useMonacoTheme = () => {
92+
const { colorMode } = useColorMode();
93+
94+
return {
95+
beforeMount: defineAirflowMonacoThemes,
96+
theme: colorMode === "dark" ? DARK_THEME_NAME : LIGHT_THEME_NAME,
97+
};
98+
};

0 commit comments

Comments
 (0)