fix(element-style): clamp out-of-range colour components instead of rejecting

A regression this framework shipped. The eight scoreboards used to read their
colours through sports_card.coerce_rgb, which clamps; routing them through the
shared element_color sent them through _normalize_color, which rejected any
component outside 0..255 and fell back to the default. So a configured
[999, -5, 20] -- a typo'd bright red -- rendered white instead of (255, 0, 20).

Their own test_element_text_colors.py caught it: one case of nineteen, in all
eight plugins, failing only once the core change reached main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-13 12:21:11 -04:00
co-authored by Claude Opus 5
parent 69d408b321
commit c0e473882a
2 changed files with 20 additions and 3 deletions
+12 -3
View File
@@ -763,6 +763,12 @@ def _adopt_handwritten_block(schema: Dict[str, Any],
for key in element_keys:
_upgrade_font_property(props[key])
# Marked like a declared element, so consumers can tell the style
# blocks from whatever else the plugin keeps under customization.
# Football's block also holds favorite_result_colors, which is a
# feature with its own fields -- without this the editor treats it
# as an element and every row grows an "enabled"/"win color" column.
props[key]['x-style-managed'] = True
modes = customization.get('x-style-modes')
if isinstance(modes, list) and modes:
@@ -904,11 +910,14 @@ def _normalize_color(value: Any) -> Optional[Tuple[int, int, int]]:
return None
if isinstance(value, (list, tuple)) and len(value) == 3:
try:
rgb = tuple(int(c) for c in value)
# Clamped, not rejected. The readers this replaced clamped
# (sports_card.coerce_rgb), and the eight scoreboards' own tests
# pin it: a configured [999, -5, 20] is a typo'd bright red, and
# answering "unusable, take the default" turned it white instead.
rgb = tuple(max(0, min(255, int(c))) for c in value)
except (TypeError, ValueError):
return None
if all(0 <= c <= 255 for c in rgb):
return rgb # type: ignore[return-value]
return rgb # type: ignore[return-value]
return None
+8
View File
@@ -53,6 +53,14 @@ class TestColour:
cfg = {"customization": {"score_text": {"text_color": value}}}
assert C.element_color(cfg, "score_text", (9, 9, 9)) == (9, 9, 9)
def test_out_of_range_components_are_clamped_not_rejected(self):
"""The readers this replaced clamped, and the eight scoreboards' own
tests pin it. Rejecting instead turned a typo'd [999, -5, 20] -- a
bright red -- into the default white, which is a colour the user never
asked for rather than the one they nearly asked for."""
cfg = {"customization": {"score_text": {"text_color": [999, -5, 20]}}}
assert C.element_color(cfg, "score_text", (9, 9, 9)) == (255, 0, 20)
def test_coerce_rgb_clamps_rather_than_rejecting(self):
assert C.coerce_rgb([300, -5, 20], (0, 0, 0)) == (255, 0, 20)