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)"}
