This is an automated email from the ASF dual-hosted git repository.
pierrejeambrun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 16754b56dae Restore graph task and group coloring via Chakra palette
tokens (#68760)
16754b56dae is described below
commit 16754b56dae04866718417c73895924abe2ed1c3
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Fri Jul 17 16:30:58 2026 +0200
Restore graph task and group coloring via Chakra palette tokens (#68760)
* Restore graph task and group coloring via Chakra palette tokens
Custom operator and TaskGroup colors disappeared in Airflow 3: the
ui_color/ui_fgcolor attributes were kept but the new UI ignored them, so
teams that relied on color to parse large graphs at a glance lost that
signal (see the demand on the linked issue).
Bring the coloring back in a way that fits the new opinionated theming:
the value must be a Chakra palette token (e.g. blue.500), which resolves
through a theme-controlled CSS variable and therefore stays legible in
both light and dark mode and under custom themes -- the dark-mode and
accessibility concerns that drove the original removal. The graph paints
the node fill from ui_color and the operator label from ui_fgcolor, as in
Airflow 2.x, while the border keeps showing run state. Legacy hex/named
values can no longer adapt to the theme, so they are ignored by the graph
and emit a warning for user-authored operators and task groups.
* Color task groups in the graph view, including when expanded
ui_color and ui_fgcolor are documented as the fill and label colors of a
TaskGroup node, and Airflow 2.x painted them, but the graph excluded groups
from the custom fill and dropped the colors entirely once a group was
expanded. Treat groups like leaf tasks so a token-valued group color renders
its fill and label in both the collapsed and expanded states.
* Accept raw hex colors and Chakra tokens for graph node coloring
Review feedback asked to keep the Airflow 2 behavior where ui_color and
ui_fgcolor accept a raw color (a hex code or CSS name) in addition to Chakra
palette tokens, so existing Dags keep their graph colors without any change.
Dark-mode color variants are deferred to a follow-up.
* Adjustments
* Fix unreadable graph task text on light custom colors in dark mode
The task icon and label fell back to the theme's default foreground for
Chakra token fills, so a light token such as yellow.200 rendered light text on
a light fill in dark mode. Deriving the text color from the resolved fill
measures tokens too, so contrast holds for hex, CSS names, and tokens alike.
* Update task group graph test fixture for restored node colors
The graph serializer now emits each node's ui_color/ui_fgcolor for task
and group nodes, so the expected graph fixture must include them; join
nodes stay uncolored. The fixture was stale and omitted these fields,
failing test_task_group_to_dict_serialized_dag and
test_task_group_to_dict_alternative_syntax.
* Document semantic theme tokens for ui_color and ui_fgcolor
The graph node color attributes accept theme-aware semantic tokens (e.g.
brand.solid) that adapt to light and dark mode, in addition to palette
tokens
and raw colors — but the docs only mentioned palette tokens, so users had no
pointer to the semantic tokens available in the UI theme.
---
airflow-core/docs/howto/custom-operator.rst | 14 +--
airflow-core/newsfragments/68760.significant.rst | 11 +++
.../core_api/datamodels/ui/structure.py | 2 +
.../api_fastapi/core_api/openapi/_private_ui.yaml | 10 +++
.../api_fastapi/core_api/services/ui/task_group.py | 9 ++
.../airflow/ui/openapi-gen/requests/schemas.gen.ts | 22 +++++
.../airflow/ui/openapi-gen/requests/types.gen.ts | 2 +
.../ui/src/components/Graph/TaskNode.test.tsx | 100 +++++++++++++++++++++
.../airflow/ui/src/components/Graph/TaskNode.tsx | 37 +++++++-
.../ui/src/components/Graph/elkGraphUtils.test.ts | 56 ++++++++++++
.../ui/src/components/Graph/elkGraphUtils.ts | 6 ++
.../airflow/ui/src/components/Graph/nodeColors.ts | 85 ++++++++++++++++++
.../ui/src/components/Graph/reactflowUtils.ts | 2 +
.../core_api/routes/ui/test_structure.py | 79 ++++++++++++++++
airflow-core/tests/unit/utils/test_task_group.py | 13 ++-
task-sdk/src/airflow/sdk/definitions/taskgroup.py | 16 +++-
16 files changed, 449 insertions(+), 15 deletions(-)
diff --git a/airflow-core/docs/howto/custom-operator.rst
b/airflow-core/docs/howto/custom-operator.rst
index f250aa7447b..858b1154c41 100644
--- a/airflow-core/docs/howto/custom-operator.rst
+++ b/airflow-core/docs/howto/custom-operator.rst
@@ -132,16 +132,20 @@ The ``execute`` gets called only during a Dag run.
User interface
--------------
-Airflow also allows the developer to control how the operator shows up in the
Dag UI.
-Override ``ui_color`` to change the background color of the operator in UI.
-Override ``ui_fgcolor`` to change the color of the label.
+Airflow also allows the developer to control how the operator shows up in the
Dag graph view.
+Override ``ui_color`` to change the node's fill color and ``ui_fgcolor`` to
change its label color.
+Each accepts a raw color (a hex code or a CSS color name) or a `Chakra
<https://www.chakra-ui.com/docs/theming/colors>`__
+theme token: a palette token such as ``blue.500``, or a **semantic token**
such as ``brand.solid``
+that is defined in the UI theme and adapts to light and dark mode. Theme
tokens -- including any
+added through a custom UI theme -- resolve through a theme-controlled CSS
variable, so they stay
+legible across color modes.
Override ``custom_operator_name`` to change the displayed name to something
other than the classname.
.. code-block:: python
class HelloOperator(BaseOperator):
- ui_color = "#ff0000"
- ui_fgcolor = "#000000"
+ ui_color = "blue.500" # a Chakra palette token
+ ui_fgcolor = "brand.contrast" # a semantic token that adapts to
light and dark mode
custom_operator_name = "Howdy"
# ...
diff --git a/airflow-core/newsfragments/68760.significant.rst
b/airflow-core/newsfragments/68760.significant.rst
new file mode 100644
index 00000000000..5b518af9993
--- /dev/null
+++ b/airflow-core/newsfragments/68760.significant.rst
@@ -0,0 +1,11 @@
+Custom graph colors return via ``ui_color`` and ``ui_fgcolor``
+
+The ``ui_color`` and ``ui_fgcolor`` attributes on operators and TaskGroups
color the graph view
+again -- the node fill from ``ui_color`` and the operator label from
``ui_fgcolor``, as they did in
+Airflow 2. They were silently ignored since Airflow 3.0.
+
+The value can be a raw color (a hex code like ``#e8b7e4`` or a CSS name) or a
Chakra theme token --
+a palette token such as ``blue.500`` or a semantic token such as
``brand.solid`` that adapts to
+light and dark mode (including tokens added through custom UI theming); a
token resolves through a
+theme-controlled CSS variable. The node border keeps reflecting the
task-instance run state; the
+custom color is applied to the fill and label only.
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/structure.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/structure.py
index 57f052dbc42..5d89b08ee98 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/structure.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/structure.py
@@ -42,6 +42,8 @@ class NodeResponse(BaseNodeResponse):
setup_teardown_type: Literal["setup", "teardown"] | None = None
operator: str | None = None
asset_condition_type: Literal["or-gate", "and-gate"] | None = None
+ ui_color: str | None = None
+ ui_fgcolor: str | None = None
class StructureDataResponse(BaseGraphResponse[EdgeResponse, NodeResponse]):
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
index bcfe8c708ef..00eaa55a5aa 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
+++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
@@ -3664,6 +3664,16 @@ components:
- and-gate
- type: 'null'
title: Asset Condition Type
+ ui_color:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Ui Color
+ ui_fgcolor:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Ui Fgcolor
type: object
required:
- id
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/services/ui/task_group.py
b/airflow-core/src/airflow/api_fastapi/core_api/services/ui/task_group.py
index 47d49757e69..139e0844091 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/services/ui/task_group.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/services/ui/task_group.py
@@ -37,6 +37,13 @@ def get_task_group_children_getter() -> Callable:
return methodcaller("hierarchical_alphabetical_sort")
+def _ui_colors(node) -> dict[str, str]:
+ """Return the node's ``ui_color``/``ui_fgcolor`` values (hex or Chakra
token) for the graph."""
+ return {
+ key: value for key in ("ui_color", "ui_fgcolor") if (value :=
getattr(node, key, None)) is not None
+ }
+
+
def task_group_to_dict(task_item_or_group, parent_group_is_mapped=False):
"""Create a nested dict representation of this TaskGroup and its children
used to construct the Graph."""
if isinstance(task := task_item_or_group, (SerializedBaseOperator,
SerializedMappedOperator)):
@@ -47,6 +54,7 @@ def task_group_to_dict(task_item_or_group,
parent_group_is_mapped=False):
"label": task_display_name,
"operator": task.operator_name,
"type": "task",
+ **_ui_colors(task),
}
if task.is_setup:
node_operator["setup_teardown_type"] = "setup"
@@ -78,6 +86,7 @@ def task_group_to_dict(task_item_or_group,
parent_group_is_mapped=False):
"is_mapped": mapped,
"children": children,
"type": "task",
+ **_ui_colors(task_group),
}
return node
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
index a2fc21293c8..6105a801215 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -10342,6 +10342,28 @@ export const $NodeResponse = {
}
],
title: 'Asset Condition Type'
+ },
+ ui_color: {
+ anyOf: [
+ {
+ type: 'string'
+ },
+ {
+ type: 'null'
+ }
+ ],
+ title: 'Ui Color'
+ },
+ ui_fgcolor: {
+ anyOf: [
+ {
+ type: 'string'
+ },
+ {
+ type: 'null'
+ }
+ ],
+ title: 'Ui Fgcolor'
}
},
type: 'object',
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
index fa719ec13df..901e20ccc13 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -2623,6 +2623,8 @@ export type NodeResponse = {
setup_teardown_type?: 'setup' | 'teardown' | null;
operator?: string | null;
asset_condition_type?: 'or-gate' | 'and-gate' | null;
+ ui_color?: string | null;
+ ui_fgcolor?: string | null;
};
export type OklchColor = string;
diff --git a/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.test.tsx
b/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.test.tsx
new file mode 100644
index 00000000000..821d0ac2fbe
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.test.tsx
@@ -0,0 +1,100 @@
+/*!
+ * 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 { render } from "@testing-library/react";
+import { ReactFlowProvider } from "@xyflow/react";
+import type { ComponentProps, ReactNode } from "react";
+import { describe, expect, it, vi } from "vitest";
+
+import { Wrapper } from "src/utils/Wrapper";
+
+import { TaskNode } from "./TaskNode";
+import { readableTextForFill } from "./nodeColors";
+import type { CustomNodeProps } from "./reactflowUtils";
+
+vi.mock("src/context/groups", () => ({
+ useGroups: vi.fn(() => ({ toggleGroupId: vi.fn() })),
+}));
+
+const TestWrapper = ({ children }: { readonly children: ReactNode }) => (
+ <Wrapper>
+ <ReactFlowProvider>{children}</ReactFlowProvider>
+ </Wrapper>
+);
+
+// Chakra/Panda hashes color props into atomic class names rather than inline
styles, so the resolved
+// colour cannot be read back in jsdom. Instead we render two
otherwise-identical nodes that differ
+// only in the prop under test: any markup difference is attributable to that
prop (hashing is
+// deterministic), and identical markup proves the prop had no effect.
+const renderHtml = (data: Partial<CustomNodeProps>): string => {
+ const { container } = render(
+ // The xyflow NodeProps surface is large; the component only reads `data`
and `id`.
+ <TaskNode
+ {...({
+ data: { height: 80, id: "t1", label: "t1", type: "task", width: 200,
...data },
+ } as unknown as ComponentProps<typeof TaskNode>)}
+ />,
+ { wrapper: TestWrapper },
+ );
+
+ return container.innerHTML;
+};
+
+describe("TaskNode operator colors", () => {
+ it("tints a leaf task when ui_color is set", () => {
+ expect(renderHtml({ operator: "BashOperator", uiColor: "blue.500"
})).not.toBe(
+ renderHtml({ operator: "BashOperator" }),
+ );
+ });
+
+ it("colors the operator text when ui_fgcolor is set", () => {
+ expect(renderHtml({ operator: "BashOperator", uiFgcolor: "red.700"
})).not.toBe(
+ renderHtml({ operator: "BashOperator" }),
+ );
+ });
+
+ it("tints a leaf task when ui_color is a raw hex color", () => {
+ expect(renderHtml({ operator: "BashOperator", uiColor: "#e8b7e4"
})).not.toBe(
+ renderHtml({ operator: "BashOperator" }),
+ );
+ });
+
+ it("tints a group node when ui_color is a token (2.x parity: ui_color is the
group fill)", () => {
+ expect(renderHtml({ isGroup: true, uiColor: "blue.500"
})).not.toBe(renderHtml({ isGroup: true }));
+ });
+
+ it("alternates the group fill shade by nesting depth so nested groups stay
distinct", () => {
+ expect(renderHtml({ depth: 0, isGroup: true, isOpen: true, uiColor:
"blue.500" })).not.toBe(
+ renderHtml({ depth: 1, isGroup: true, isOpen: true, uiColor: "blue.500"
}),
+ );
+ });
+});
+
+describe("readableTextForFill", () => {
+ it.each([
+ { color: "#ffffff", expected: "black" },
+ { color: "#fff", expected: "black" },
+ { color: "#000000", expected: "gray.50" },
+ { color: "#e8b7e4", expected: "black" },
+ { color: "#1f77b4", expected: "gray.50" },
+ { color: "blue.500", expected: undefined },
+ { color: undefined, expected: undefined },
+ ])("returns $expected for a fill of $color", ({ color, expected }) => {
+ expect(readableTextForFill(color)).toBe(expected);
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.tsx
b/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.tsx
index 0efe7dbbb81..e35a4c2a47b 100644
--- a/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.tsx
+++ b/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Box, Button, Flex, HStack, LinkOverlay, Text } from
"@chakra-ui/react";
+import { Box, Button, Flex, HStack, LinkOverlay, Text, useToken } from
"@chakra-ui/react";
import type { NodeProps, Node as NodeType } from "@xyflow/react";
import { useTranslation } from "react-i18next";
import { AiOutlineGroup } from "react-icons/ai";
@@ -30,8 +30,14 @@ import { NodeWrapper } from "./NodeWrapper";
import { SegmentedStateBar } from "./SegmentedStateBar";
import { TaskLink } from "./TaskLink";
import { opacityStyle } from "./graphTypes";
+import { readableTextForFill } from "./nodeColors";
import type { CustomNodeProps } from "./reactflowUtils";
+// A colored group is softened toward the surface and alternates between these
two shares of the custom
+// color by nesting depth, so open nested groups stay visually distinct (like
the bg.muted/bg stripes).
+const GROUP_FILL_STRONG = 45;
+const GROUP_FILL_SOFT = 22;
+
export const TaskNode = ({
data: {
childCount,
@@ -48,12 +54,17 @@ export const TaskNode = ({
taskInstance,
team,
tooltip,
+ uiColor,
+ uiFgcolor,
width = 0,
},
id,
}: NodeProps<NodeType<CustomNodeProps, "task">>) => {
const { t: translate } = useTranslation("components");
const { toggleGroupId } = useGroups();
+ // Resolve the fill through Chakra so any token (dotted like "blue.500" or
bare like "bg") becomes a
+ // valid CSS color for the group color-mix; raw hex/CSS names pass through
unchanged.
+ const [resolvedFill] = useToken("colors", [uiColor ?? "transparent"]);
const onClick = () => {
if (isGroup) {
toggleGroupId(id);
@@ -85,6 +96,24 @@ export const TaskNode = ({
.map(([_state, count]) => count)
.reduce((sum, val) => sum + val, 0);
+ // Custom colors can mess up the readability of the text, so we calculate a
readable foreground color for the node based on the background color.
+ // Pass the resolved color so Chakra tokens are measured by their hex rather
than skipped.
+ const readableFgColor = isGroup ? undefined :
readableTextForFill(resolvedFill);
+ // Alternate nested groups colors so nested groups are visually distinct and
readable
+ const shouldAlternate = isOpen && depth !== undefined && depth % 2 === 0;
+
+ let nodeBg: string;
+
+ if (!isGroup) {
+ nodeBg = uiColor ?? "bg";
+ } else if (uiColor === undefined) {
+ nodeBg = shouldAlternate ? "bg.muted" : "bg";
+ } else {
+ const share = shouldAlternate ? GROUP_FILL_STRONG : GROUP_FILL_SOFT;
+
+ nodeBg = `color-mix(in srgb, ${resolvedFill} ${share}%,
var(--chakra-colors-bg))`;
+ }
+
return (
<NodeWrapper>
<Flex alignItems="center" cursor="default" flexDirection="column"
{...opacityStyle(isFiltered)}>
@@ -97,13 +126,13 @@ export const TaskNode = ({
tooltip={isGroup ? tooltip : undefined}
>
<Flex
- // Alternate background color for nested open groups
- bg={isOpen && depth !== undefined && depth % 2 === 0 ? "bg.muted"
: "bg"}
+ bg={nodeBg}
borderColor={
isSelected ? "blue.500" : taskInstance?.state ?
`${taskInstance.state}.solid` : "border"
}
borderRadius={5}
borderWidth={isSelected ? 4 : 2}
+ color={readableFgColor}
direction="column"
height={`${height + (isSelected ? 4 : 0)}px`}
overflow="hidden"
@@ -127,7 +156,7 @@ export const TaskNode = ({
</LinkOverlay>
</HStack>
<Text
- color="fg.muted"
+ color={uiFgcolor ?? readableFgColor ?? "fg.muted"}
fontSize="sm"
overflow="hidden"
textOverflow="ellipsis"
diff --git
a/airflow-core/src/airflow/ui/src/components/Graph/elkGraphUtils.test.ts
b/airflow-core/src/airflow/ui/src/components/Graph/elkGraphUtils.test.ts
index 02942fef9e4..6c0de8dc190 100644
--- a/airflow-core/src/airflow/ui/src/components/Graph/elkGraphUtils.test.ts
+++ b/airflow-core/src/airflow/ui/src/components/Graph/elkGraphUtils.test.ts
@@ -303,3 +303,59 @@ describe("generateElkGraph — closed TaskGroup", () => {
expect(rootEdgeIds).toEqual(new Set(["start-group_a",
"group_a-final_task"]));
});
});
+
+describe("generateElkGraph — operator colors", () => {
+ it("maps ui_color/ui_fgcolor onto the formatted leaf node", () => {
+ const root = generateElkGraph({
+ direction: "RIGHT",
+ edges: [],
+ font: "12px sans-serif",
+ nodes: [buildNode({ id: "t1", label: "t1", ui_color: "blue.500",
ui_fgcolor: "red.700" })],
+ openGroupIds: [],
+ });
+
+ const node = root.children?.[0] as FormattedNode;
+
+ expect(node.uiColor).toBe("blue.500");
+ expect(node.uiFgcolor).toBe("red.700");
+ });
+
+ it("leaves the colors undefined when the node has none", () => {
+ const root = generateElkGraph({
+ direction: "RIGHT",
+ edges: [],
+ font: "12px sans-serif",
+ nodes: [buildNode({ id: "t1", label: "t1" })],
+ openGroupIds: [],
+ });
+
+ const node = root.children?.[0] as FormattedNode;
+
+ expect(node.uiColor).toBeUndefined();
+ expect(node.uiFgcolor).toBeUndefined();
+ });
+
+ it("maps ui_color/ui_fgcolor onto an expanded (open) group node", () => {
+ const root = generateElkGraph({
+ direction: "RIGHT",
+ edges: [],
+ font: "12px sans-serif",
+ nodes: [
+ buildNode({
+ children: [buildNode({ id: "g.t1", label: "t1" })],
+ id: "g",
+ label: "g",
+ ui_color: "purple.600",
+ ui_fgcolor: "green.600",
+ }),
+ ],
+ openGroupIds: ["g"],
+ });
+
+ const group = (root.children as Array<FormattedNode>).find((child) =>
child.id === "g");
+
+ expect(group?.isOpen).toBe(true);
+ expect(group?.uiColor).toBe("purple.600");
+ expect(group?.uiFgcolor).toBe("green.600");
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/components/Graph/elkGraphUtils.ts
b/airflow-core/src/airflow/ui/src/components/Graph/elkGraphUtils.ts
index 6e8548fbed1..40c6875e499 100644
--- a/airflow-core/src/airflow/ui/src/components/Graph/elkGraphUtils.ts
+++ b/airflow-core/src/airflow/ui/src/components/Graph/elkGraphUtils.ts
@@ -49,6 +49,8 @@ export type FormattedNode = {
isOpen?: boolean;
setupTeardownType?: NodeResponse["setup_teardown_type"];
team?: string | null;
+ uiColor?: string | null;
+ uiFgcolor?: string | null;
} & ElkShape &
NodeResponse;
@@ -352,6 +354,8 @@ export const generateElkGraph = ({
"elk.padding": "[top=80,left=15,bottom=15,right=15]",
...(direction === "RIGHT" ? { "elk.portConstraints": "FIXED_SIDE" }
: {}),
},
+ uiColor: node.ui_color,
+ uiFgcolor: node.ui_fgcolor,
};
}
@@ -392,6 +396,8 @@ export const generateElkGraph = ({
team: node.team,
tooltip: node.tooltip,
type: node.type,
+ uiColor: node.ui_color,
+ uiFgcolor: node.ui_fgcolor,
width,
};
};
diff --git a/airflow-core/src/airflow/ui/src/components/Graph/nodeColors.ts
b/airflow-core/src/airflow/ui/src/components/Graph/nodeColors.ts
new file mode 100644
index 00000000000..6457c87e99f
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/Graph/nodeColors.ts
@@ -0,0 +1,85 @@
+/*!
+ * 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.
+ */
+
+// The theme's default text colors ("fg"): black in light mode, gray.50 in
dark mode. We pick between
+// them by the fill brightness instead of by the color mode, so custom-colored
nodes stay legible.
+const DARK_TEXT = "black";
+const LIGHT_TEXT = "gray.50";
+const BRIGHTNESS_THRESHOLD = 128;
+
+const hexToRgb = (hex: string): [number, number, number] | undefined => {
+ const isShort = /^#[\da-f]{3}$/iu.test(hex);
+ const isLong = /^#[\da-f]{6}$/iu.test(hex);
+
+ if (!isShort && !isLong) {
+ return undefined;
+ }
+
+ // Expand #abc to #aabbcc so both forms share one slicing path.
+ const normalized = isShort
+ ? `#${hex.slice(1, 2).repeat(2)}${hex.slice(2, 3).repeat(2)}${hex.slice(3,
4).repeat(2)}`
+ : hex;
+
+ return [
+ parseInt(normalized.slice(1, 3), 16),
+ parseInt(normalized.slice(3, 5), 16),
+ parseInt(normalized.slice(5, 7), 16),
+ ];
+};
+
+// Perceived brightness (ITU-R BT.601, 0-255) of a raw color -- a hex code
parsed directly, or a CSS
+// name normalized through a canvas. Returns undefined for anything that
cannot be resolved.
+const perceivedBrightness = (color: string): number | undefined => {
+ let rgb = hexToRgb(color);
+
+ if (rgb === undefined) {
+ const context = document.createElement("canvas").getContext("2d");
+
+ if (context !== null) {
+ context.fillStyle = "#000000";
+ context.fillStyle = color;
+ rgb = hexToRgb(context.fillStyle);
+ }
+ }
+
+ if (rgb === undefined) {
+ return undefined;
+ }
+
+ const [red, green, blue] = rgb;
+
+ return (red * 299 + green * 587 + blue * 114) / 1000;
+};
+
+// Pick the theme's dark or light text color for a node painted with the vivid
fill `color`, so the
+// icon and label stay legible. Chakra palette tokens ("blue.500") are
theme-managed and keep the
+// default foreground (undefined), as do colors that cannot be parsed.
+export const readableTextForFill = (color: string | undefined): string |
undefined => {
+ if (color === undefined || color.includes(".")) {
+ return undefined;
+ }
+
+ const brightness = perceivedBrightness(color);
+
+ if (brightness === undefined) {
+ return undefined;
+ }
+
+ return brightness >= BRIGHTNESS_THRESHOLD ? DARK_TEXT : LIGHT_TEXT;
+};
diff --git a/airflow-core/src/airflow/ui/src/components/Graph/reactflowUtils.ts
b/airflow-core/src/airflow/ui/src/components/Graph/reactflowUtils.ts
index 67fe2c2d240..7a2667a7abc 100644
--- a/airflow-core/src/airflow/ui/src/components/Graph/reactflowUtils.ts
+++ b/airflow-core/src/airflow/ui/src/components/Graph/reactflowUtils.ts
@@ -41,6 +41,8 @@ export type CustomNodeProps = {
team?: string | null;
tooltip?: string | null;
type: string;
+ uiColor?: string | null;
+ uiFgcolor?: string | null;
width?: number;
};
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_structure.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_structure.py
index 0bde80a22dc..6aaff421ee3 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_structure.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_structure.py
@@ -33,6 +33,7 @@ from airflow.providers.standard.operators.trigger_dagrun
import TriggerDagRunOpe
from airflow.providers.standard.sensors.external_task import ExternalTaskSensor
from airflow.sdk import Metadata, task
from airflow.sdk.definitions.asset import Asset, AssetAlias, Dataset
+from airflow.sdk.definitions.taskgroup import TaskGroup
from tests_common.test_utils.asserts import assert_queries_count
from tests_common.test_utils.db import clear_db_assets, clear_db_runs
@@ -58,6 +59,8 @@ LATEST_VERSION_DAG_RESPONSE: dict = {
"team": None,
"operator": "EmptyOperator",
"asset_condition_type": None,
+ "ui_color": "#e8f7e4",
+ "ui_fgcolor": "#000",
},
{
"children": None,
@@ -70,6 +73,8 @@ LATEST_VERSION_DAG_RESPONSE: dict = {
"team": None,
"operator": "EmptyOperator",
"asset_condition_type": None,
+ "ui_color": "#e8f7e4",
+ "ui_fgcolor": "#000",
},
{
"children": None,
@@ -82,6 +87,8 @@ LATEST_VERSION_DAG_RESPONSE: dict = {
"team": None,
"operator": "EmptyOperator",
"asset_condition_type": None,
+ "ui_color": "#e8f7e4",
+ "ui_fgcolor": "#000",
},
],
}
@@ -274,6 +281,8 @@ class TestStructureDataEndpoint:
"nodes": [
{
"asset_condition_type": None,
+ "ui_color": "#e8f7e4",
+ "ui_fgcolor": "#000",
"children": None,
"id": "task_1",
"is_mapped": None,
@@ -286,6 +295,8 @@ class TestStructureDataEndpoint:
},
{
"asset_condition_type": None,
+ "ui_color": "#4db7db",
+ "ui_fgcolor": "#000",
"children": None,
"id": "external_task_sensor",
"is_mapped": None,
@@ -298,6 +309,8 @@ class TestStructureDataEndpoint:
},
{
"asset_condition_type": None,
+ "ui_color": "#e8f7e4",
+ "ui_fgcolor": "#000",
"children": None,
"id": "task_2",
"is_mapped": None,
@@ -332,6 +345,8 @@ class TestStructureDataEndpoint:
"nodes": [
{
"asset_condition_type": None,
+ "ui_color": "#e8f7e4",
+ "ui_fgcolor": "#000",
"children": None,
"id": "task_1",
"is_mapped": None,
@@ -361,6 +376,8 @@ class TestStructureDataEndpoint:
"nodes": [
{
"asset_condition_type": None,
+ "ui_color": "#ffefeb",
+ "ui_fgcolor": "#000",
"children": None,
"id": "trigger_dag_run_operator",
"is_mapped": None,
@@ -373,6 +390,8 @@ class TestStructureDataEndpoint:
},
{
"asset_condition_type": None,
+ "ui_color": None,
+ "ui_fgcolor": None,
"children": None,
"id":
"trigger:external_trigger:dag_with_multiple_versions:trigger_dag_run_operator",
"is_mapped": None,
@@ -480,6 +499,8 @@ class TestStructureDataEndpoint:
"team": None,
"operator": "EmptyOperator",
"asset_condition_type": None,
+ "ui_color": "#e8f7e4",
+ "ui_fgcolor": "#000",
},
{
"children": None,
@@ -492,6 +513,8 @@ class TestStructureDataEndpoint:
"team": None,
"operator": "ExternalTaskSensor",
"asset_condition_type": None,
+ "ui_color": "#4db7db",
+ "ui_fgcolor": "#000",
},
{
"children": None,
@@ -504,6 +527,8 @@ class TestStructureDataEndpoint:
"team": None,
"operator": "EmptyOperator",
"asset_condition_type": None,
+ "ui_color": "#e8f7e4",
+ "ui_fgcolor": "#000",
},
{
"children": None,
@@ -516,6 +541,8 @@ class TestStructureDataEndpoint:
"team": None,
"operator": None,
"asset_condition_type": None,
+ "ui_color": None,
+ "ui_fgcolor": None,
},
{
"children": None,
@@ -528,6 +555,8 @@ class TestStructureDataEndpoint:
"team": None,
"operator": None,
"asset_condition_type": None,
+ "ui_color": None,
+ "ui_fgcolor": None,
},
{
"children": None,
@@ -540,6 +569,8 @@ class TestStructureDataEndpoint:
"team": None,
"operator": None,
"asset_condition_type": None,
+ "ui_color": None,
+ "ui_fgcolor": None,
},
{
"children": None,
@@ -552,6 +583,8 @@ class TestStructureDataEndpoint:
"team": None,
"operator": None,
"asset_condition_type": "and-gate",
+ "ui_color": None,
+ "ui_fgcolor": None,
},
{
"children": None,
@@ -564,6 +597,8 @@ class TestStructureDataEndpoint:
"team": None,
"operator": None,
"asset_condition_type": None,
+ "ui_color": None,
+ "ui_fgcolor": None,
},
{
"children": None,
@@ -576,6 +611,8 @@ class TestStructureDataEndpoint:
"team": None,
"operator": None,
"asset_condition_type": None,
+ "ui_color": None,
+ "ui_fgcolor": None,
},
{
"children": None,
@@ -588,6 +625,8 @@ class TestStructureDataEndpoint:
"team": None,
"operator": None,
"asset_condition_type": None,
+ "ui_color": None,
+ "ui_fgcolor": None,
},
],
}
@@ -637,6 +676,8 @@ class TestStructureDataEndpoint:
"setup_teardown_type": None,
"operator": "@task",
"asset_condition_type": None,
+ "ui_color": "#ffefeb",
+ "ui_fgcolor": "#000",
},
{
"id": "task_2",
@@ -649,6 +690,8 @@ class TestStructureDataEndpoint:
"setup_teardown_type": None,
"operator": "EmptyOperator",
"asset_condition_type": None,
+ "ui_color": "#e8f7e4",
+ "ui_fgcolor": "#000",
},
{
"id": f"asset:{resolved_asset.id}",
@@ -661,6 +704,8 @@ class TestStructureDataEndpoint:
"setup_teardown_type": None,
"operator": None,
"asset_condition_type": None,
+ "ui_color": None,
+ "ui_fgcolor": None,
},
],
}
@@ -834,6 +879,40 @@ class TestStructureDataEndpoint:
assert mapped_in_group["is_mapped"] is True
assert mapped_in_group["operator"] == "PythonOperator"
+ def test_ui_colors_passed_through_to_graph(self, dag_maker, test_client,
session):
+ """Both raw hex colors and Chakra palette tokens reach the graph
unchanged, for operators and groups."""
+
+ class TokenOperator(EmptyOperator):
+ ui_color = "blue.500"
+ ui_fgcolor = "red.700"
+
+ class HexOperator(EmptyOperator):
+ ui_color = "#e8b7e4"
+ ui_fgcolor = "#000000"
+
+ with dag_maker(
+ dag_id="test_ui_colors_dag",
+ serialized=True,
+ session=session,
+ start_date=pendulum.DateTime(2023, 2, 1, 0, 0, 0,
tzinfo=pendulum.UTC),
+ ):
+ TokenOperator(task_id="token")
+ HexOperator(task_id="hex")
+ with TaskGroup(group_id="grp", ui_color="teal.400",
ui_fgcolor="#ffffff"):
+ EmptyOperator(task_id="inner")
+
+ dag_maker.sync_dagbag_to_db()
+ response = test_client.get("/structure/structure_data",
params={"dag_id": "test_ui_colors_dag"})
+ assert response.status_code == 200
+ nodes = {node["id"]: node for node in response.json()["nodes"]}
+
+ assert nodes["token"]["ui_color"] == "blue.500"
+ assert nodes["token"]["ui_fgcolor"] == "red.700"
+ assert nodes["hex"]["ui_color"] == "#e8b7e4"
+ assert nodes["hex"]["ui_fgcolor"] == "#000000"
+ assert nodes["grp"]["ui_color"] == "teal.400"
+ assert nodes["grp"]["ui_fgcolor"] == "#ffffff"
+
@pytest.mark.parametrize(
("params", "expected_task_ids", "description"),
[
diff --git a/airflow-core/tests/unit/utils/test_task_group.py
b/airflow-core/tests/unit/utils/test_task_group.py
index 2d1458e95fb..c0cd58f30dd 100644
--- a/airflow-core/tests/unit/utils/test_task_group.py
+++ b/airflow-core/tests/unit/utils/test_task_group.py
@@ -155,9 +155,12 @@ EXPECTED_JSON_LEGACY = {
],
}
+TASK_COLORS = {"ui_color": "#e8f7e4", "ui_fgcolor": "#000"}
+GROUP_COLORS = {"ui_color": "CornflowerBlue", "ui_fgcolor": "#000"}
+
EXPECTED_JSON = {
"children": [
- {"id": "task1", "label": "task1", "operator": "EmptyOperator", "type":
"task"},
+ {"id": "task1", "label": "task1", "operator": "EmptyOperator", "type":
"task", **TASK_COLORS},
{
"children": [
{
@@ -167,12 +170,14 @@ EXPECTED_JSON = {
"label": "task3",
"operator": "EmptyOperator",
"type": "task",
+ **TASK_COLORS,
},
{
"id": "group234.group34.task4",
"label": "task4",
"operator": "EmptyOperator",
"type": "task",
+ **TASK_COLORS,
},
{"id": "group234.group34.downstream_join_id", "label":
"", "type": "join"},
],
@@ -181,12 +186,14 @@ EXPECTED_JSON = {
"label": "group34",
"tooltip": "",
"type": "task",
+ **GROUP_COLORS,
},
{
"id": "group234.task2",
"label": "task2",
"operator": "EmptyOperator",
"type": "task",
+ **TASK_COLORS,
},
{"id": "group234.upstream_join_id", "label": "", "type":
"join"},
],
@@ -195,14 +202,16 @@ EXPECTED_JSON = {
"label": "group234",
"tooltip": "",
"type": "task",
+ **GROUP_COLORS,
},
- {"id": "task5", "label": "task5", "operator": "EmptyOperator", "type":
"task"},
+ {"id": "task5", "label": "task5", "operator": "EmptyOperator", "type":
"task", **TASK_COLORS},
],
"id": None,
"is_mapped": False,
"label": "",
"tooltip": "",
"type": "task",
+ **GROUP_COLORS,
}
diff --git a/task-sdk/src/airflow/sdk/definitions/taskgroup.py
b/task-sdk/src/airflow/sdk/definitions/taskgroup.py
index 14bb2fba319..c1be99c5766 100644
--- a/task-sdk/src/airflow/sdk/definitions/taskgroup.py
+++ b/task-sdk/src/airflow/sdk/definitions/taskgroup.py
@@ -117,8 +117,10 @@ class TaskGroup(DAGNode):
`default_args`, the actual value will be `False`.
:param tooltip: The tooltip of the TaskGroup node when displayed in the UI
:param doc_md: Markdown documentation for the TaskGroup displayed in the UI
- :param ui_color: The fill color of the TaskGroup node when displayed in
the UI
- :param ui_fgcolor: The label color of the TaskGroup node when displayed in
the UI
+ :param ui_color: The fill color of the TaskGroup node in the graph view --
a raw color (hex code
+ or CSS name) or a Chakra palette or semantic token (e.g. ``blue.500``
or ``brand.solid``)
+ :param ui_fgcolor: The label color of the TaskGroup node in the graph view
-- a raw color (hex
+ code or CSS name) or a Chakra palette or semantic token
:param add_suffix_on_collision: If this task group name already exists,
automatically add `__1` etc suffixes
:param group_display_name: If set, this will be the display name for the
TaskGroup node in the UI.
@@ -149,8 +151,14 @@ class TaskGroup(DAGNode):
on_setattr=attrs.setters.frozen,
)
- ui_color: str = attrs.field(default="CornflowerBlue",
validator=attrs.validators.instance_of(str))
- ui_fgcolor: str = attrs.field(default="#000",
validator=attrs.validators.instance_of(str))
+ ui_color: str = attrs.field(
+ default="CornflowerBlue",
+ validator=attrs.validators.instance_of(str),
+ )
+ ui_fgcolor: str = attrs.field(
+ default="#000",
+ validator=attrs.validators.instance_of(str),
+ )
add_suffix_on_collision: bool = False