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 a06896fe1de Allow UI theme config without tokens (CSS-only or empty 
fallback) (#64552)
a06896fe1de is described below

commit a06896fe1de2213b5c1c2d07c8310f4a85ea507c
Author: Constance Martineau <[email protected]>
AuthorDate: Tue Apr 7 12:08:00 2026 -0400

    Allow UI theme config without tokens (CSS-only or empty fallback) (#64552)
    
    * Add .worktrees/ to .gitignore
    
    * Make Theme tokens field optional to allow CSS-only or empty theme configs
    
    * Add integration test: CSS-only theme passes config endpoint validation
    
    * Docs: note that Theme tokens is optional, empty config restores OSS 
defaults
    
    * Improve CSS-only theme test: also assert icon fields absent from response
    
    * Add newsfragment for optional theme tokens
    
    * Rename newsfragment to PR #64552
    
    * Fix TypeScript errors in createTheme for optional tokens support
    
    The API schema for Theme became opaque ({[key: string]: unknown}) after
    @model_serializer was added to Theme in Python to exclude None values.
    This broke theme.ts in two ways:
    - userTheme.tokens typed as unknown, not assignable to TokenDefinition
    - mergeConfigs() called with undefined, which it doesn't accept
    
    Fix by conditionally spreading tokens/globalCss (guarding for CSS-only
    themes) and casting to Record<string, unknown> which is assignable to
    TokenDefinition. Use conditional mergeConfigs call to avoid passing
    undefined.
    
    * Fix Theme OpenAPI schema collapsing to untyped object
    
    The @model_serializer(mode="wrap") on Theme and ThemeColors caused
    Pydantic to generate additionalProperties:true for both models in the
    OpenAPI spec, losing typed fields (tokens, globalCss, icon) and
    breaking TypeScript types to {[key: string]: unknown}.
    
    Fix: remove wrap serializers from Theme/ThemeColors; add
    @field_serializer("theme") to ConfigResponse with 
model_dump(exclude_none=True)
    to preserve None-exclusion behavior scoped to the theme field only.
    Add ConfigDict(json_schema_mode_override="validation") to ConfigResponse
    so schema generation uses type annotations rather than the field
    serializer's dict return type, keeping the $ref: Theme link intact.
    
    Also removes unrelated .worktrees/ entry from .gitignore.
---
 airflow-core/docs/howto/customize-ui.rst           |   1 +
 airflow-core/newsfragments/64552.improvement.rst   |   1 +
 .../src/airflow/api_fastapi/common/types.py        |   8 +-
 .../api_fastapi/core_api/datamodels/ui/config.py   |  10 ++
 .../api_fastapi/core_api/openapi/_private_ui.yaml  |  85 ++++++++++++++--
 .../airflow/ui/openapi-gen/requests/schemas.gen.ts | 109 +++++++++++++++++++--
 .../airflow/ui/openapi-gen/requests/types.gen.ts   |  26 ++++-
 airflow-core/src/airflow/ui/src/theme.ts           |  20 ++--
 .../tests/unit/api_fastapi/common/test_types.py    |  31 +++++-
 .../api_fastapi/core_api/routes/ui/test_config.py  |  24 +++++
 10 files changed, 277 insertions(+), 38 deletions(-)

diff --git a/airflow-core/docs/howto/customize-ui.rst 
b/airflow-core/docs/howto/customize-ui.rst
index 3d696f52969..b9d03cdf94d 100644
--- a/airflow-core/docs/howto/customize-ui.rst
+++ b/airflow-core/docs/howto/customize-ui.rst
@@ -71,6 +71,7 @@ We can provide a JSON configuration to customize the UI.
 .. important::
 
   - You can customize the ``brand``, ``gray``, ``black``, and ``white`` color 
tokens, ``globalCss``, and the navigation icon via ``icon`` (and 
``icon_dark_mode``).
+  - All top-level fields (``tokens``, ``globalCss``, ``icon``, 
``icon_dark_mode``) are **optional** — you can supply any combination, 
including an empty ``{}`` to restore OSS defaults.
   - 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(...)" }``.
diff --git a/airflow-core/newsfragments/64552.improvement.rst 
b/airflow-core/newsfragments/64552.improvement.rst
new file mode 100644
index 00000000000..ae70554cd22
--- /dev/null
+++ b/airflow-core/newsfragments/64552.improvement.rst
@@ -0,0 +1 @@
+Allow UI theme config with only CSS overrides, icon only, or empty ``{}`` to 
restore OSS defaults. The ``tokens`` field is now optional in the theme 
configuration.
diff --git a/airflow-core/src/airflow/api_fastapi/common/types.py 
b/airflow-core/src/airflow/api_fastapi/common/types.py
index bd4176a9fd9..7d2a944c822 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, Any, Literal
+from typing import Annotated, Literal
 
 from pydantic import (
     AfterValidator,
@@ -208,15 +208,11 @@ class ThemeColors(BaseModel):
             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"], ThemeColors]
+    tokens: dict[Literal["colors"], ThemeColors] | None = None
     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/datamodels/ui/config.py 
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/config.py
index 96cd4aaad26..a511b31142b 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/config.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/config.py
@@ -16,6 +16,8 @@
 # under the License.
 from __future__ import annotations
 
+from pydantic import ConfigDict, field_serializer
+
 from airflow.api_fastapi.common.types import Theme, UIAlert
 from airflow.api_fastapi.core_api.base import BaseModel
 
@@ -23,6 +25,8 @@ from airflow.api_fastapi.core_api.base import BaseModel
 class ConfigResponse(BaseModel):
     """configuration serializer."""
 
+    model_config = ConfigDict(json_schema_mode_override="validation")
+
     fallback_page_limit: int
     auto_refresh_interval: int
     hide_paused_dags_by_default: bool
@@ -36,3 +40,9 @@ class ConfigResponse(BaseModel):
     external_log_name: str | None = None
     theme: Theme | None
     multi_team: bool
+
+    @field_serializer("theme")
+    def serialize_theme(self, theme: Theme | None) -> dict | None:
+        if theme is None:
+            return None
+        return theme.model_dump(exclude_none=True)
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 0ae06e44046..c1553542f03 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
@@ -3492,11 +3492,13 @@ components:
     Theme:
       properties:
         tokens:
-          additionalProperties:
-            $ref: '#/components/schemas/ThemeColors'
-          propertyNames:
-            const: colors
-          type: object
+          anyOf:
+          - additionalProperties:
+              $ref: '#/components/schemas/ThemeColors'
+            propertyNames:
+              const: colors
+            type: object
+          - type: 'null'
           title: Tokens
         globalCss:
           anyOf:
@@ -3517,13 +3519,80 @@ components:
           - type: 'null'
           title: Icon Dark Mode
       type: object
-      required:
-      - tokens
       title: Theme
       description: JSON to modify Chakra's theme.
     ThemeColors:
-      additionalProperties: true
+      properties:
+        brand:
+          anyOf:
+          - 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
+          - type: 'null'
+          title: Brand
+        gray:
+          anyOf:
+          - 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
+          - type: 'null'
+          title: Gray
+        black:
+          anyOf:
+          - additionalProperties:
+              $ref: '#/components/schemas/OklchColor'
+            propertyNames:
+              const: value
+            type: object
+          - type: 'null'
+          title: Black
+        white:
+          anyOf:
+          - additionalProperties:
+              $ref: '#/components/schemas/OklchColor'
+            propertyNames:
+              const: value
+            type: object
+          - type: 'null'
+          title: White
       type: object
+      title: ThemeColors
+      description: Color tokens for the UI theme. All fields are optional; at 
least
+        one must be provided.
     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 c37bb840017..dcb743c34f8 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
@@ -9087,13 +9087,20 @@ export const $TeamResponse = {
 export const $Theme = {
     properties: {
         tokens: {
-            additionalProperties: {
-                '$ref': '#/components/schemas/ThemeColors'
-            },
-            propertyNames: {
-                const: 'colors'
-            },
-            type: 'object',
+            anyOf: [
+                {
+                    additionalProperties: {
+                        '$ref': '#/components/schemas/ThemeColors'
+                    },
+                    propertyNames: {
+                        const: 'colors'
+                    },
+                    type: 'object'
+                },
+                {
+                    type: 'null'
+                }
+            ],
             title: 'Tokens'
         },
         globalCss: {
@@ -9135,14 +9142,96 @@ export const $Theme = {
         }
     },
     type: 'object',
-    required: ['tokens'],
     title: 'Theme',
     description: "JSON to modify Chakra's theme."
 } as const;
 
 export const $ThemeColors = {
-    additionalProperties: true,
-    type: 'object'
+    properties: {
+        brand: {
+            anyOf: [
+                {
+                    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'
+                },
+                {
+                    type: 'null'
+                }
+            ],
+            title: 'Brand'
+        },
+        gray: {
+            anyOf: [
+                {
+                    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'
+                },
+                {
+                    type: 'null'
+                }
+            ],
+            title: 'Gray'
+        },
+        black: {
+            anyOf: [
+                {
+                    additionalProperties: {
+                        '$ref': '#/components/schemas/OklchColor'
+                    },
+                    propertyNames: {
+                        const: 'value'
+                    },
+                    type: 'object'
+                },
+                {
+                    type: 'null'
+                }
+            ],
+            title: 'Black'
+        },
+        white: {
+            anyOf: [
+                {
+                    additionalProperties: {
+                        '$ref': '#/components/schemas/OklchColor'
+                    },
+                    propertyNames: {
+                        const: 'value'
+                    },
+                    type: 'object'
+                },
+                {
+                    type: 'null'
+                }
+            ],
+            title: 'White'
+        }
+    },
+    type: 'object',
+    title: 'ThemeColors',
+    description: 'Color tokens for the UI theme. All fields are optional; at 
least one must be provided.'
 } as const;
 
 export const $TokenType = {
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 9a6c9fb7935..9d1380698f9 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
@@ -2241,9 +2241,9 @@ export type TeamResponse = {
  * JSON to modify Chakra's theme.
  */
 export type Theme = {
-    tokens: {
-        [key: string]: ThemeColors;
-    };
+    tokens?: {
+    [key: string]: ThemeColors;
+} | null;
     globalCss?: {
     [key: string]: {
         [key: string]: unknown;
@@ -2253,8 +2253,26 @@ export type Theme = {
     icon_dark_mode?: string | null;
 };
 
+/**
+ * Color tokens for the UI theme. All fields are optional; at least one must 
be provided.
+ */
 export type ThemeColors = {
-    [key: string]: unknown;
+    brand?: {
+    [key: string]: {
+        [key: string]: OklchColor;
+    };
+} | null;
+    gray?: {
+    [key: string]: {
+        [key: string]: OklchColor;
+    };
+} | null;
+    black?: {
+    [key: string]: OklchColor;
+} | null;
+    white?: {
+    [key: string]: OklchColor;
+} | null;
 };
 
 /**
diff --git a/airflow-core/src/airflow/ui/src/theme.ts 
b/airflow-core/src/airflow/ui/src/theme.ts
index fc9c07a99b8..15b34dda2c9 100644
--- a/airflow-core/src/airflow/ui/src/theme.ts
+++ b/airflow-core/src/airflow/ui/src/theme.ts
@@ -406,16 +406,20 @@ const defaultAirflowTheme = {
 export const createTheme = (userTheme?: Theme) => {
   const defaultAirflowConfig = defineConfig({ theme: defaultAirflowTheme });
 
-  const userConfig = defineConfig(
-    userTheme
-      ? {
-          theme: { tokens: userTheme.tokens },
+  const userConfig = userTheme
+    ? defineConfig({
+        ...(userTheme.tokens !== undefined && {
+          theme: { tokens: userTheme.tokens as Record<string, unknown> },
+        }),
+        ...(userTheme.globalCss !== undefined && {
           globalCss: userTheme.globalCss as Record<string, SystemStyleObject>,
-        }
-      : {},
-  );
+        }),
+      })
+    : undefined;
 
-  const mergedConfig = mergeConfigs(defaultConfig, defaultAirflowConfig, 
userConfig);
+  const mergedConfig = userConfig
+    ? mergeConfigs(defaultConfig, defaultAirflowConfig, userConfig)
+    : mergeConfigs(defaultConfig, defaultAirflowConfig);
 
   return createSystem(mergedConfig);
 };
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 3476f6a5293..da17ca505cc 100644
--- a/airflow-core/tests/unit/api_fastapi/common/test_types.py
+++ b/airflow-core/tests/unit/api_fastapi/common/test_types.py
@@ -147,7 +147,7 @@ class TestThemeColors:
 
     def test_serialization_excludes_none_fields(self):
         colors = ThemeColors.model_validate({"brand": _BRAND_SCALE})
-        dumped = colors.model_dump()
+        dumped = colors.model_dump(exclude_none=True)
         assert "brand" in dumped
         assert "gray" not in dumped
         assert "black" not in dumped
@@ -200,10 +200,37 @@ class TestTheme:
     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()
+        dumped = theme.model_dump(exclude_none=True)
         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)"
+
+    def test_globalcss_only_theme(self):
+        """tokens is optional; globalCss alone is sufficient."""
+        theme = Theme.model_validate({"globalCss": {"button": 
{"text-transform": "uppercase"}}})
+        assert theme.tokens is None
+        assert theme.globalCss == {"button": {"text-transform": "uppercase"}}
+
+    def test_icon_only_theme(self):
+        """tokens is optional; an icon URL alone is sufficient."""
+        theme = Theme.model_validate({"icon": "https://example.com/logo.svg"})
+        assert theme.tokens is None
+        assert theme.icon == "https://example.com/logo.svg";
+
+    def test_empty_theme(self):
+        """An empty theme object is valid — it means 'use OSS defaults'."""
+        theme = Theme.model_validate({})
+        assert theme.tokens is None
+        assert theme.globalCss is None
+        assert theme.icon is None
+        assert theme.icon_dark_mode is None
+
+    def test_theme_serialization_excludes_none_tokens(self):
+        """When tokens is None it must not appear in the serialized output."""
+        theme = Theme.model_validate({"globalCss": {"a": {"color": "red"}}})
+        dumped = theme.model_dump(exclude_none=True)
+        assert "tokens" not in dumped
+        assert dumped == {"globalCss": {"a": {"color": "red"}}}
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 dbc3c0eb649..8b9982fc47b 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
@@ -136,6 +136,19 @@ def mock_config_data_all_colors():
         yield
 
 
+THEME_CSS_ONLY = {
+    "globalCss": {
+        "button": {"text-transform": "uppercase"},
+    }
+}
+
+
[email protected]
+def mock_config_data_css_only():
+    with conf_vars(_theme_conf_vars(THEME_CSS_ONLY)):
+        yield
+
+
 class TestGetConfig:
     def test_should_response_200(self, mock_config_data, test_client):
         """
@@ -170,3 +183,14 @@ class TestGetConfig:
         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)"}
+
+    def test_should_response_200_with_css_only_theme(self, 
mock_config_data_css_only, test_client):
+        """Theme with only globalCss (no tokens) is valid and round-trips 
correctly."""
+        response = test_client.get("/config")
+
+        assert response.status_code == 200
+        theme = response.json()["theme"]
+        assert "tokens" not in theme
+        assert theme["globalCss"] == {"button": {"text-transform": 
"uppercase"}}
+        assert "icon" not in theme
+        assert "icon_dark_mode" not in theme

Reply via email to