From b45f673da765aca7efb668c99270232581a70d26 Mon Sep 17 00:00:00 2001 From: catborise Date: Wed, 21 Oct 2026 07:58:08 +0300 Subject: [PATCH] fix(templates): status colours keep their contrast on every theme The pool, network and interface cards had a solid green or red header with light text, and status words (Connected, ON, OFF, the DRBD state) were in the theme's green or red text colour. axe found them below the WCAG AA 4.5:1 contrast on the live theme (4.29:1, 3.52:1), on the default zephyr theme, whose green is light (2.5:1), and on dark themes (darkly 1.18:1). The card headers, and the status words as badges, are now the theme's pale status background with its dark status text (bg-*-subtle with text-*-emphasis). Bootstrap derives the pair from each theme's palette, and it does not depend on the page's background: the dark Bootswatch themes keep light-mode emphasis colours, which on their own would be dark text on a dark page. The card border keeps the status colour. --- .../templates/allinstances_index_grouped.html | 6 +-- .../allinstances_index_nongrouped.html | 2 +- interfaces/templates/interfaces.html | 4 +- networks/templates/network.html | 8 ++-- networks/templates/networks.html | 4 +- storages/templates/storages.html | 4 +- webvirtcloud/tests/test_status_contrast.py | 48 +++++++++++++++++++ 7 files changed, 62 insertions(+), 14 deletions(-) create mode 100644 webvirtcloud/tests/test_status_contrast.py diff --git a/instances/templates/allinstances_index_grouped.html b/instances/templates/allinstances_index_grouped.html index 38fd6d2d..ad1ea64a 100644 --- a/instances/templates/allinstances_index_grouped.html +++ b/instances/templates/allinstances_index_grouped.html @@ -29,9 +29,9 @@ {% if compute.status is True %} - {% trans "Connected" %} + {% trans "Connected" %} {% else %} - {% trans "Not Connected" %} + {% trans "Not Connected" %} {% endif %} {% if app_settings.VM_DRBD_STATUS == 'True' %} @@ -76,7 +76,7 @@ {% if app_settings.VM_DRBD_STATUS == 'True' %} - {% if instance.drbd == "Primary/OK" or instance.drbd == "Secondary/OK" %}{% else %}{% endif %}{{ instance.drbd }} + {% if instance.drbd == "Primary/OK" or instance.drbd == "Secondary/OK" %}{% else %}{% endif %}{{ instance.drbd }} {% endif %} {{ instance.info.3 }} diff --git a/instances/templates/allinstances_index_nongrouped.html b/instances/templates/allinstances_index_nongrouped.html index 14474779..34b8f476 100644 --- a/instances/templates/allinstances_index_nongrouped.html +++ b/instances/templates/allinstances_index_nongrouped.html @@ -43,7 +43,7 @@ {% if app_settings.VM_DRBD_STATUS == 'True' %} - {% if instance.drbd == "Primary/OK" or instance.drbd == "Secondary/OK" %}{% else %}{% endif %}{{ instance.drbd }} + {% if instance.drbd == "Primary/OK" or instance.drbd == "Secondary/OK" %}{% else %}{% endif %}{{ instance.drbd }} {% endif %} {{ instance.info.3 }} diff --git a/interfaces/templates/interfaces.html b/interfaces/templates/interfaces.html index f5913b6b..fce57db4 100644 --- a/interfaces/templates/interfaces.html +++ b/interfaces/templates/interfaces.html @@ -54,9 +54,9 @@ {% for iface in ifaces_all %}
-
+
diff --git a/networks/templates/network.html b/networks/templates/network.html index 19135f25..04d0f84d 100644 --- a/networks/templates/network.html +++ b/networks/templates/network.html @@ -110,9 +110,9 @@
{% trans "DHCP" %}:
{% if ipv4_dhcp_range_start and ipv4_dhcp_range_end %} - {% trans "ON" %} + {% trans "ON" %} {% else %} - {% trans "OFF" %} + {% trans "OFF" %} {% endif %}
{% if ipv4_dhcp_range_start and ipv4_dhcp_range_end %} @@ -222,9 +222,9 @@
{% if ipv6_dhcp_range_start and ipv6_dhcp_range_end %} - {% trans "ON" %} + {% trans "ON" %} {% else %} - {% trans "OFF" %} + {% trans "OFF" %} {% endif %}
{% if ipv6_dhcp_range_start and ipv6_dhcp_range_end %} diff --git a/networks/templates/networks.html b/networks/templates/networks.html index 1b3f44b8..64bbf813 100644 --- a/networks/templates/networks.html +++ b/networks/templates/networks.html @@ -50,9 +50,9 @@ {% for pool in networks %}
-
+
diff --git a/storages/templates/storages.html b/storages/templates/storages.html index 0fca5a50..8c3bb373 100644 --- a/storages/templates/storages.html +++ b/storages/templates/storages.html @@ -50,9 +50,9 @@ {% for pool in storages %}
-
+
diff --git a/webvirtcloud/tests/test_status_contrast.py b/webvirtcloud/tests/test_status_contrast.py new file mode 100644 index 00000000..37763f12 --- /dev/null +++ b/webvirtcloud/tests/test_status_contrast.py @@ -0,0 +1,48 @@ +"""Status colours keep their contrast on every theme: a pool, network or +interface card's header, and a status word, is the theme's pale status +background with its dark status text, a pair that does not depend on the +page's background (dark themes keep light-mode emphasis colours).""" + +from unittest.mock import PropertyMock, patch + +import libvirt +from computes.models import Compute +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from lxml import html + + +class StatusContrastTests(TestCase): + def setUp(self): + self.conn = libvirt.open("test:///default") + self.addCleanup(self.conn.close) + self.compute = Compute.objects.create(name="contrast", hostname="localhost", login="", password="", type=4) + self.client.force_login(get_user_model().objects.create_superuser("contrast", "c@example.com", "pw")) + + def page(self, name, *args, compute=True): + with patch("vrtManager.connection.connection_manager.get_connection", return_value=self.conn): + response = self.client.get(reverse(name, args=[self.compute.id, *args] if compute else args)) + self.assertEqual(response.status_code, 200) + return html.fromstring(response.content) + + def test_card_headers_are_pale_with_dark_text(self): + for name in ("storages", "networks", "interfaces"): + with self.subTest(page=name): + headers = self.page(name).xpath("//div[contains(@class, 'card-header')]") + self.assertTrue(headers) + for header in headers: + classes = header.get("class").split() + self.assertTrue({"bg-success-subtle", "bg-danger-subtle"} & set(classes), classes) + link = header.xpath(".//a")[0].get("class").split() + self.assertTrue({"text-success-emphasis", "text-danger-emphasis"} & set(link), link) + + def test_status_words_are_pale_badges_with_dark_text(self): + status = self.page("network", "default").xpath("//span[normalize-space()='ON' or normalize-space()='OFF']") + # the test driver has no node memory statistics for the host row + with patch.object(Compute, "ram_usage", new_callable=PropertyMock, return_value=1): + status += self.page("instances:index", compute=False).xpath("//span[normalize-space()='Connected']") + self.assertEqual(len(status), 3) + for span in status: + self.assertIn(span.get("class"), ("badge bg-success-subtle text-success-emphasis", + "badge bg-danger-subtle text-danger-emphasis"))