This is an automated email from the ASF dual-hosted git repository.

rahulvats pushed a commit to branch v3-2-test
in repository https://gitbox.apache.org/repos/asf/airflow.git

commit 2f44647557f8c24907b37dc9f3c873c6e4e5b1ac
Author: Constance Martineau <[email protected]>
AuthorDate: Fri Mar 27 10:36:10 2026 -0400

    Allow gray, black, and white color tokens in AIRFLOW__API__THEME (#64232)
    
    * Allow gray, black, and white color tokens in AIRFLOW__API__THEME
    
    Previously the Theme model only accepted tokens.colors.brand, forcing
    users to use globalCss CSS-variable hacks to customize neutral surfaces,
    borders, and the gray palette.
    
    Introduce ThemeColors sub-model accepting brand, gray, black, and white —
    all optional, with at least one required. Existing brand-only configs
    continue to work unchanged.
    
    Also regenerate the private UI OpenAPI spec and TypeScript types.
    
    * Add newsfragment for #64232
    
    * Factorize common conf_vars into helper in test_config
    
    (cherry picked from commit 536101b12e4dd3ed7a800b14ea01f55f98e4e31e)
---
 airflow-core/docs/howto/customize-ui.rst           |  45 ++++++-
 airflow-core/newsfragments/64232.feature.rst       |   1 +
 .../src/airflow/api_fastapi/common/types.py        |  38 ++++--
 .../api_fastapi/core_api/openapi/_private_ui.yaml  |  28 +----
 .../airflow/ui/openapi-gen/requests/schemas.gen.ts |  25 +---
 .../airflow/ui/openapi-gen/requests/types.gen.ts   |  12 +-
 .../tests/unit/api_fastapi/common/test_types.py    | 135 ++++++++++++++++++++-
 .../api_fastapi/core_api/routes/ui/test_config.py  |  80 ++++++++++--
 8 files changed, 286 insertions(+), 78 deletions(-)

diff --git a/airflow-core/docs/howto/customize-ui.rst 
b/airflow-core/docs/howto/customize-ui.rst
index 24e4b035890..3d696f52969 100644
--- a/airflow-core/docs/howto/customize-ui.rst
+++ b/airflow-core/docs/howto/customize-ui.rst
@@ -70,14 +70,18 @@ We can provide a JSON configuration to customize the UI.
 
 .. important::
 
-  - You can customize the ``brand`` color palette, ``globalCss`` and the 
navigation icon via ``icon`` (and ``icon_dark_mode``).
-  - You must supply ``50``-``950`` OKLCH color values for ``brand`` color.
-  - OKLCH colors must have next format ``oklch(l c h)`` For more info see 
:ref:`config:api__theme`
-  - There is also the ability to provide custom global CSS for a fine grained 
theme control.
+  - You can customize the ``brand``, ``gray``, ``black``, and ``white`` color 
tokens, ``globalCss``, and the navigation icon via ``icon`` (and 
``icon_dark_mode``).
+  - All color tokens are **optional** — you can override any subset without 
supplying the others.
+  - ``brand`` and ``gray`` each accept an 11-shade scale with keys 
``50``–``950``.
+  - ``black`` and ``white`` each accept a single color: ``{ "value": 
"oklch(...)" }``.
+  - OKLCH colors must use the format ``oklch(l c h)``. For more info see 
:ref:`config:api__theme`
+  - There is also the ability to provide custom global CSS for fine-grained 
theme control.
 
 .. note::
 
-  Modifying the ``brand`` color palette you also modify the navbar/sidebar.
+  Modifying the ``brand`` color palette also modifies the navbar/sidebar.
+  Modifying ``gray`` controls neutral surfaces and borders.
+  Modifying ``black`` and ``white`` controls the darkest and lightest surface 
colors.
 
 To customize the UI, simply:
 
@@ -180,6 +184,37 @@ Dark Mode
     }
   }'
 
