diff --git a/club/migrations/0014_club_secondary_color.py b/club/migrations/0014_club_secondary_color.py new file mode 100644 index 0000000..8829a7b --- /dev/null +++ b/club/migrations/0014_club_secondary_color.py @@ -0,0 +1,19 @@ +# Generated by Django 6.0.6 on 2026-07-24 16:30 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('club', '0013_club_created_club_modified_clubmembership_created_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='club', + name='secondary_color', + field=models.CharField(blank=True, help_text="Hex colour for highlights on the club's pages, e.g. avatar initials. Defaults to the theme's secondary colour.", max_length=7, validators=[django.core.validators.RegexValidator('^#[0-9a-fA-F]{6}$', 'Enter a colour as a hex value, e.g. #be185d.')], verbose_name='secondary colour'), + ), + ] diff --git a/club/models.py b/club/models.py index 0a3a5db..80c3602 100644 --- a/club/models.py +++ b/club/models.py @@ -40,6 +40,14 @@ class Club(UUIDModel): help_text=_("Hex colour for buttons and links on the club's pages, e.g. #1e40af."), ) + secondary_color = models.CharField( + _("secondary colour"), + max_length=7, + blank=True, + validators=[RegexValidator(r"^#[0-9a-fA-F]{6}$", _("Enter a colour as a hex value, e.g. #be185d."))], + help_text=_("Hex colour for highlights on the club's pages, e.g. avatar initials. Defaults to the theme's secondary colour."), + ) + archived_at = models.DateTimeField(_("archived at"), null=True, blank=True, help_text=_("Archived clubs stop resolving on their subdomain, but their data is retained.")) objects = ClubManager() @@ -69,19 +77,29 @@ class Club(UUIDModel): @property def primary_content_color(self) -> str: - """Readable text colour to sit *on* ``primary_color``. + """Readable text colour to sit *on* ``primary_color``. See ``_content_color_for``.""" + return self._content_color_for(self.primary_color) + + @property + def secondary_content_color(self) -> str: + """Readable text colour to sit *on* ``secondary_color``. See ``_content_color_for``.""" + return self._content_color_for(self.secondary_color) + + @staticmethod + def _content_color_for(hex_color: str) -> str: + """Black or white, whichever reads on ``hex_color``. A club picking a pale yellow would otherwise get white-on-yellow buttons. Relative luminance per WCAG, with its 0.179 threshold for black vs white. """ - if not self.primary_color: + if not hex_color: return "" def channel(value: int) -> float: fraction = value / 255 return fraction / 12.92 if fraction <= 0.04045 else ((fraction + 0.055) / 1.055) ** 2.4 - red, green, blue = (channel(int(self.primary_color[index : index + 2], 16)) for index in (1, 3, 5)) + red, green, blue = (channel(int(hex_color[index : index + 2], 16)) for index in (1, 3, 5)) luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue return "#000000" if luminance > 0.179 else "#ffffff" diff --git a/club/tests.py b/club/tests.py index ba62d86..8d699a3 100644 --- a/club/tests.py +++ b/club/tests.py @@ -1018,6 +1018,15 @@ class BrandingTests(TestCase): def test_no_colour_means_no_override(self): self.assertNotContains(self.login_page("ajax-united.rosterchief.app"), "--color-primary") + def test_a_club_secondary_colour_overrides_the_theme(self): + self.club.secondary_color = "#be185d" + self.club.save() + + self.assertContains(self.login_page("ajax-united.rosterchief.app"), "--color-secondary: #be185d") + + def test_no_secondary_colour_means_no_override(self): + self.assertNotContains(self.login_page("ajax-united.rosterchief.app"), "--color-secondary") + class ClubBrandingModelTests(TestCase): def test_initials_use_the_first_two_words(self): diff --git a/controlpanel/forms.py b/controlpanel/forms.py index 1c5c4b8..6c6f33f 100644 --- a/controlpanel/forms.py +++ b/controlpanel/forms.py @@ -13,12 +13,15 @@ from .services.admins import find_member_by_email class ClubForm(forms.ModelForm): class Meta: model = Club - fields = ["name", "slug", "logo", "primary_color"] + fields = ["name", "slug", "logo", "primary_color", "secondary_color"] help_texts = {"slug": _("Drives the club's subdomain. Left blank, it is derived from the name.")} # Deliberately a text input, not : a colour picker cannot # express "no colour" -- it would submit #000000 for every club that never # touched it, and every club would silently get a black theme. - widgets = {"primary_color": forms.TextInput(attrs={"placeholder": "#1e40af"})} + widgets = { + "primary_color": forms.TextInput(attrs={"placeholder": "#1e40af"}), + "secondary_color": forms.TextInput(attrs={"placeholder": "#be185d"}), + } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/templates/_club_base.html b/templates/_club_base.html index 40f659b..bd37541 100644 --- a/templates/_club_base.html +++ b/templates/_club_base.html @@ -10,17 +10,24 @@ {% endblock title %} {% block extra %} - {% if club.primary_color %} + {% if club.primary_color or club.secondary_color %} {% comment %} daisyUI declares its theme variables inside `@layer base`, and unlayered styles beat every layered rule regardless of specificity -- so this plain :root wins - without any !important or selector games. primary_content_color is computed from - the club's colour so a pale brand doesn't end up with white-on-yellow buttons. + without any !important or selector games. primary_content_color / secondary_content_color + are computed from the club's colours so a pale brand doesn't end up with + white-on-yellow buttons or initials. {% endcomment %} {% endif %} @@ -33,7 +40,7 @@ {% else %} {# Never the RosterChief mark: that would pass our branding off as the club's own. #}