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."),
)
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"

View File

@@ -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):

View File

@@ -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 <input type="color">: 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)

View File

@@ -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 %}
<style>
:root {
--color-primary: {{ club.primary_color }};
--color-primary-content: {{ club.primary_content_color }};
{% if club.primary_color %}
--color-primary: {{ club.primary_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>
{% endif %}
@@ -33,7 +40,7 @@
{% else %}
{# Never the RosterChief mark: that would pass our branding off as the club's own. #}
<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>
</div>
</div>