Add a live D9-style preview to the Club identity page

Two columns (form left, preview right, matching D9) with a mini mock of this
app's own sidebar + a sample button -- primary_color/secondary_color are
what those actually control (base.html's --tenant-* properties), so the
preview reflects a real surface rather than D9's aspirational App/Website/
Email tabs. Colour swatch pickers sync with the existing hex text fields in
both directions, and a contrast-ratio row mirrors Club._content_color_for's
exact WCAG math client-side, so the ✓/⚠ shown is the real number the server
computes on save.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-20 09:00:13 +02:00
parent 8baf3f7b3d
commit fc3901fb5a
5 changed files with 363 additions and 50 deletions

View File

@@ -1736,3 +1736,130 @@
background: var(--color-club); background: var(--color-club);
} }
} }
/* --- Club identity live preview (management/templates/management/club_settings.html) ---
A mini mock of this app's own sidebar + a sample button -- genuinely what
primary_color/secondary_color affect (base.html's --tenant-* custom
properties), not a fabricated App/Website/Email mockup. Colours are set
inline by that template's own script as the form fields change; only
layout/typography lives here. */
@layer components {
.club-color-swatch {
width: 2rem;
height: 1.5rem;
padding: 0;
border: 1px solid var(--color-line);
border-radius: 0.375rem;
background: none;
cursor: pointer;
}
.preview-frame {
display: flex;
height: 220px;
overflow: hidden;
border: 1px solid var(--color-line);
border-radius: var(--radius-box);
}
.preview-sidebar {
display: flex;
width: 40%;
flex-shrink: 0;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
background: var(--color-ink);
color: #fff;
transition: background-color 0.15s ease, color 0.15s ease;
}
.preview-crest {
display: flex;
width: 1.5rem;
height: 1.5rem;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
background: rgba(255, 255, 255, 0.15);
font-family: var(--font-display);
font-size: 0.625rem;
font-weight: 800;
}
.preview-club-name {
margin-bottom: 0.25rem;
font-family: var(--font-display);
font-size: 0.6875rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
opacity: 0.9;
}
.preview-nav-item {
border-radius: 0.375rem;
padding: 0.375rem 0.5rem;
font-size: 0.6875rem;
opacity: 0.75;
}
.preview-nav-active {
opacity: 1;
font-weight: 700;
background: var(--color-club);
color: #fff;
transition: background-color 0.15s ease, color 0.15s ease;
}
.preview-panel {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.625rem;
padding: 1rem;
background: var(--color-paper);
}
.preview-panel-heading {
font-family: var(--font-display);
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-ink);
}
.preview-button {
align-self: flex-start;
border: none;
border-radius: var(--radius-box);
padding: 0.375rem 0.875rem;
font-family: var(--font-display);
font-size: 0.6875rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
background: var(--color-club);
color: #fff;
cursor: default;
transition: background-color 0.15s ease, color 0.15s ease;
}
.preview-skeleton-line {
height: 0.5rem;
border-radius: 999px;
background: var(--color-line);
}
.preview-skeleton-line-short {
width: 60%;
}
.contrast-icon {
display: inline-flex;
width: 1rem;
justify-content: center;
font-weight: 700;
}
}

View File

