Skip to content

Commit 5c6e8a5

Browse files
github-actions[bot]shivaamdependabot[bot]potiukjustinpakzad
authored andcommitted
[v3-2-test] UI: Rework Monaco editor theme to match Chakra UI palette (#64748) (#65228)
* [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> * [v3-2-test] Bump actions/github-script in the github-actions-updates group (#65150) (#65160) Bumps the github-actions-updates group with 1 update: [actions/github-script](https://github.com/actions/github-script). Updates `actions/github-script` from 8.0.0 to 9.0.0 - [Release notes](https://github.com/actions/github-script/releases) - [Commits](actions/github-script@ed59741...3a2844b) (cherry picked from commit e5a047c) --- updated-dependencies: - dependency-name: actions/github-script dependency-version: 9.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * [v3-2-test] Added breeze generate issue content for airflow-ctl (#65042) (#65241) * Add breeze generate issue content for airflow-ctl * add new command to doc (cherry picked from commit b24538b) Co-authored-by: Justin Pakzad <114518232+justinpakzad@users.noreply.github.com> * [v3-2-test] Run release calendar verification on its own schedule (#65118) (#65242) * Move release calendar verification to its own scheduled workflow Run dev/verify_release_calendar.py from a dedicated daily scheduled workflow instead of as a canary job in the main CI pipeline, and notify the #release-management Slack channel when the check fails so the issue is surfaced to release managers directly. * Include wiki and calendar links in release calendar Slack alert (cherry picked from commit 048e9a1) --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Shivam Rastogi <6463385+shivaam@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jarek Potiuk <jarek@potiuk.com> Co-authored-by: Justin Pakzad <114518232+justinpakzad@users.noreply.github.com>
1 parent 4ad460d commit 5c6e8a5

9 files changed

Lines changed: 258 additions & 15 deletions

File tree

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)