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:
19
club/migrations/0014_club_secondary_color.py
Normal file
19
club/migrations/0014_club_secondary_color.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user