@@ -5,10 +5,16 @@
A club's own self-service identity/branding editor -- see A club's own self-service identity/branding editor -- see
management/forms.py's ClubSettingsForm for exactly which fields are management/forms.py's ClubSettingsForm for exactly which fields are
editable here versus platform-staff-only (slug, seasons). D9 "Club editable here versus platform-staff-only (slug, seasons). D9 "Club
identity & branding" in the design handoff is the reference; its identity & branding" in the design handoff is the reference: two columns,
live-preview and advanced (custom stylesheet / own domain) panels aren't form fields on the left and a live preview on the right. The preview here
real features here, so this sticks to the fields the form actually has, is scoped to what actually exists in this app rather than D9's aspirational
laid out in the same card-per-section language. App/Website/Email tabs -- primary_color drives the sidebar
(--tenant-sidebar-bg/fg, base.html), secondary_color drives the club accent
(--tenant-club/-dark/-content, buttons/active nav/crest) -- so the mini
sidebar + button mock below is genuinely what changing these fields does,
not a fabricated mockup. The colour math (content colour, contrast ratio)
mirrors Club._content_color_for exactly, so the "computed ratio" shown is
the real number the server will use, not an approximation.
{% endcomment %} {% endcomment %}
{% block panel_title %}{% trans "Club identity" %}{% endblock panel_title %} {% block panel_title %}{% trans "Club identity" %}{% endblock panel_title %}
@@ -19,58 +25,192 @@
{% endblock actions %} {% endblock actions %}
{% block panel %} {% block panel %}
<form id="club-settings-form" method="post" enctype="multipart/form-data"> <div class="grid grid-cols-1 gap-5 xl:grid-cols-[1.2fr_1fr]">
{% csrf_token %} <form id="club-settings-form" method="post" enctype="multipart/form-data" class="flex flex-col gap-5">
{% csrf_token %}
{% for error in form.non_field_errors %} {% for error in form.non_field_errors %}
<div class="alert alert-error"> <div class="alert alert-error">
{% lucide "circle-x" size=18 %} {% lucide "circle-x" size=18 %}
<span>{{ error }}</span> <span>{{ error }}</span>
</div>
{% endfor %}
<div class="grid grid-cols-2 gap-5">
<div class="flex flex-col gap-5">
<div class="card">
<div class="card-body">
<div class="card-title">{% lucide "building-2" size=16 %} {% trans "Club" %}</div>
<div class="flex flex-col gap-4">
{% form_field form.name %}
{% form_field form.legal_name %}
{% form_field form.contact_email %}
</div>
</div>
</div> </div>
{% endfor %}
<div class="card"> <div class="card">
<div class="card-body">
<div class="card-title">{% lucide "image" size=16 %} {% trans "Logo" %}</div>
<div class="flex items-center gap-3.5">
{% if club.logo %}
<img class="h-14 w-14 shrink-0 rounded-lg border border-line bg-paper object-contain p-1" src="{{ club.logo.url }}" alt="">
{% else %}
<span class="flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border border-line bg-paper font-display text-sm font-extrabold text-muted">{{ club.initials }}</span>
{% endif %}
<div class="flex-1">{% form_field form.logo %}</div>
</div>
</div>
</div>
</div>
<div class="card self-start">
<div class="card-body"> <div class="card-body">
<div class="card-title">{% lucide "palette" size=16 %} {% trans "Colours" %}</div> <div class="card-title">{% lucide "building-2" size=16 %} {% trans "Club" %}</div>
<div class="grid grid-cols-2 gap-4"> <div class="flex flex-col gap-4">
{% form_field form.primary_color %} {% form_field form.name %}
{% form_field form.secondary_color %} {% form_field form.legal_name %}
{% form_field form.contact_email %}
</div> </div>
<div class="mt-1 flex items-center gap-2"> </div>
<span class="h-7 w-7 rounded-md border border-line" style="background: {{ form.primary_color.value|default:'#fff' }}"></span> </div>
<span class="h-7 w-7 rounded-md border border-line" style="background: {{ form.secondary_color.value|default:'#fff' }}"></span>
<span class="font-mono text-xs text-muted">{% trans "Current colours" %}</span> <div class="card">
<div class="card-body">
<div class="flex items-center justify-between gap-2">
<div class="card-title">{% lucide "palette" size=16 %} {% trans "Colours" %}</div>
<span class="font-mono text-xs text-dim">{% trans "Accents only" %}</span>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
{% form_field form.primary_color %}
<div class="mt-1.5 flex items-center gap-2">
<input type="color" id="primary_color_swatch" class="club-color-swatch" value="{{ form.primary_color.value|default:"#0b1220" }}" aria-label="{% trans "Pick the primary colour" %}">
<span class="font-mono text-xs text-muted">{% trans "Sidebar background" %}</span>
</div>
</div>
<div>
{% form_field form.secondary_color %}
<div class="mt-1.5 flex items-center gap-2">
<input type="color" id="secondary_color_swatch" class="club-color-swatch" value="{{ form.secondary_color.value|default:"#e4002b" }}" aria-label="{% trans "Pick the secondary colour" %}">
<span class="font-mono text-xs text-muted">{% trans "Buttons & accents" %}</span>
</div>
</div>
</div>
<div class="mt-3 flex flex-col gap-1.5 border-t border-rule pt-3">
<div class="contrast-row flex items-center justify-between gap-2 text-xs">
<span class="text-muted">{% trans "Sidebar text on sidebar background" %}</span>
<span class="flex items-center gap-1 font-mono">
<span id="primary-contrast-ratio"></span>
<span class="contrast-icon"></span>
</span>
</div>
<div class="contrast-row flex items-center justify-between gap-2 text-xs">
<span class="text-muted">{% trans "Button text on accent colour" %}</span>
<span class="flex items-center gap-1 font-mono">
<span id="secondary-contrast-ratio"></span>
<span class="contrast-icon"></span>
</span>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="card-title">{% lucide "image" size=16 %} {% trans "Logo" %}</div>
<div class="flex items-center gap-3.5">
{% if club.logo %}
<img class="h-14 w-14 shrink-0 rounded-lg border border-line bg-paper object-contain p-1" src="{{ club.logo.url }}" alt="">
{% else %}
<span class="flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border border-line bg-paper font-display text-sm font-extrabold text-muted">{{ club.initials }}</span>
{% endif %}
<div class="flex-1">{% form_field form.logo %}</div>
</div>
</div>
</div>
</form>
<div class="xl:sticky xl:top-4 xl:self-start">
<div class="card">
<div class="card-body">
<div class="card-title">{% lucide "eye" size=16 %} {% trans "Live preview" %}</div>
<p class="text-xs text-muted">{% trans "Where these colours actually show up in the management app -- updates as you edit, before you save anything." %}</p>
<div class="preview-frame mt-2">
<div id="preview-sidebar" class="preview-sidebar">
<div class="preview-crest">{{ club.initials|default:"CL" }}</div>
<div class="preview-club-name">{{ club.name|default:_("Your club") }}</div>
<div class="preview-nav-item">{% trans "Overview" %}</div>
<div id="preview-nav-active" class="preview-nav-item preview-nav-active">{% trans "Members" %}</div>
<div class="preview-nav-item">{% trans "Calendar" %}</div>
</div>
<div class="preview-panel">
<div class="preview-panel-heading">{% trans "New member" %}</div>
<button type="button" id="preview-button" class="preview-button">{% trans "Save changes" %}</button>
<div class="preview-skeleton-line"></div>
<div class="preview-skeleton-line preview-skeleton-line-short"></div>
</div>
</div>
<div class="mt-3 text-xs text-dim">
{% trans "Never used for: status colours (paid/overdue, present/absent), table styling, or form fields -- those stay consistent for everyone regardless of club branding." %}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</form> </div>
{% endblock panel %} {% endblock panel %}
{% block extra_body %}
<script>
(() => {
// Mirrors club.models.Club._content_color_for exactly (WCAG relative
// luminance, 0.179 threshold) -- the ratio/checkmark shown here is the
// real number the server computes on save, not an approximation.
const relativeLuminance = (hex) => {
const channel = (value) => {
const fraction = value / 255;
return fraction <= 0.04045 ? fraction / 12.92 : ((fraction + 0.055) / 1.055) ** 2.4;
};
const r = channel(parseInt(hex.slice(1, 3), 16));
const g = channel(parseInt(hex.slice(3, 5), 16));
const b = channel(parseInt(hex.slice(5, 7), 16));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const contentColorFor = (hex) => (relativeLuminance(hex) > 0.179 ? "#000000" : "#ffffff");
const contrastRatio = (hexA, hexB) => {
const [lighter, darker] = [relativeLuminance(hexA), relativeLuminance(hexB)].sort((a, b) => b - a);
return (lighter + 0.05) / (darker + 0.05);
};
const isHex = (value) => /^#[0-9a-fA-F]{6}$/.test(value);
const primaryText = document.getElementById("id_primary_color");
const secondaryText = document.getElementById("id_secondary_color");
const primarySwatch = document.getElementById("primary_color_swatch");
const secondarySwatch = document.getElementById("secondary_color_swatch");
if (!primaryText || !secondaryText) return;
const setContrastRow = (elementId, hex, contentHex) => {
const ratio = contrastRatio(hex, contentHex);
const cell = document.getElementById(elementId);
cell.textContent = `${ratio.toFixed(1)}:1`;
const row = cell.closest(".contrast-row");
const icon = row.querySelector(".contrast-icon");
icon.textContent = ratio >= 4.5 ? "✓" : "⚠";
icon.classList.toggle("text-ok", ratio >= 4.5);
icon.classList.toggle("text-warn", ratio < 4.5);
};
const updatePreview = () => {
const primary = isHex(primaryText.value) ? primaryText.value : "#0b1220";
const secondary = isHex(secondaryText.value) ? secondaryText.value : "#e4002b";
const primaryContent = contentColorFor(primary);
const secondaryContent = contentColorFor(secondary);
document.getElementById("preview-sidebar").style.background = primary;
document.getElementById("preview-sidebar").style.color = primaryContent;
document.getElementById("preview-nav-active").style.background = secondary;
document.getElementById("preview-nav-active").style.color = secondaryContent;
document.getElementById("preview-button").style.background = secondary;
document.getElementById("preview-button").style.color = secondaryContent;
setContrastRow("primary-contrast-ratio", primary, primaryContent);
setContrastRow("secondary-contrast-ratio", secondary, secondaryContent);
if (primarySwatch && isHex(primary)) primarySwatch.value = primary;
if (secondarySwatch && isHex(secondary)) secondarySwatch.value = secondary;
};
primaryText.addEventListener("input", updatePreview);
secondaryText.addEventListener("input", updatePreview);
if (primarySwatch) {
primarySwatch.addEventListener("input", () => {
primaryText.value = primarySwatch.value;
updatePreview();
});
}
if (secondarySwatch) {
secondarySwatch.addEventListener("input", () => {
secondaryText.value = secondarySwatch.value;
updatePreview();
});
}
updatePreview();
})();
</script>
{% endblock extra_body %}

View File

@@ -4458,6 +4458,32 @@ class SponsorManagementTests(ManagementTestBase):
self.assertNotContains(self.club_get("home"), "Sponsors") self.assertNotContains(self.club_get("home"), "Sponsors")
class ClubSettingsPreviewTests(ManagementTestBase):
"""The D9-alike live preview on the Club identity page -- see
management/templates/management/club_settings.html."""
def setUp(self):
self.client.force_login(self.admin_user)
def test_the_preview_frame_and_colour_swatches_render(self):
response = self.club_get("club_settings")
self.assertContains(response, "preview-sidebar")
self.assertContains(response, 'id="primary_color_swatch"')
self.assertContains(response, 'id="secondary_color_swatch"')
def test_saving_still_updates_the_club(self):
response = self.club_post(
"club_settings",
{"name": "Ajax United", "legal_name": "", "contact_email": "", "primary_color": "#123456", "secondary_color": "#654321"},
)
self.assertRedirects(response, reverse("management:club_settings"))
self.club.refresh_from_db()
self.assertEqual(self.club.primary_color, "#123456")
self.assertEqual(self.club.secondary_color, "#654321")
class BillingEndingBannerTests(ManagementTestBase): class BillingEndingBannerTests(ManagementTestBase):
"""The club dashboard's "billing is about to stop" warning -- see """The club dashboard's "billing is about to stop" warning -- see
management.views.HomeView and management/templates/management/home.html. management.views.HomeView and management/templates/management/home.html.

View File

@@ -5173,6 +5173,16 @@
grid-template-columns: repeat(6, minmax(0, 1fr)); grid-template-columns: repeat(6, minmax(0, 1fr));
} }
} }
.xl\:sticky {
@media (width >= 80rem) {
position: sticky;
}
}
.xl\:top-4 {
@media (width >= 80rem) {
top: calc(var(--spacing) * 4);
}
}
.xl\:grid-cols-2 { .xl\:grid-cols-2 {
@media (width >= 80rem) { @media (width >= 80rem) {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -5188,6 +5198,11 @@
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
} }
} }
.xl\:grid-cols-\[1\.2fr_1fr\] {
@media (width >= 80rem) {
grid-template-columns: 1.2fr 1fr;
}
}
.xl\:grid-cols-\[1\.6fr_1fr\] { .xl\:grid-cols-\[1\.6fr_1fr\] {
@media (width >= 80rem) { @media (width >= 80rem) {
grid-template-columns: 1.6fr 1fr; grid-template-columns: 1.6fr 1fr;
@@ -5198,6 +5213,11 @@
grid-template-columns: 1.35fr 1fr; grid-template-columns: 1.35fr 1fr;
} }
} }
.xl\:self-start {
@media (width >= 80rem) {
align-self: flex-start;
}
}
.\[\&\:\:-webkit-details-marker\]\:hidden { .\[\&\:\:-webkit-details-marker\]\:hidden {
&::-webkit-details-marker { &::-webkit-details-marker {
display: none; display: none;

File diff suppressed because one or more lines are too long