+Customizing gray, black, and white tokens
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+You can override the neutral palette and surface colors independently of 
``brand``. ``gray`` controls
+borders and neutral UI elements, while ``black`` and ``white`` control the 
darkest and lightest surface
+backgrounds. All fields are optional — supply only the tokens you want to 
change.
+
+.. code-block::
+
+  AIRFLOW__API__THEME='{
+    "tokens": {
+      "colors": {
+        "gray": {
+          "50":  { "value": "oklch(0.975 0.002 264.0)" },
+          "100": { "value": "oklch(0.950 0.003 264.0)" },
+          "200": { "value": "oklch(0.880 0.005 264.0)" },
+          "300": { "value": "oklch(0.780 0.008 264.0)" },
+          "400": { "value": "oklch(0.640 0.012 264.0)" },
+          "500": { "value": "oklch(0.520 0.015 264.0)" },
+          "600": { "value": "oklch(0.420 0.015 264.0)" },
+          "700": { "value": "oklch(0.340 0.012 264.0)" },
+          "800": { "value": "oklch(0.260 0.009 264.0)" },
+          "900": { "value": "oklch(0.200 0.007 264.0)" },
+          "950": { "value": "oklch(0.145 0.005 264.0)" }
+        },
+        "black": { "value": "oklch(0.220 0.025 288.6)" },
+        "white": { "value": "oklch(0.985 0.002 264.0)" }
+      }
+    }
+  }'
+
 Icon (SVG-only)
 ^^^^^^^^^^^^^^^
 
diff --git a/airflow-core/newsfragments/64232.feature.rst 
b/airflow-core/newsfragments/64232.feature.rst
new file mode 100644
index 00000000000..393a668ba82
--- /dev/null
+++ b/airflow-core/newsfragments/64232.feature.rst
@@ -0,0 +1 @@
+Allow customizing gray, black, and white color tokens in AIRFLOW__API__THEME 
in addition to brand.
diff --git a/airflow-core/src/airflow/api_fastapi/common/types.py 
b/airflow-core/src/airflow/api_fastapi/common/types.py
index 269669c8ee5..bd4176a9fd9 100644
--- a/airflow-core/src/airflow/api_fastapi/common/types.py
+++ b/airflow-core/src/airflow/api_fastapi/common/types.py
@@ -20,7 +20,7 @@ import re
 from dataclasses import dataclass
 from datetime import timedelta
 from enum import Enum
