From 674e6159f6960883abfe44471092b518d39a5205 Mon Sep 17 00:00:00 2001 From: Shivam Rastogi <6463385+shivaam@users.noreply.github.com> Date: Sun, 5 Apr 2026 12:25:54 -0700 Subject: [PATCH 1/4] UI: Rework Monaco editor theme to match Chakra UI palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../airflow/ui/src/components/JsonEditor.tsx | 7 +- .../ui/src/components/RenderedJsonField.tsx | 6 +- .../airflow/ui/src/context/colorMode/index.ts | 1 + .../context/colorMode/useMonacoTheme.test.ts | 135 ++++++++++++++++++ .../src/context/colorMode/useMonacoTheme.ts | 126 ++++++++++++++++ .../airflow/ui/src/pages/Dag/Code/Code.tsx | 7 +- .../ui/src/pages/Dag/Code/CodeDiffViewer.tsx | 7 +- 7 files changed, 274 insertions(+), 15 deletions(-) create mode 100644 airflow-core/src/airflow/ui/src/context/colorMode/useMonacoTheme.test.ts create mode 100644 airflow-core/src/airflow/ui/src/context/colorMode/useMonacoTheme.ts diff --git a/airflow-core/src/airflow/ui/src/components/JsonEditor.tsx b/airflow-core/src/airflow/ui/src/components/JsonEditor.tsx index 3b9cc6f4e01a4..806b73b7d2625 100644 --- a/airflow-core/src/airflow/ui/src/components/JsonEditor.tsx +++ b/airflow-core/src/airflow/ui/src/components/JsonEditor.tsx @@ -19,7 +19,7 @@ import Editor, { type EditorProps } from "@monaco-editor/react"; import { useRef } from "react"; -import { useColorMode } from "src/context/colorMode"; +import { useMonacoTheme } from "src/context/colorMode"; type JsonEditorProps = { readonly editable?: boolean; @@ -39,7 +39,7 @@ export const JsonEditor = ({ value, ...rest }: JsonEditorProps) => { - const { colorMode } = useColorMode(); + const { beforeMount, theme } = useMonacoTheme(); const onBlurRef = useRef(onBlur); onBlurRef.current = onBlur; @@ -55,8 +55,6 @@ export const JsonEditor = ({ scrollBeyondLastLine: false, }; - const theme = colorMode === "dark" ? "vs-dark" : "vs-light"; - const handleChange = (val: string | undefined) => { onChange?.(val ?? ""); }; @@ -72,6 +70,7 @@ export const JsonEditor = ({ {...rest} > { const contentFormatted = JSON.stringify(content, undefined, 2); - const { colorMode } = useColorMode(); + const { beforeMount, theme } = useMonacoTheme(); const lineCount = contentFormatted.split("\n").length; const expandedHeight = Math.min(Math.max(lineCount * 19 + 10, MIN_HEIGHT), MAX_HEIGHT); const [editorHeight, setEditorHeight] = useState(collapsed ? MIN_HEIGHT : expandedHeight); const [isReady, setIsReady] = useState(!collapsed); - const theme = colorMode === "dark" ? "vs-dark" : "vs-light"; const handleMount: OnMount = useCallback( (editorInstance) => { @@ -75,6 +74,7 @@ const RenderedJsonField = ({ collapsed = false, content, enableClipboard = true, {...rest} > { colorMode: "dark" | "light" | undefined }>(); + +vi.mock("./useColorMode", () => ({ + useColorMode: () => colorModeMock(), +})); + +// The hook registers Monaco themes exactly once via a module-level flag. We +// reset modules between tests so each test starts with a fresh flag state. +const loadHook = async () => { + const module = await import("./useMonacoTheme"); + + return module.useMonacoTheme; +}; + +const createFakeMonaco = () => { + const defineTheme = vi.fn(); + + return { defineTheme, monaco: { editor: { defineTheme } } as unknown as Monaco }; +}; + +// happy-dom does not implement Canvas2D rendering, so the hook's `getContext` +// call would return null and short-circuit before registering themes. We stub +// it with the minimal surface the hook needs: the draw/clear methods and a +// `getImageData` that returns arbitrary RGB bytes (the exact hex values are +// irrelevant to these tests — we only care that theme registration happens). +const stubCanvasContext = () => { + const fakeContext = { + clearRect: vi.fn(), + fillRect: vi.fn(), + fillStyle: "", + getImageData: vi.fn(() => ({ data: new Uint8ClampedArray([0, 0, 0, 255]) })), + }; + + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue( + fakeContext as unknown as CanvasRenderingContext2D, + ); +}; + +describe("useMonacoTheme", () => { + beforeEach(() => { + vi.resetModules(); + colorModeMock.mockReturnValue({ colorMode: "light" }); + stubCanvasContext(); + }); + + it("returns the airflow-light theme name when color mode is light", async () => { + const useMonacoTheme = await loadHook(); + const { result } = renderHook(() => useMonacoTheme()); + + expect(result.current.theme).toBe("airflow-light"); + }); + + it("returns the airflow-dark theme name when color mode is dark", async () => { + colorModeMock.mockReturnValue({ colorMode: "dark" }); + const useMonacoTheme = await loadHook(); + const { result } = renderHook(() => useMonacoTheme()); + + expect(result.current.theme).toBe("airflow-dark"); + }); + + it("falls back to airflow-light when color mode is undefined", async () => { + colorModeMock.mockReturnValue({ colorMode: undefined }); + const useMonacoTheme = await loadHook(); + const { result } = renderHook(() => useMonacoTheme()); + + expect(result.current.theme).toBe("airflow-light"); + }); + + it("registers both airflow themes when beforeMount runs for the first time", async () => { + const useMonacoTheme = await loadHook(); + const { result } = renderHook(() => useMonacoTheme()); + const { defineTheme, monaco } = createFakeMonaco(); + + result.current.beforeMount(monaco); + + expect(defineTheme).toHaveBeenCalledTimes(2); + expect(defineTheme).toHaveBeenCalledWith("airflow-light", expect.objectContaining({ base: "vs" })); + expect(defineTheme).toHaveBeenCalledWith( + "airflow-dark", + expect.objectContaining({ base: "vs-dark" }), + ); + }); + + it("does not re-register themes on subsequent beforeMount calls", async () => { + const useMonacoTheme = await loadHook(); + const { result } = renderHook(() => useMonacoTheme()); + const first = createFakeMonaco(); + const second = createFakeMonaco(); + + result.current.beforeMount(first.monaco); + result.current.beforeMount(second.monaco); + + expect(first.defineTheme).toHaveBeenCalledTimes(2); + expect(second.defineTheme).not.toHaveBeenCalled(); + }); + + it("inherits from the base theme and adds no syntax token rules", async () => { + const useMonacoTheme = await loadHook(); + const { result } = renderHook(() => useMonacoTheme()); + const { defineTheme, monaco } = createFakeMonaco(); + + result.current.beforeMount(monaco); + + for (const call of defineTheme.mock.calls) { + const [, themeData] = call as [string, { inherit: boolean; rules: Array }]; + + expect(themeData.inherit).toBe(true); + expect(themeData.rules).toEqual([]); + } + }); +}); diff --git a/airflow-core/src/airflow/ui/src/context/colorMode/useMonacoTheme.ts b/airflow-core/src/airflow/ui/src/context/colorMode/useMonacoTheme.ts new file mode 100644 index 0000000000000..58dff6bea818d --- /dev/null +++ b/airflow-core/src/airflow/ui/src/context/colorMode/useMonacoTheme.ts @@ -0,0 +1,126 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import type { Monaco } from "@monaco-editor/react"; +import { useCallback } from "react"; + +import { useColorMode } from "./useColorMode"; + +const LIGHT_THEME_NAME = "airflow-light"; +const DARK_THEME_NAME = "airflow-dark"; + +let themesRegistered = false; + +// Convert any CSS color (including modern color spaces like OKLCH that Chakra +// UI uses) to a #rrggbb string that Monaco's `defineTheme` accepts. +// +// We rasterize a single pixel and read back its sRGB bytes via `getImageData`. +// `ctx.fillStyle` readback does NOT work for this: starting in Chrome 111, it +// preserves the original OKLCH string instead of converting to hex, which +// Monaco would silently ignore. +const cssVarToHex = (ctx: CanvasRenderingContext2D, cssVar: string): string => { + const value = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim(); + + if (value === "") { + return "#000000"; + } + + ctx.fillStyle = value; + ctx.clearRect(0, 0, 1, 1); + ctx.fillRect(0, 0, 1, 1); + const [red, green, blue] = ctx.getImageData(0, 0, 1, 1).data; + + return `#${[red, green, blue].map((channel) => (channel ?? 0).toString(16).padStart(2, "0")).join("")}`; +}; + +const defineAirflowMonacoThemes = (monaco: Monaco) => { + if (themesRegistered) { + return; + } + + const canvas = document.createElement("canvas"); + + canvas.width = 1; + canvas.height = 1; + const ctx = canvas.getContext("2d"); + + if (!ctx) { + return; + } + + const toHex = (cssVar: string) => cssVarToHex(ctx, cssVar); + + monaco.editor.defineTheme(LIGHT_THEME_NAME, { + base: "vs", + colors: { + "editor.background": toHex("--chakra-colors-gray-50"), + "editor.foreground": toHex("--chakra-colors-gray-900"), + "editor.inactiveSelectionBackground": toHex("--chakra-colors-gray-200"), + "editor.lineHighlightBackground": toHex("--chakra-colors-gray-100"), + "editor.selectionBackground": toHex("--chakra-colors-brand-200"), + "editorGutter.background": toHex("--chakra-colors-gray-50"), + "editorLineNumber.activeForeground": toHex("--chakra-colors-gray-700"), + "editorLineNumber.foreground": toHex("--chakra-colors-gray-400"), + "editorSuggestWidget.background": toHex("--chakra-colors-gray-50"), + "editorWidget.background": toHex("--chakra-colors-gray-50"), + "editorWidget.border": toHex("--chakra-colors-gray-300"), + "scrollbarSlider.activeBackground": `${toHex("--chakra-colors-gray-500")}c0`, + "scrollbarSlider.background": `${toHex("--chakra-colors-gray-300")}80`, + "scrollbarSlider.hoverBackground": `${toHex("--chakra-colors-gray-400")}a0`, + }, + inherit: true, + rules: [], + }); + + monaco.editor.defineTheme(DARK_THEME_NAME, { + base: "vs-dark", + colors: { + "editor.background": toHex("--chakra-colors-gray-900"), + "editor.foreground": toHex("--chakra-colors-gray-100"), + "editor.inactiveSelectionBackground": toHex("--chakra-colors-gray-800"), + "editor.lineHighlightBackground": toHex("--chakra-colors-gray-800"), + "editor.selectionBackground": toHex("--chakra-colors-brand-800"), + "editorGutter.background": toHex("--chakra-colors-gray-900"), + "editorLineNumber.activeForeground": toHex("--chakra-colors-gray-300"), + "editorLineNumber.foreground": toHex("--chakra-colors-gray-600"), + "editorSuggestWidget.background": toHex("--chakra-colors-gray-900"), + "editorWidget.background": toHex("--chakra-colors-gray-900"), + "editorWidget.border": toHex("--chakra-colors-gray-700"), + "scrollbarSlider.activeBackground": `${toHex("--chakra-colors-gray-500")}c0`, + "scrollbarSlider.background": `${toHex("--chakra-colors-gray-700")}80`, + "scrollbarSlider.hoverBackground": `${toHex("--chakra-colors-gray-600")}a0`, + }, + inherit: true, + rules: [], + }); + + themesRegistered = true; +}; + +export const useMonacoTheme = () => { + const { colorMode } = useColorMode(); + + const beforeMount = useCallback((monaco: Monaco) => { + defineAirflowMonacoThemes(monaco); + }, []); + + return { + beforeMount, + theme: colorMode === "dark" ? DARK_THEME_NAME : LIGHT_THEME_NAME, + }; +}; diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Code/Code.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Code/Code.tsx index 3d023c9f0904f..a3dd224f890d9 100644 --- a/airflow-core/src/airflow/ui/src/pages/Dag/Code/Code.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Code/Code.tsx @@ -38,7 +38,7 @@ import { ErrorAlert } from "src/components/ErrorAlert"; import Time from "src/components/Time"; import { ClipboardRoot, ClipboardButton, Tooltip } from "src/components/ui"; import { ProgressBar } from "src/components/ui"; -import { useColorMode } from "src/context/colorMode"; +import { useMonacoTheme } from "src/context/colorMode"; import useSelectedVersion from "src/hooks/useSelectedVersion"; import { useConfig } from "src/queries/useConfig"; import { renderDuration } from "src/utils"; @@ -115,7 +115,7 @@ export const Code = () => { setIsCompareDropdownOpen(false); }; - const { colorMode } = useColorMode(); + const { beforeMount, theme } = useMonacoTheme(); useHotkeys("w", toggleWrap); @@ -137,8 +137,6 @@ export const Code = () => { wordWrap: wrap ? "on" : "off", }; - const theme = colorMode === "dark" ? "vs-dark" : "vs-light"; - const hasMultipleVersions = (dagVersions?.dag_versions.length ?? 0) >= 2; return ( @@ -278,6 +276,7 @@ export const Code = () => { )} { - const { colorMode } = useColorMode(); + const { beforeMount, theme } = useMonacoTheme(); const diffOptions: DiffEditorProps["options"] = { automaticLayout: true, @@ -62,8 +62,6 @@ export const CodeDiffViewer = ({ }, }; - const theme = colorMode === "dark" ? "vs-dark" : "vs-light"; - return ( Date: Tue, 7 Apr 2026 23:27:13 -0700 Subject: [PATCH 2/4] 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/src/context/colorMode/useMonacoTheme.test.ts | 5 +---- .../src/airflow/ui/src/context/colorMode/useMonacoTheme.ts | 7 +------ 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/airflow-core/src/airflow/ui/src/context/colorMode/useMonacoTheme.test.ts b/airflow-core/src/airflow/ui/src/context/colorMode/useMonacoTheme.test.ts index 45b1698eb17c1..fd51aabfd47a1 100644 --- a/airflow-core/src/airflow/ui/src/context/colorMode/useMonacoTheme.test.ts +++ b/airflow-core/src/airflow/ui/src/context/colorMode/useMonacoTheme.test.ts @@ -99,10 +99,7 @@ describe("useMonacoTheme", () => { expect(defineTheme).toHaveBeenCalledTimes(2); expect(defineTheme).toHaveBeenCalledWith("airflow-light", expect.objectContaining({ base: "vs" })); - expect(defineTheme).toHaveBeenCalledWith( - "airflow-dark", - expect.objectContaining({ base: "vs-dark" }), - ); + expect(defineTheme).toHaveBeenCalledWith("airflow-dark", expect.objectContaining({ base: "vs-dark" })); }); it("does not re-register themes on subsequent beforeMount calls", async () => { diff --git a/airflow-core/src/airflow/ui/src/context/colorMode/useMonacoTheme.ts b/airflow-core/src/airflow/ui/src/context/colorMode/useMonacoTheme.ts index 58dff6bea818d..14f54fd13227b 100644 --- a/airflow-core/src/airflow/ui/src/context/colorMode/useMonacoTheme.ts +++ b/airflow-core/src/airflow/ui/src/context/colorMode/useMonacoTheme.ts @@ -17,7 +17,6 @@ * under the License. */ import type { Monaco } from "@monaco-editor/react"; -import { useCallback } from "react"; import { useColorMode } from "./useColorMode"; @@ -115,12 +114,8 @@ const defineAirflowMonacoThemes = (monaco: Monaco) => { export const useMonacoTheme = () => { const { colorMode } = useColorMode(); - const beforeMount = useCallback((monaco: Monaco) => { - defineAirflowMonacoThemes(monaco); - }, []); - return { - beforeMount, + beforeMount: defineAirflowMonacoThemes, theme: colorMode === "dark" ? DARK_THEME_NAME : LIGHT_THEME_NAME, }; }; From 7dba46aedc8e2700c379642a2eaf218bddd72c8c Mon Sep 17 00:00:00 2001 From: Shivam Rastogi <6463385+shivaam@users.noreply.github.com> Date: Sat, 11 Apr 2026 06:20:49 -0700 Subject: [PATCH 3/4] 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. --- airflow-core/src/airflow/ui/package.json | 2 ++ airflow-core/src/airflow/ui/pnpm-lock.yaml | 17 +++++++++ .../context/colorMode/useMonacoTheme.test.ts | 32 ++++++++--------- .../src/context/colorMode/useMonacoTheme.ts | 35 ++++--------------- 4 files changed, 39 insertions(+), 47 deletions(-) diff --git a/airflow-core/src/airflow/ui/package.json b/airflow-core/src/airflow/ui/package.json index f5ee19ce78505..6321f7e9076dd 100644 --- a/airflow-core/src/airflow/ui/package.json +++ b/airflow-core/src/airflow/ui/package.json @@ -43,6 +43,7 @@ "chart.js": "^4.5.1", "chartjs-adapter-dayjs-4": "^1.0.4", "chartjs-plugin-annotation": "^3.1.0", + "culori": "^4.0.2", "dayjs": "^1.11.19", "elkjs": "^0.11.1", "html-to-image": "^1.11.13", @@ -78,6 +79,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@trivago/prettier-plugin-sort-imports": "^4.3.0", + "@types/culori": "^4.0.1", "@types/node": "^24.10.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/airflow-core/src/airflow/ui/pnpm-lock.yaml b/airflow-core/src/airflow/ui/pnpm-lock.yaml index 261aea68aaa4e..f265f423e5048 100644 --- a/airflow-core/src/airflow/ui/pnpm-lock.yaml +++ b/airflow-core/src/airflow/ui/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: chartjs-plugin-annotation: specifier: ^3.1.0 version: 3.1.0(chart.js@4.5.1) + culori: + specifier: ^4.0.2 + version: 4.0.2 dayjs: specifier: ^1.11.19 version: 1.11.19 @@ -183,6 +186,9 @@ importers: '@trivago/prettier-plugin-sort-imports': specifier: ^4.3.0 version: 4.3.0(prettier@3.8.1) + '@types/culori': + specifier: ^4.0.1 + version: 4.0.1 '@types/node': specifier: ^24.10.1 version: 24.10.3 @@ -1298,6 +1304,9 @@ packages: '@types/chai@5.2.2': resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + '@types/culori@4.0.1': + resolution: {integrity: sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ==} + '@types/d3-array@3.0.3': resolution: {integrity: sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==} @@ -2175,6 +2184,10 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + culori@4.0.2: + resolution: {integrity: sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + d3-array@3.2.1: resolution: {integrity: sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==} engines: {node: '>=12'} @@ -5566,6 +5579,8 @@ snapshots: dependencies: '@types/deep-eql': 4.0.2 + '@types/culori@4.0.1': {} + '@types/d3-array@3.0.3': {} '@types/d3-color@3.1.0': {} @@ -6888,6 +6903,8 @@ snapshots: csstype@3.2.3: {} + culori@4.0.2: {} + d3-array@3.2.1: dependencies: internmap: 2.0.3 diff --git a/airflow-core/src/airflow/ui/src/context/colorMode/useMonacoTheme.test.ts b/airflow-core/src/airflow/ui/src/context/colorMode/useMonacoTheme.test.ts index fd51aabfd47a1..ff6812b9a0ec0 100644 --- a/airflow-core/src/airflow/ui/src/context/colorMode/useMonacoTheme.test.ts +++ b/airflow-core/src/airflow/ui/src/context/colorMode/useMonacoTheme.test.ts @@ -18,7 +18,7 @@ */ import type { Monaco } from "@monaco-editor/react"; import { renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // `useColorMode` is the only dependency of the hook we want to test. We mock // it with a mutable return so individual tests can drive light/dark behaviour. @@ -42,29 +42,25 @@ const createFakeMonaco = () => { return { defineTheme, monaco: { editor: { defineTheme } } as unknown as Monaco }; }; -// happy-dom does not implement Canvas2D rendering, so the hook's `getContext` -// call would return null and short-circuit before registering themes. We stub -// it with the minimal surface the hook needs: the draw/clear methods and a -// `getImageData` that returns arbitrary RGB bytes (the exact hex values are -// irrelevant to these tests — we only care that theme registration happens). -const stubCanvasContext = () => { - const fakeContext = { - clearRect: vi.fn(), - fillRect: vi.fn(), - fillStyle: "", - getImageData: vi.fn(() => ({ data: new Uint8ClampedArray([0, 0, 0, 255]) })), - }; - - vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue( - fakeContext as unknown as CanvasRenderingContext2D, - ); +// happy-dom does not resolve Chakra's CSS custom properties, so the hook's +// `getPropertyValue` calls would return empty strings. Stub it to return a +// parseable value — culori accepts plain hex, so the exact string doesn't +// matter as long as it's a valid CSS color the parser recognizes. +const stubComputedStyle = () => { + vi.spyOn(globalThis, "getComputedStyle").mockReturnValue({ + getPropertyValue: () => "#abcdef", + } as unknown as CSSStyleDeclaration); }; describe("useMonacoTheme", () => { beforeEach(() => { vi.resetModules(); colorModeMock.mockReturnValue({ colorMode: "light" }); - stubCanvasContext(); + stubComputedStyle(); + }); + + afterEach(() => { + vi.restoreAllMocks(); }); it("returns the airflow-light theme name when color mode is light", async () => { diff --git a/airflow-core/src/airflow/ui/src/context/colorMode/useMonacoTheme.ts b/airflow-core/src/airflow/ui/src/context/colorMode/useMonacoTheme.ts index 14f54fd13227b..86252e2d43316 100644 --- a/airflow-core/src/airflow/ui/src/context/colorMode/useMonacoTheme.ts +++ b/airflow-core/src/airflow/ui/src/context/colorMode/useMonacoTheme.ts @@ -17,6 +17,7 @@ * under the License. */ import type { Monaco } from "@monaco-editor/react"; +import { formatHex, parse } from "culori"; import { useColorMode } from "./useColorMode"; @@ -26,25 +27,13 @@ const DARK_THEME_NAME = "airflow-dark"; let themesRegistered = false; // Convert any CSS color (including modern color spaces like OKLCH that Chakra -// UI uses) to a #rrggbb string that Monaco's `defineTheme` accepts. -// -// We rasterize a single pixel and read back its sRGB bytes via `getImageData`. -// `ctx.fillStyle` readback does NOT work for this: starting in Chrome 111, it -// preserves the original OKLCH string instead of converting to hex, which -// Monaco would silently ignore. -const cssVarToHex = (ctx: CanvasRenderingContext2D, cssVar: string): string => { +// UI uses) to a #rrggbb string that Monaco's `defineTheme` accepts. culori +// handles parsing and gamut mapping; we fall back to black for unset or +// unparseable values so Monaco never sees an invalid color. +const toHex = (cssVar: string): string => { const value = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim(); - if (value === "") { - return "#000000"; - } - - ctx.fillStyle = value; - ctx.clearRect(0, 0, 1, 1); - ctx.fillRect(0, 0, 1, 1); - const [red, green, blue] = ctx.getImageData(0, 0, 1, 1).data; - - return `#${[red, green, blue].map((channel) => (channel ?? 0).toString(16).padStart(2, "0")).join("")}`; + return formatHex(parse(value)) ?? "#000000"; }; const defineAirflowMonacoThemes = (monaco: Monaco) => { @@ -52,18 +41,6 @@ const defineAirflowMonacoThemes = (monaco: Monaco) => { return; } - const canvas = document.createElement("canvas"); - - canvas.width = 1; - canvas.height = 1; - const ctx = canvas.getContext("2d"); - - if (!ctx) { - return; - } - - const toHex = (cssVar: string) => cssVarToHex(ctx, cssVar); - monaco.editor.defineTheme(LIGHT_THEME_NAME, { base: "vs", colors: { From 2f7abcb13925e9a5cee73f2f073dd8c670ea4240 Mon Sep 17 00:00:00 2001 From: Shivam Rastogi <6463385+shivaam@users.noreply.github.com> Date: Sat, 11 Apr 2026 08:28:14 -0700 Subject: [PATCH 4/4] UI: Fix codespell typo in useMonacoTheme comment unparseable -> unparsable. --- .../src/airflow/ui/src/context/colorMode/useMonacoTheme.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/airflow-core/src/airflow/ui/src/context/colorMode/useMonacoTheme.ts b/airflow-core/src/airflow/ui/src/context/colorMode/useMonacoTheme.ts index 86252e2d43316..6ecb1f8b89050 100644 --- a/airflow-core/src/airflow/ui/src/context/colorMode/useMonacoTheme.ts +++ b/airflow-core/src/airflow/ui/src/context/colorMode/useMonacoTheme.ts @@ -29,7 +29,7 @@ let themesRegistered = false; // Convert any CSS color (including modern color spaces like OKLCH that Chakra // UI uses) to a #rrggbb string that Monaco's `defineTheme` accepts. culori // handles parsing and gamut mapping; we fall back to black for unset or -// unparseable values so Monaco never sees an invalid color. +// unparsable values so Monaco never sees an invalid color. const toHex = (cssVar: string): string => { const value = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim();