Files
computer-playbook/tests/unit/python/utils/design/test_palette.py
T
kevinveenbirkenbachandClaude Opus 5.5 30c9008b6c fix(web-svc-design): keep links apart from body text for extreme bases
For a very dark base color the light palette used the base itself as
link color, which is as dark as body text: #001f3f gave a link at
contrast 1.02 against the text, so links in tables read as plain text.
The dark palette did the same for a very light base (#FFFF00: 1.11).

The lightness the link search starts from is clamped to 0.45..0.55 in
light and 0.70..0.80 in dark mode. #001f3f now yields #37577c in light
mode (2.27 against the text, AA on every surface). Bases inside the
band, #FFA500 among them, produce the same palette as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 23:14:02 +02:00

212 lines
8.3 KiB
Python

import unittest
from typing import ClassVar
from ansible.errors import AnsibleError
from plugins.lookup.design_palette import LookupModule
from utils.design.palette import (
CONTRAST_BODY_TEXT,
CONTRAST_TEXT,
CONTRAST_UI,
SCALE_STEPS,
STATUS_HUES,
build_palette,
contrast,
parse_hex,
relative_luminance,
)
class TestPaletteContrast(unittest.TestCase):
BASES: ClassVar[tuple[str, ...]] = (
"#001f3f",
"#FFA500",
"#FFFF00",
"#00FF00",
"#808080",
"#FF00FF",
)
MODES: ClassVar[tuple[str, ...]] = ("light", "dark")
LINK_APART_FROM_TEXT: ClassVar[float] = 1.4
def _assert_min(
self, fg: str, backgrounds: list[str], target: float, label: str
) -> None:
worst = min(contrast(fg, bg) for bg in backgrounds)
self.assertGreaterEqual(
worst, target, f"{label}: {fg} reaches only {worst:.2f}"
)
def test_semantic_tokens_meet_wcag_targets(self) -> None:
for base in self.BASES:
palette = build_palette(base)
for mode in self.MODES:
with self.subTest(base=base, mode=mode):
t = palette[mode]
surfaces = [t[f"--design-surface-{i}"] for i in (1, 2, 3)]
content = surfaces[:2]
self._assert_min(
t["--design-text"], surfaces, CONTRAST_BODY_TEXT, "text"
)
self._assert_min(
t["--design-text-muted"], surfaces, CONTRAST_TEXT, "muted"
)
self._assert_min(
t["--design-link"], surfaces, CONTRAST_TEXT, "link"
)
self._assert_min(
t["--design-primary"], content, CONTRAST_UI, "primary"
)
self._assert_min(
t["--design-border-strong"], content, CONTRAST_UI, "border"
)
self._assert_min(
t["--design-on-primary"],
[t["--design-primary"]],
CONTRAST_TEXT,
"on-primary",
)
for status in STATUS_HUES:
color = t[f"--design-{status}"]
self._assert_min(
color,
[*surfaces, t[f"--design-{status}-subtle"]],
CONTRAST_TEXT,
status,
)
self._assert_min(
t[f"--design-on-{status}"],
[color],
CONTRAST_TEXT,
f"on-{status}",
)
def test_interaction_states_keep_their_text_readable(self) -> None:
for base in self.BASES:
palette = build_palette(base)
for mode in self.MODES:
with self.subTest(base=base, mode=mode):
t = palette[mode]
states = [
t["--design-surface-hover"],
t["--design-surface-active"],
]
self._assert_min(
t["--design-text"], states, CONTRAST_BODY_TEXT, "text"
)
self._assert_min(
t["--design-text-muted"], states, CONTRAST_TEXT, "muted"
)
for name in ("link", *STATUS_HUES):
self._assert_min(
t[f"--design-{name}"], states, CONTRAST_TEXT, name
)
for fill in ("primary", *STATUS_HUES):
self._assert_min(
t[f"--design-on-{fill}"],
[t[f"--design-{fill}-hover"], t[f"--design-{fill}-active"]],
CONTRAST_TEXT,
f"on-{fill}",
)
def test_frame_is_a_deep_brand_surface_in_both_modes(self) -> None:
for base in self.BASES:
palette = build_palette(base)
for mode in self.MODES:
with self.subTest(base=base, mode=mode):
t = palette[mode]
frame = t["--design-frame"]
self._assert_min(
t["--design-on-frame"],
[
frame,
t["--design-frame-hover"],
t["--design-frame-active"],
],
CONTRAST_BODY_TEXT,
"on-frame",
)
self.assertLess(
relative_luminance(frame),
relative_luminance(palette["light"]["--design-surface-3"]),
"the frame must stay darker than every light surface",
)
self.assertGreaterEqual(
contrast(frame, t["--design-surface-1"]),
1.2,
"the frame must stand out against the page",
)
def test_light_frame_keeps_a_deep_base_unchanged(self) -> None:
self.assertEqual(build_palette("#001f3f")["light"]["--design-frame"], "#001f3f")
def test_light_primary_keeps_a_compliant_base_unchanged(self) -> None:
self.assertEqual(
build_palette("#001f3f")["light"]["--design-primary"], "#001f3f"
)
def test_a_link_stays_apart_from_body_text(self) -> None:
for base in (*self.BASES, "#000000", "#ffffff"):
palette = build_palette(base)
for mode in self.MODES:
with self.subTest(base=base, mode=mode):
t = palette[mode]
self.assertGreaterEqual(
contrast(t["--design-link"], t["--design-text"]),
self.LINK_APART_FROM_TEXT,
"a link as dark or as light as body text cannot be told from it",
)
def test_dark_mode_does_not_invert_the_light_palette(self) -> None:
palette = build_palette("#001f3f")
self.assertNotEqual(
palette["light"]["--design-primary"], palette["dark"]["--design-primary"]
)
self.assertLess(
relative_luminance(palette["dark"]["--design-surface-1"]),
relative_luminance(palette["light"]["--design-surface-1"]),
)
class TestPaletteScale(unittest.TestCase):
def test_scales_run_from_surface_to_text_in_both_modes(self) -> None:
palette = build_palette("#001f3f")
for mode, descending in (("light", True), ("dark", False)):
for prefix in ("primary", "neutral"):
with self.subTest(mode=mode, prefix=prefix):
values = [
palette[mode][f"--design-{prefix}-{step}"]
for step in SCALE_STEPS
]
luminances = [relative_luminance(v) for v in values]
self.assertEqual(luminances, sorted(luminances, reverse=descending))
def test_every_color_carries_a_matching_rgb_triplet(self) -> None:
palette = build_palette("#FFA500")
for section in palette.values():
for name, value in section.items():
if name.endswith("-rgb"):
continue
expected = ", ".join(str(round(c * 255)) for c in parse_hex(value))
self.assertEqual(section[f"{name}-rgb"], expected, name)
class TestPaletteInput(unittest.TestCase):
def test_short_hex_equals_long_hex(self) -> None:
self.assertEqual(build_palette("#fa0"), build_palette("#ffaa00"))
def test_invalid_hex_names_the_value(self) -> None:
with self.assertRaisesRegex(ValueError, "'navy'"):
build_palette("navy")
def test_lookup_turns_invalid_hex_into_ansible_error(self) -> None:
with self.assertRaisesRegex(AnsibleError, "#12345"):
LookupModule().run(["#12345"])
def test_lookup_returns_the_palette(self) -> None:
self.assertEqual(LookupModule().run(["#001f3f"]), [build_palette("#001f3f")])
if __name__ == "__main__":
unittest.main()