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
 


Reply via email to