Fix mismatched fallback text colour on the initials badge/button
The initials badge and reset button hardcoded two independent fallback literals -- a background (#ec4899 / #0ea5e9) and a text colour (#ffffff) -- that were only ever chosen together for a club's own colour via Club._content_color_for. #ffffff on #ec4899 or #0ea5e9 actually contrasts worse than black by the same WCAG formula the app already uses elsewhere (verified: 6.4:1 vs 3.3:1, and 7.6:1 vs 2.8:1). Added a contrast_color filter so the text colour is always derived from whatever background hex is actually in play -- real club colour or fallback alike -- instead of a second, independently guessed literal that can silently drift out of sync with the first. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -318,6 +318,27 @@ class PasswordResetEmailTests(TestCase):
|
||||
self.assertIn("AU", with_club) # initials fallback: no logo set
|
||||
self.assertIn("https://ajax-united.rosterchief.app/accounts/password/reset/key/abc-def/", with_club)
|
||||
|
||||
def test_the_initials_badge_text_contrasts_against_the_fallback_colour(self):
|
||||
# Club has no secondary_color, so the badge falls back to #ec4899 --
|
||||
# black text (contrast_color("#ec4899")) reads far better on it than
|
||||
# the white the template used to hardcode. See
|
||||
# club/templatetags/club_email.py::contrast_color.
|
||||
club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||
|
||||
rendered = render_to_string("account/email/password_reset_key_message.html", {"club": club, "password_reset_url": "https://ajax-united.rosterchief.app/accounts/password/reset/key/abc-def/"})
|
||||
|
||||
self.assertIn("background-color:#ec4899", rendered)
|
||||
self.assertIn("color:#000000", rendered)
|
||||
|
||||
def test_the_reset_button_text_contrasts_against_the_no_club_fallback_colour(self):
|
||||
# No club at all (the base-domain flow) -- the button falls back to
|
||||
# #0ea5e9, RosterChief's own sky blue, which also needs black text
|
||||
# for a passing contrast ratio, not the white previously hardcoded.
|
||||
rendered = render_to_string("account/email/password_reset_key_message.html", {"club": None, "password_reset_url": "https://rosterchief.app/accounts/password/reset/key/abc-def/", "current_site": None})
|
||||
|
||||
self.assertIn("background-color:#0ea5e9", rendered)
|
||||
self.assertIn("color:#000000", rendered)
|
||||
|
||||
without_club = render_to_string("account/email/password_reset_key_message.html", {"club": None, "password_reset_url": "https://rosterchief.app/accounts/password/reset/key/abc-def/", "current_site": SimpleNamespace(name="rosterchief.app")})
|
||||
self.assertIn("Roster", without_club)
|
||||
self.assertIn("Chief", without_club)
|
||||
|
||||
@@ -9,9 +9,27 @@ is relative.
|
||||
|
||||
from django import template
|
||||
|
||||
from club.models import Club
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def contrast_color(hex_color):
|
||||
"""Black or white, whichever reads on ``hex_color`` -- for a literal
|
||||
fallback background (e.g. ``club.secondary_color|default:"#ec4899"``)
|
||||
rather than a club's own colour, which already has a matching
|
||||
``primary_content_color``/``secondary_content_color`` computed for it.
|
||||
|
||||
Chaining this onto the *same* expression used for the background --
|
||||
``club.secondary_color|default:"#ec4899"|contrast_color`` -- rather than
|
||||
hardcoding a second, independently-guessed text colour is what keeps the
|
||||
two from drifting apart: a pale fallback and a dark one both get the
|
||||
contrast Club._content_color_for would compute for them either way.
|
||||
"""
|
||||
return Club._content_color_for(hex_color)
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def absolute_media_url(context, file_field):
|
||||
"""An absolute URL for ``file_field`` (e.g. ``club.logo``), for use in an
|
||||
|
||||
@@ -1258,6 +1258,17 @@ class ClubBrandingModelTests(TestCase):
|
||||
def test_no_colour_means_no_contrast_colour(self):
|
||||
self.assertEqual(Club(primary_color="").primary_content_color, "")
|
||||
|
||||
def test_contrast_color_filter_matches_the_same_algorithm(self):
|
||||
# HTML emails apply this to a literal fallback background (e.g.
|
||||
# club.secondary_color|default:"#ec4899") rather than a club's own
|
||||
# colour, so it can't be exercised through primary_content_color --
|
||||
# but it must still agree with it for a colour a club actually set.
|
||||
from club.templatetags.club_email import contrast_color
|
||||
|
||||
self.assertEqual(contrast_color("#ec4899"), Club(secondary_color="#ec4899").secondary_content_color)
|
||||
self.assertEqual(contrast_color("#fef08a"), "#000000")
|
||||
self.assertEqual(contrast_color("#1e40af"), "#ffffff")
|
||||
|
||||
def test_a_colour_must_be_a_hex_value(self):
|
||||
club = Club(name="Ajax United", primary_color="blue")
|
||||
|
||||
|
||||
@@ -1516,6 +1516,21 @@ class ParentClaimViewTests(ManagementTestBase):
|
||||
[reset_path] = [line for line in sent.body.splitlines() if "/accounts/password/reset/key/" in line]
|
||||
self.assertIn(reset_path.strip(), html_body)
|
||||
|
||||
def test_the_initials_badge_text_contrasts_against_the_fallback_colour(self):
|
||||
# self.club has no secondary_color set, so the badge falls back to
|
||||
# #ec4899 -- white text on that (the old hardcoded default) reads
|
||||
# far worse than black (contrast_color("#ec4899") == "#000000"), see
|
||||
# club/templatetags/club_email.py::contrast_color.
|
||||
self.submit()
|
||||
claim = ParentClaim.objects.get(club=self.club)
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
self.club_post("parent_claim_approve", {"child": str(self.child.pk)}, claim.pk)
|
||||
|
||||
[(html_body, _mimetype)] = mail.outbox[0].alternatives
|
||||
self.assertIn("background-color:#ec4899", html_body)
|
||||
self.assertIn("color:#000000", html_body)
|
||||
|
||||
def test_the_email_mentions_the_clubs_contact_email_when_set(self):
|
||||
self.club.contact_email = "info@ajax-united.example.com"
|
||||
self.club.save(update_fields=["contact_email"])
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
{% else %}
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="48" style="width:48px;">
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="48" height="48" bgcolor="{{ club.secondary_color|default:"#ec4899" }}" style="width:48px; height:48px; border-radius:24px; background-color:{{ club.secondary_color|default:"#ec4899" }}; color:{{ club.secondary_content_color|default:"#ffffff" }}; font-family: Arial, Helvetica, sans-serif; font-size:16px; font-weight:bold;">
|
||||
<td align="center" valign="middle" width="48" height="48" bgcolor="{{ club.secondary_color|default:"#ec4899" }}" style="width:48px; height:48px; border-radius:24px; background-color:{{ club.secondary_color|default:"#ec4899" }}; color:{{ club.secondary_color|default:"#ec4899"|contrast_color }}; font-family: Arial, Helvetica, sans-serif; font-size:16px; font-weight:bold;">
|
||||
{{ club.initials }}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -43,7 +43,7 @@
|
||||
<p style="margin:0 0 20px 0;">{% blocktrans with club=club.name child=child %}{{ club }} has confirmed that you're {{ child }}'s parent or guardian, and your account is ready.{% endblocktrans %}</p>
|
||||
|
||||
{% trans "Set your password" as set_password_label %}
|
||||
{% include "email/_button.html" with href=set_password_url label=set_password_label bg=club.primary_color|default:"#4f46e5" fg=club.primary_content_color|default:"#ffffff" %}
|
||||
{% include "email/_button.html" with href=set_password_url label=set_password_label bg=club.primary_color|default:"#4f46e5" fg=club.primary_color|default:"#4f46e5"|contrast_color %}
|
||||
|
||||
<p style="margin:0 0 16px 0; font-size:13px; color:#6b7280;">{% trans "That link is for you alone — please don't forward it." %}</p>
|
||||
|
||||
|
||||
@@ -4294,6 +4294,9 @@
|
||||
--tw-tracking: var(--tracking-wider);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
}
|
||||
.text-wrap {
|
||||
text-wrap: wrap;
|
||||
}
|
||||
.whitespace-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
{% else %}
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="48" style="width:48px;">
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="48" height="48" bgcolor="{{ club.secondary_color|default:"#ec4899" }}" style="width:48px; height:48px; border-radius:24px; background-color:{{ club.secondary_color|default:"#ec4899" }}; color:{{ club.secondary_content_color|default:"#ffffff" }}; font-family: Arial, Helvetica, sans-serif; font-size:16px; font-weight:bold;">
|
||||
<td align="center" valign="middle" width="48" height="48" bgcolor="{{ club.secondary_color|default:"#ec4899" }}" style="width:48px; height:48px; border-radius:24px; background-color:{{ club.secondary_color|default:"#ec4899" }}; color:{{ club.secondary_color|default:"#ec4899"|contrast_color }}; font-family: Arial, Helvetica, sans-serif; font-size:16px; font-weight:bold;">
|
||||
{{ club.initials }}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -60,7 +60,7 @@
|
||||
<p style="margin:0 0 20px 0;">{% trans "You're receiving this email because you or someone else has requested a password reset for your user account. It can be safely ignored if you did not request one." %}</p>
|
||||
|
||||
{% trans "Reset your password" as reset_label %}
|
||||
{% include "email/_button.html" with href=password_reset_url label=reset_label bg=club.primary_color|default:"#0ea5e9" fg=club.primary_content_color|default:"#ffffff" %}
|
||||
{% include "email/_button.html" with href=password_reset_url label=reset_label bg=club.primary_color|default:"#0ea5e9" fg=club.primary_color|default:"#0ea5e9"|contrast_color %}
|
||||
|
||||
{% if username %}
|
||||
<p style="margin:0;">{% blocktrans %}In case you forgot, your username is {{ username }}.{% endblocktrans %}</p>
|
||||
|
||||
Reference in New Issue
Block a user