Add a secondary brand colour for clubs

Mirrors primary_color: a club-picked hex highlight used for accents like
the avatar-initials badge, with a computed readable text colour so a
pale pick doesn't produce white-on-yellow text. The shared contrast math
moves into _content_color_for so both colours use the same rule.
This commit is contained in:
2026-07-27 10:26:31 +02:00
parent ccaa9e991a
commit 075c2918b6
5 changed files with 67 additions and 11 deletions

View File

@@ -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'),
),
]

View File

@@ -40,6 +40,14 @@ class Club(UUIDModel):
help_text=_("Hex colour for buttons and links on the club's pages, e.g. #1e40af."), 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.")) 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() objects = ClubManager()
@@ -69,19 +77,29 @@ class Club(UUIDModel):
@property @property
def primary_content_color(self) -> str: 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. 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. Relative luminance per WCAG, with its 0.179 threshold for black vs white.
""" """
if not self.primary_color: if not hex_color:
return "" return ""
def channel(value: int) -> float: def channel(value: int) -> float:
fraction = value / 255 fraction = value / 255
return fraction / 12.92 if fraction <= 0.04045 else ((fraction + 0.055) / 1.055) ** 2.4 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 luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue
return "#000000" if luminance > 0.179 else "#ffffff" return "#000000" if luminance > 0.179 else "#ffffff"

View File

@@ -1018,6 +1018,15 @@ class BrandingTests(TestCase):
def test_no_colour_means_no_override(self): def test_no_colour_means_no_override(self):
self.assertNotContains(self.login_page("ajax-united.rosterchief.app"), "--color-primary") 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): class ClubBrandingModelTests(TestCase):
def test_initials_use_the_first_two_words(self): def test_initials_use_the_first_two_words(self):

View File

@@ -13,12 +13,15 @@ from .services.admins import find_member_by_email
class ClubForm(forms.ModelForm): class ClubForm(forms.ModelForm):
class Meta: class Meta:
model = Club 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.")} help_texts = {"slug": _("Drives the club's subdomain. Left blank, it is derived from the name.")}
# Deliberately a text input, not <input type="color">: a colour picker cannot # Deliberately a text input, not <input type="color">: a colour picker cannot
# express "no colour" -- it would submit #000000 for every club that never # express "no colour" -- it would submit #000000 for every club that never
# touched it, and every club would silently get a black theme. # 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): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)

View File

@@ -10,17 +10,24 @@
{% endblock title %} {% endblock title %}
{% block extra %} {% block extra %}
{% if club.primary_color %} {% if club.primary_color or club.secondary_color %}
{% comment %} {% comment %}
daisyUI declares its theme variables inside `@layer base`, and unlayered styles daisyUI declares its theme variables inside `@layer base`, and unlayered styles
beat every layered rule regardless of specificity -- so this plain :root wins beat every layered rule regardless of specificity -- so this plain :root wins
without any !important or selector games. primary_content_color is computed from without any !important or selector games. primary_content_color / secondary_content_color
the club's colour so a pale brand doesn't end up with white-on-yellow buttons. are computed from the club's colours so a pale brand doesn't end up with
white-on-yellow buttons or initials.
{% endcomment %} {% endcomment %}
<style> <style>
:root { :root {
{% if club.primary_color %}
--color-primary: {{ club.primary_color }}; --color-primary: {{ club.primary_color }};
--color-primary-content: {{ club.primary_content_color }}; --color-primary-content: {{ club.primary_content_color }};
{% endif %}
{% if club.secondary_color %}
--color-secondary: {{ club.secondary_color }};
--color-secondary-content: {{ club.secondary_content_color }};
{% endif %}
} }
</style> </style>
{% endif %} {% endif %}
@@ -33,7 +40,7 @@
{% else %} {% else %}
{# Never the RosterChief mark: that would pass our branding off as the club's own. #} {# Never the RosterChief mark: that would pass our branding off as the club's own. #}
<div class="avatar avatar-placeholder"> <div class="avatar avatar-placeholder">
<div class="w-16 rounded-full bg-primary text-primary-content"> <div class="w-16 rounded-full bg-secondary text-secondary-content">
<span class="font-roboto text-xl font-bold">{{ club.initials }}</span> <span class="font-roboto text-xl font-bold">{{ club.initials }}</span>
</div> </div>
</div> </div>