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 536101b12e4 Allow gray, black, and white color tokens in
AIRFLOW__API__THEME (#64232)
536101b12e4 is described below
commit 536101b12e4dd3ed7a800b14ea01f55f98e4e31e
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
---
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 0195c0b564a..e17c16e70e4 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
@@ -8908,25 +8908,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'
@@ -8978,6 +8960,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 9c61f27e71e..1bf8cd21fb1 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
@@ -2201,13 +2201,7 @@ export type TeamResponse = {
*/
export type Theme = {
tokens: {
- [key: string]: {
- [key: string]: {
- [key: string]: {
- [key: string]: OklchColor;
- };
- };
- };
+ [key: string]: ThemeColors;
};
globalCss?: {
[key: string]: {
@@ -2218,6 +2212,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)"}