-from typing import Annotated, Literal
+from typing import Annotated, Any, Literal
 
 from pydantic import (
     AfterValidator,
@@ -188,19 +188,35 @@ class OklchColor(BaseModel):
         return f"oklch({self.lightness} {self.chroma} {self.hue})"
 
 
+# Private type aliases for theme token shapes
+_ColorShade = dict[Literal["value"], OklchColor]
+_SHADE_LITERAL = Literal["50", "100", "200", "300", "400", "500", "600", 
"700", "800", "900", "950"]
+_ColorScale = dict[_SHADE_LITERAL, _ColorShade]
+
+
+class ThemeColors(BaseModel):
+    """Color tokens for the UI theme. All fields are optional; at least one 
must be provided."""
+
+    brand: _ColorScale | None = None
+    gray: _ColorScale | None = None
+    black: _ColorShade | None = None
+    white: _ColorShade | None = None
+
+    @model_validator(mode="after")
+    def check_at_least_one_color(self) -> ThemeColors:
+        if not any([self.brand, self.gray, self.black, self.white]):
+            raise ValueError("At least one color token must be provided: 
brand, gray, black, or white")
+        return self
+
+    @model_serializer(mode="wrap")
+    def serialize_model(self, handler: Any) -> dict:
+        return {k: v for k, v in handler(self).items() if v is not None}
+
+
 class Theme(BaseModel):
     """JSON to modify Chakra's theme."""
 
-    tokens: dict[
-        Literal["colors"],
-        dict[
-            Literal["brand"],
-            dict[
-                Literal["50", "100", "200", "300", "400", "500", "600", "700", 
"800", "900", "950"],
-                dict[Literal["value"], OklchColor],
-            ],
-        ],
-    ]
+    tokens: dict[Literal["colors"], ThemeColors]
     globalCss: dict[str, dict] | None = None
     icon: ThemeIconType = None
     icon_dark_mode: ThemeIconType = None
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 9e81e8344fb..915e4d54300 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
@@ -3273,30 +3273,7 @@ components:
       properties:
         tokens:
           additionalProperties:
-            additionalProperties:
-              additionalProperties:
-                additionalProperties:
-                  $ref: '#/components/schemas/OklchColor'
-                propertyNames:
-                  const: value
-                type: object
-              propertyNames:
-                enum:
-                - '50'
-                - '100'
-                - '200'
-                - '300'
-                - '400'
-                - '500'
-                - '600'
-                - '700'
-                - '800'
-                - '900'
-                - '950'
-              type: object
-            propertyNames:
-              const: brand
-            type: object
+            $ref: '#/components/schemas/ThemeColors'
           propertyNames:
             const: colors
           type: object
@@ -3324,6 +3301,9 @@ components:
       - tokens
       title: Theme
       description: JSON to modify Chakra's theme.
+    ThemeColors:
+      additionalProperties: true
+      type: object
     TokenType:
       type: string
       enum:
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 f47aa107aea..a6cabf8c9d3 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
@@ -8896,25 +8896,7 @@ export const $Theme = {
     properties: {
         tokens: {
             additionalProperties: {
-                additionalProperties: {
-                    additionalProperties: {
-                        additionalProperties: {
-                            '$ref': '#/components/schemas/OklchColor'
-                        },
-                        propertyNames: {
-                            const: 'value'
-                        },
-                        type: 'object'
-                    },
-                    propertyNames: {
-                        enum: ['50', '100', '200', '300', '400', '500', '600', 
'700', '800', '900', '950']
-                    },
-                    type: 'object'
-                },
-                propertyNames: {
-                    const: 'brand'
-                },
-                type: 'object'
+                '$ref': '#/components/schemas/ThemeColors'
             },
             propertyNames: {
                 const: 'colors'
@@ -8966,6 +8948,11 @@ export const $Theme = {
     description: "JSON to modify Chakra's theme."
 } as const;
 
+export const $ThemeColors = {
+    additionalProperties: true,
+    type: 'object'
+} as const;
+
 export const $TokenType = {
     type: 'string',
     enum: ['api', 'cli'],
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 7423c08d42c..14a88fd0ffa 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
@@ -2200,13 +2200,7 @@ export type TeamResponse = {
  */
 export type Theme = {
     tokens: {
-        [key: string]: {
-            [key: string]: {
-                [key: string]: {
-                    [key: string]: OklchColor;
-                };
-            };
-        };
+        [key: string]: ThemeColors;
     };
     globalCss?: {
     [key: string]: {
@@ -2217,6 +2211,10 @@ export type Theme = {
     icon_dark_mode?: string | null;
 };
 
+export type ThemeColors = {
+    [key: string]: unknown;
+};
+
 /**
  * Type of token to generate.
  */
diff --git a/airflow-core/tests/unit/api_fastapi/common/test_types.py 
b/airflow-core/tests/unit/api_fastapi/common/test_types.py
index c56e0a2e629..3476f6a5293 100644
--- a/airflow-core/tests/unit/api_fastapi/common/test_types.py
+++ b/airflow-core/tests/unit/api_fastapi/common/test_types.py
@@ -19,7 +19,7 @@ from __future__ import annotations
 import pytest
 from pydantic import ValidationError
 
-from airflow.api_fastapi.common.types import OklchColor
+from airflow.api_fastapi.common.types import OklchColor, Theme, ThemeColors
 
 
 class TestOklchColor:
@@ -74,3 +74,136 @@ class TestOklchColor:
         with pytest.raises(ValidationError) as exc_info:
             OklchColor.model_validate(input_str)
         assert error_message in str(exc_info.value)
+
+
+# Shared test data for Theme/ThemeColors tests
+_BRAND_SCALE = {
+    "50": {"value": "oklch(0.975 0.007 298.0)"},
+    "100": {"value": "oklch(0.950 0.014 298.0)"},
+    "200": {"value": "oklch(0.900 0.023 298.0)"},
+    "300": {"value": "oklch(0.800 0.030 298.0)"},
+    "400": {"value": "oklch(0.680 0.038 298.0)"},
+    "500": {"value": "oklch(0.560 0.044 298.0)"},
+    "600": {"value": "oklch(0.460 0.048 298.0)"},
+    "700": {"value": "oklch(0.390 0.049 298.0)"},
+    "800": {"value": "oklch(0.328 0.050 298.0)"},
+    "900": {"value": "oklch(0.230 0.043 298.0)"},
+    "950": {"value": "oklch(0.155 0.035 298.0)"},
+}
+_GRAY_SCALE = {
+    "50": {"value": "oklch(0.975 0.002 264.0)"},
+    "100": {"value": "oklch(0.950 0.003 264.0)"},
+    "200": {"value": "oklch(0.880 0.005 264.0)"},
+    "300": {"value": "oklch(0.780 0.008 264.0)"},
+    "400": {"value": "oklch(0.640 0.012 264.0)"},
+    "500": {"value": "oklch(0.520 0.015 264.0)"},
+    "600": {"value": "oklch(0.420 0.015 264.0)"},
+    "700": {"value": "oklch(0.340 0.012 264.0)"},
+    "800": {"value": "oklch(0.260 0.009 264.0)"},
+    "900": {"value": "oklch(0.200 0.007 264.0)"},
+    "950": {"value": "oklch(0.145 0.005 264.0)"},
+}
+_BLACK_SHADE = {"value": "oklch(0.220 0.025 288.6)"}
+_WHITE_SHADE = {"value": "oklch(0.985 0.002 264.0)"}
+
+
+class TestThemeColors:
+    def test_brand_only(self):
+        colors = ThemeColors.model_validate({"brand": _BRAND_SCALE})
+        assert colors.brand is not None
+        assert colors.gray is None
+        assert colors.black is None
+        assert colors.white is None
+
+    def test_gray_only(self):
+        colors = ThemeColors.model_validate({"gray": _GRAY_SCALE})
+        assert colors.gray is not None
+        assert colors.brand is None
+
+    def test_black_and_white_only(self):
+        colors = ThemeColors.model_validate({"black": _BLACK_SHADE, "white": 
_WHITE_SHADE})
+        assert colors.black is not None
+        assert colors.white is not None
+        assert colors.brand is None
+        assert colors.gray is None
+
+    def test_all_tokens(self):
+        colors = ThemeColors.model_validate(
+            {"brand": _BRAND_SCALE, "gray": _GRAY_SCALE, "black": 
_BLACK_SHADE, "white": _WHITE_SHADE}
+        )
+        assert colors.brand is not None
+        assert colors.gray is not None
+        assert colors.black is not None
+        assert colors.white is not None
+
+    def test_empty_colors_rejected(self):
+        with pytest.raises(ValidationError) as exc_info:
+            ThemeColors.model_validate({})
+        assert "At least one color token must be provided" in 
str(exc_info.value)
+
+    def test_invalid_shade_key_rejected(self):
+        with pytest.raises(ValidationError):
+            ThemeColors.model_validate({"gray": {"999": {"value": "oklch(0.5 
0.1 264.0)"}}})
+
+    def test_serialization_excludes_none_fields(self):
+        colors = ThemeColors.model_validate({"brand": _BRAND_SCALE})
+        dumped = colors.model_dump()
+        assert "brand" in dumped
+        assert "gray" not in dumped
+        assert "black" not in dumped
+        assert "white" not in dumped
+
+
+class TestTheme:
+    def test_brand_only_theme(self):
+        """Backwards-compatible: existing configs with only brand still 
work."""
+        theme = Theme.model_validate({"tokens": {"colors": {"brand": 
_BRAND_SCALE}}})
+        assert theme.tokens["colors"].brand is not None
+        assert theme.tokens["colors"].gray is None
+        assert theme.globalCss is None
+
+    def test_gray_only_theme(self):
+        """New: brand is no longer required."""
+        theme = Theme.model_validate({"tokens": {"colors": {"gray": 
_GRAY_SCALE}}})
+        assert theme.tokens["colors"].gray is not None
+        assert theme.tokens["colors"].brand is None
+
+    def test_black_white_theme(self):
+        theme = Theme.model_validate({"tokens": {"colors": {"black": 
_BLACK_SHADE, "white": _WHITE_SHADE}}})
+        assert theme.tokens["colors"].black is not None
+        assert theme.tokens["colors"].white is not None
+
+    def test_all_tokens_theme(self):
+        theme = Theme.model_validate(
+            {
+                "tokens": {
+                    "colors": {
+                        "brand": _BRAND_SCALE,
+                        "gray": _GRAY_SCALE,
+                        "black": _BLACK_SHADE,
+                        "white": _WHITE_SHADE,
+                    }
+                }
+            }
+        )
+        colors = theme.tokens["colors"]
+        assert colors.brand is not None
+        assert colors.gray is not None
+        assert colors.black is not None
+        assert colors.white is not None
+
+    def test_empty_colors_rejected(self):
+        with pytest.raises(ValidationError) as exc_info:
+            Theme.model_validate({"tokens": {"colors": {}}})
+        assert "At least one color token must be provided" in 
str(exc_info.value)
+
+    def test_serialization_round_trip(self):
+        """Verify None color fields are excluded and OklchColor values are 
serialized as strings."""
+        theme = Theme.model_validate({"tokens": {"colors": {"brand": 
_BRAND_SCALE}}})
+        dumped = theme.model_dump()
+        colors = dumped["tokens"]["colors"]
+        assert "brand" in colors
+        assert "gray" not in colors
+        assert "black" not in colors
+        assert "white" not in colors
+        assert colors["brand"]["50"]["value"] == "oklch(0.975 0.007 298.0)"
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_config.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_config.py
index ca5f44b1fc5..dbc3c0eb649 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_config.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_config.py
@@ -72,23 +72,67 @@ expected_config_response = {
 }
 
 
+def _theme_conf_vars(theme: dict) -> dict:
+    return {
+        ("api", "instance_name"): "Airflow",
+        ("api", "enable_swagger_ui"): "true",
+        ("api", "hide_paused_dags_by_default"): "true",
+        ("api", "fallback_page_limit"): "100",
+        ("api", "default_wrap"): "false",
+        ("api", "auto_refresh_interval"): "3",
+        ("api", "require_confirmation_dag_change"): "false",
+        ("api", "theme"): json.dumps(theme),
+    }
+
+
 @pytest.fixture
 def mock_config_data():
     """
     Mock configuration settings used in the endpoint.
     """
-    with conf_vars(
-        {
-            ("api", "instance_name"): "Airflow",
-            ("api", "enable_swagger_ui"): "true",
-            ("api", "hide_paused_dags_by_default"): "true",
-            ("api", "fallback_page_limit"): "100",
-            ("api", "default_wrap"): "false",
-            ("api", "auto_refresh_interval"): "3",
-            ("api", "require_confirmation_dag_change"): "false",
-            ("api", "theme"): json.dumps(THEME),
+    with conf_vars(_theme_conf_vars(THEME)):
+        yield
+
+
+THEME_WITH_ALL_COLORS = {
+    "tokens": {
+        "colors": {
+            "brand": {
+                "50": {"value": "oklch(0.975 0.008 298.0)"},
+                "100": {"value": "oklch(0.950 0.020 298.0)"},
+                "200": {"value": "oklch(0.900 0.045 298.0)"},
+                "300": {"value": "oklch(0.800 0.080 298.0)"},
+                "400": {"value": "oklch(0.680 0.120 298.0)"},
+                "500": {"value": "oklch(0.560 0.160 298.0)"},
+                "600": {"value": "oklch(0.460 0.190 298.0)"},
+                "700": {"value": "oklch(0.390 0.160 298.0)"},
+                "800": {"value": "oklch(0.328 0.080 298.0)"},
+                "900": {"value": "oklch(0.230 0.050 298.0)"},
+                "950": {"value": "oklch(0.155 0.030 298.0)"},
+            },
+            "gray": {
+                "50": {"value": "oklch(0.975 0.002 264.0)"},
+                "100": {"value": "oklch(0.950 0.003 264.0)"},
+                "200": {"value": "oklch(0.880 0.005 264.0)"},
+                "300": {"value": "oklch(0.780 0.008 264.0)"},
+                "400": {"value": "oklch(0.640 0.012 264.0)"},
+                "500": {"value": "oklch(0.520 0.015 264.0)"},
+                "600": {"value": "oklch(0.420 0.015 264.0)"},
+                "700": {"value": "oklch(0.340 0.012 264.0)"},
+                "800": {"value": "oklch(0.260 0.009 264.0)"},
+                "900": {"value": "oklch(0.200 0.007 264.0)"},
+                "950": {"value": "oklch(0.145 0.005 264.0)"},
+            },
+            "black": {"value": "oklch(0.220 0.025 288.6)"},
+            "white": {"value": "oklch(0.985 0.002 264.0)"},
         }
-    ):
+    },
+}
+
+
[email protected]
+def mock_config_data_all_colors():
+    with conf_vars(_theme_conf_vars(THEME_WITH_ALL_COLORS)):
         yield
 
 
@@ -112,3 +156,17 @@ class TestGetConfig:
         response = unauthorized_test_client.get("/config")
         assert response.status_code == 200
         assert response.json() == expected_config_response
+
+    def test_should_response_200_with_all_color_tokens(self, 
mock_config_data_all_colors, test_client):
+        """Theme with gray, black, and white tokens (in addition to brand) 
passes validation and round-trips."""
+        response = test_client.get("/config")
+
+        assert response.status_code == 200
+        theme = response.json()["theme"]
+        colors = theme["tokens"]["colors"]
+        assert "brand" in colors
+        assert "gray" in colors
+        assert "black" in colors
+        assert "white" in colors
+        assert colors["black"] == {"value": "oklch(0.22 0.025 288.6)"}
+        assert colors["white"] == {"value": "oklch(0.985 0.002 264.0)"}

Reply via email to