mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
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>
212 lines
8.3 KiB
Python
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()
|