Every page is styled by hand-written stylesheets that the templates link
directly. There is no CSS build step, no preprocessor and no bundler, so the
files under static/ are what the browser receives. This guide is the contract
for that system: where a sheet lives, which copy wins, what belongs in a
variant’s palette, and the policies for load order, !important, breakpoints,
z-index and JavaScript hooks.
The tree holds 88 hand-written stylesheets (90 .css files minus the two
vendored minified bundles, static/common/date/css/vanilla-calendar.min.css and
static/common/core/css/flatpickr.min.css). A handful of them are linked by
templates/common/core/base.html and therefore load on every page; the rest are
linked by the pages that need them.
Shared sheets live at static/common/<app>/css/<sheet>.css. An association
override lives at static/<assoc>/<app>/css/<sheet>.css. build_static_dirs
in core/settings/common.py builds STATICFILES_DIRS with the association
directory first and static/common last, so Django’s static finders resolve the
association path before the shared one.
A file at the same logical path replaces the shared file. It does not extend
it and it does not merge with it: if static/kk/events/css/style.css exists, no
rule from static/common/events/css/style.css is loaded for kk.
Static directories do not inherit parent variants, even where templates do.
biocum, impuls and sf pass parent_variants=('date',) to
build_templates and therefore fall back to templates/date, but each calls
build_static_dirs('biocum' | 'impuls' | 'sf') with no parents. A missing file
under static/biocum/ falls through to static/common/, never to
static/date/.
The shipped variants are date, kk, biocum, pulterit, sf and impuls.
Across the six shipped variants, 21 association stylesheets currently sit at a
logical path that also exists under static/common/. Every one of those is a
shadow, and each one has to be justified by the rules in the section below on
the legitimate ways to vary an association.
A few older sheets predate the <app>/css/ layout:
static/common/members/style.css, static/common/prettyjson/prettyjson.css
and static/common/css/admin_translation_tabs.css. New sheets should use
<app>/css/<sheet>.css.
Because the base sheets load on every page of the association, a selector that
matches a bare HTML element reaches parts of the page it was not written for.
The header sheets are the trap: the header is <nav class="navbar ..."> in
templates/common/core/header.html, but pagination widgets are <nav> elements
too. django-tables2’s table pagination renders a <nav aria-label="Table
navigation"> on the archive documents and exam pages, the archive grid has
nav#gallery-pagination-fallback, and the publications list has
nav.publications-pagination. A rule written as
nav { ... } to style the header also painted a table pagination bar with the
header background, which on associations whose link colour matched their
background made the page numbers effectively invisible.
Rules to follow:
nav.navbar { ... }. The
same applies to rules that hide the chrome for a full page takeover, such as
nav.navbar, footer { display: none !important; } on the Lucia and April
pages..table-container
wrapper, which is what static/common/core/css/pagination.css does, so the
news, publications and gallery paginations keep their own styling.templates/common/core/base.html, which links it, so a new shell should
extend the shared shell rather than copy it.core/tests/test_static_css.py enforces the selector rules: it fails on any
unscoped nav selector under static/ (a nav type selector must be
qualified immediately by a class, id or attribute, because an ancestor alone
such as .content nav can still contain a pagination nav) and on a rule in
static/common/core/css/pagination.css that escapes django-tables2’s
.table-container wrapper. The shell itself is guarded separately:
date/tests.py renders core/base.html for every association and asserts that
the shared pagination sheet and the Font Awesome compatibility sheet are linked
(test_every_association_shell_loads_the_shared_pagination_css and
test_every_association_shell_loads_font_awesome_compatibility_css).
An association has three legitimate ways to differ visually from the shared look:
static/kk, static/sf and
static/biocum each had a copy of events/css/event-card.css that differed
from the shared one only by which palette tokens it named, so the shared sheet
now reads role tokens (--event-card-bg, --event-card-link and the rest) and
the three copies are gone.static/pulterit/events/css/event-card.css is
the surviving example, and its header comment says why: besides the card rules
it carries page chrome that belongs only on the pages linking it.static/common/core/css/base-core.css
and static/common/date/css/homepage-core.css are the two examples, linked
from templates/common/core/base.html and from each association’s homepage
template respectively. Because the shared core loads first and the association
sheet second, every declaration the association still writes keeps winning,
which is what makes the split order-safe.Copying a shared sheet into an association directory and editing a few lines is
what produced today’s drift: it freezes a private fork of the shared rules, so
later fixes to static/common/ never reach that association. Do not do it. A
guard test rejects a new shadow that is byte-identical to the shared file it
replaces. Three such copies existed when the rule landed
(static/biocum/archive/css/document.css, static/biocum/core/css/ckeditor.css
and static/biocum/core/css/error-view.css) and have been deleted, so the list
of accepted byte-identical shadows is now empty.
A second ratchet covers the near copy, which is the version of the mistake that
actually happens: a copy edited in two places, frozen, while the shared sheet
moves on. It normalises both sheets and measures how much of the shared sheet the
shadow still repeats. At three quarters or more the pair is a fork rather than a
delta, and it has to be either deleted, with the difference expressed as palette
tokens, or split with a shared core sheet. Four pairs are frozen as debt in
core/tests/css_footgun_baseline.json with a reason each; a new pair at that
similarity fails, and so does a frozen pair that drifts further. The worst case
is static/kk/events/css/tomtejakt.css at 0.99, which is already missing a rule
the shared sheet gained, so that page renders differently for kk.
Where a variation is only a colour or another token, do not fork the rule: put the value in the association’s palette (next section) and let the shared sheet consume the token.
When splitting a shadow into a shared core and an association delta, lift a
declaration only when it is identical in every association that carries it and is
declared once for its media query context, selector and property. A sheet that
declares the same key twice has a winner chosen by order, and lifting the later
one into a sheet that loads first hands the win to the earlier one left behind.
Leave anything inside @keyframes alone entirely: its steps look like selectors
but are not, so moving one re-parents it.
Each association’s palette is static/<assoc>/date/css/date-root.css, a
:root block of custom properties. templates/common/core/base.html links
date/css/date-root.css, which the static finders resolve to the association’s
copy, so that file is the association’s theming axis. Changing a palette value
re-themes every shared sheet that consumes it.
Naming convention for new tokens: kebab-case,
--<family>-<role>[-<state>], for example --pub-card-bg (defined in
static/common/publications/css/list.css). The existing camelCase palette
(--primaryColor, --linkColorLight, and the rest of date-root.css) predates
the convention and is not being renamed wholesale, so expect both styles to
coexist. New tokens are kebab-case regardless.
Build semantic tokens from palette tokens rather than repeating a literal, and give the palette token a fallback so the shared sheet still works for an association that does not define it:
--pub-card-bg: var(--pubCardBg, var(--secondaryColor));
The same pattern lets one association re-colour a shared sheet without forking
it: the shared rule reads a token with its old literal as the fallback, and only
that association defines the token. The members forms keep the shared green
for every association; static/common/members/style.css reads only
--form-consent-link (fallback blue), which DaTe defines to make the signup
terms link gold. The inline alumni links on the signup and update-verify pages
read --link-gold-on-light with --linkColorDark as the fallback. DaTe’s cookie banner uses --cookie-accept-bg and
--cookie-accept-fg from static/common/core/css/cookie-banner.css. Other
associations keep the green/blue fallbacks. DaTe also overrides Bootstrap’s blue defaults (focus glow,
.btn-primary, the active language item’s background) and sets accent-color in
static/date/core/css/base.css; Bootstrap loads after that sheet, so those
rules carry an html prefix to outrank it. The focus glows derive from
--linkColorLight with color-mix() instead of repeating its RGB value. The
active language item’s text stays white, because header.css pins
.dropdown-item colour with !important.
DaTe’s gold shine (a gradient clipped to the text with background-clip: text)
is opt-in per item. Editors tick title_shine on an event or news post, the
templates add the title-shine class, and static/date/core/css/base.css
styles it with the on-dark gradient. static/date/date/css/homepage.css swaps
in --gold-shine-title-on-light, because the homepage news column and event
cards are white: deep golds at rest, plus a bright middle band that is only on
screen while the hover sweep runs (with background-size: 250%, the resting and
hovered positions each show only an outer 40% of the gradient). Gold text on white uses the deep tokens (--gold-shine,
--link-gold-on-light), whose stops all keep at least 5:1 contrast; the homepage
keeps the bright --linkColorDark only for decorative borders. The homepage calendar marks a day holding a ticked event with calendar-eventday--shine: the deep --gold-shine gradient (so the white day number stays readable at every point of the sweep) and a sweep on hover or keyboard focus. The arsfest page and the event passcode page also honour title_shine; .ball-content .title-shine in base.css outranks the arsfest sheets’ .ball-content h1 colour. Other associations do not define .title-shine, and
TITLE_SHINE_ENABLED keeps the checkbox out of their admin.
A custom property consumed by a shared sheet must be defined either in every
association that loads that sheet or at the use site in a var() fallback. An
undefined var() with no fallback silently drops the declaration, so the rule
disappears with no console error and no test failure from the browser side; a
guard test catches the missing definition per association, and fails both when a
new gap appears and when a fixed gap is left in its frozen list.
Two classes of gap have been found and closed. --linkColorLight,
--linkColorLightHover, --warning-darker and --textColorMedium were not
declared for biocum although shared sheets consume them without a fallback
(static/common/archive/css/detailed.css, static/common/news/css/style.css,
static/common/events/css/form.css,
static/common/members/css/two-factor.css,
static/common/members/css/functionary.css); they are now defined in
static/biocum/date/css/date-root.css, aliased to biocum’s own link tokens
where the meaning matched. Separately, --silver, --lightSilver2 and
--darkSilver2 were declared nowhere after the shared sheet that held them was
removed, which silently dropped --mainArsfestColor, --mainArsfestTextColor
and --mainArsfestButtonHoverColor in static/date/events/css/arsfest.css and
static/pulterit/events/css/arsfest.css; those three are restored locally in
both sheets until they are consolidated onto the shared arsfest sheet.
One accepted entry remains in the guard’s frozen list:
--luciaBackgroundColor is referenced by static/common/lucia/css/style.css,
which only kk declares, because the lucia app is installed for kk alone.
The sheet is never linked on the other five associations, so the reference is
inert rather than broken, and it stays listed so the scanner does not have to
model which apps a variant installs.
templates/common/core/base.html emits assets in a fixed order:
core/css/base-core.css, core/css/base.css,
date/css/date-root.css, core/css/cookie-banner.css,
core/css/pagination.css, core/css/focus.css.{% block extra_head %}.core/css/header.css, then core/css/header-submenu.css.{% block extra_header_css %}.core/css/footer.css.{% block extra_css %}.core/css/base-core.css is the shared core of the per-association
core/css/base.css sheets and sits first on purpose: the association sheet loads
one line later, so wherever an association diverges its own declaration still
wins. The same shape is used for the homepage, where each association’s homepage
template links date/css/homepage-core.css immediately before its own
date/css/homepage.css.
The consequence that keeps coming back is step 1 versus step 2: because the
project base sheets load before Bootstrap, a project rule that ties on
specificity with a Bootstrap rule loses. That is why the tree contains
!important and over-qualified selectors such as html body { ... } and
div a { ... } in static/<assoc>/core/css/base.css. Treat this as a known,
deliberate constraint rather than a bug: reordering the two groups would change
rendering across all six associations at once, so it is not a local cleanup.
Page-level stylesheets belong in the extra_head or extra_header_css blocks.
A sheet that must win by order rather than by specificity goes in
{% block extra_css %}, which is the last stylesheet position in <head>, after
core/css/footer.css. A stylesheet that must beat a vendor sheet that loads
later has to be linked after that sheet. The vanilla-calendar bundle
(static/common/date/css/vanilla-calendar.min.css) is an example: the base
sheets and Bootstrap are already on the page before it is linked, so only a
later link can override its calendar rules.
!important policy!important is permitted only to:
!important;Each use should carry a one-line comment naming the reason, as the
keep img ratio correct notes next to width: auto !important do. Many of the
existing uses have no such comment, so treating that as the standard is
aspirational: the comment is the rule for new work, and the uncommented
existing uses are debt.
The tree has 338 !important declarations across 37 hand-written stylesheets
(comment text excluded); the largest concentrations are 41 in each of
static/biocum/events/css/arsfest.css, static/date/events/css/arsfest.css and
static/pulterit/events/css/arsfest.css.
A guard test ratchets the per-file count, so a file’s count can only go down:
adding a new !important to a file that is at its frozen count fails until one
is removed.
The intended scale is Bootstrap’s, because Bootstrap 5.1.3 is the framework the project is written against:
| Breakpoint | Mobile-first (min-width) |
Desktop-first (max-width) |
|---|---|---|
| 576px | min-width: 576px |
max-width: 575.98px |
| 768px | min-width: 768px |
max-width: 767.98px |
| 992px | min-width: 992px |
max-width: 991.98px |
| 1200px | min-width: 1200px |
max-width: 1199.98px |
| 1400px | min-width: 1400px |
max-width: 1399.98px |
Prefer mobile-first min-width: Npx, and use max-width: (N - 0.02)px when a
desktop-first rule is genuinely needed.
The tree does not follow this today. It contains 36 distinct media query
strings as written, 31 of them width based, including whitespace variants such
as (max-width : 992px) and (min-width : 992px), prefixed variants such as
only screen and (max-width: 900px), and near-duplicates such as 767px,
767.98px and 768px. A guard test normalises whitespace, so it freezes 29
keys and treats (max-width : 992px) as a repeat of (max-width: 992px).
Queries that are not about a viewport width are not breakpoints and are ignored
by that ratchet: @media print, (hover: hover), (pointer: coarse) and
(prefers-reduced-motion: reduce) are media types and feature tests, and
freezing them would flag a print stylesheet as a new breakpoint. Adding any new
width query is a reviewed change that regenerates the baseline, and the query
must use the documented scale; the existing oddities are debt rather than
precedent.
There is no scale today. The tree declares 15 distinct z-index values: 14
numbers from -150 up to 1000000, plus inherit. The mixing has visible
consequences:
#lightbox-dl-btn, #lightbox-share-btn,
#lightbox-share-feedback, #lightbox-counter) all sit at 1000000 in
static/common/archive/css/detailed.css, one above the 999999 that the
glightbox 3.3.1 bundle (loaded from jsDelivr by
templates/common/archive/partials/detail_scripts.html) uses for its own
container. The controls win only because the number happens to be larger, and
all four share one value, so their internal order is not expressible.1000 in static/common/core/css/cookie-banner.css,
below the Lucia snowflakes at 9999 in static/common/lucia/css/style.css.The intended scale for new work:
| Layer | Value |
|---|---|
| Background and decorative layers | negative |
| Local stacking inside one component | 1 to 10 |
| Sticky chrome (header nav, offcanvas) | 100 |
| Page-level banner (cookie banner) | 1000 |
| Modal, lightbox and its controls | 9000 and above |
Within the modal and lightbox band, give each layer its own slot rather than
sharing one value: for example 9000 for a modal overlay, 9100 for the
lightbox container, and 9200, 9210, 9220 (and so on) for individual
controls. Distinct slots let a later rule order controls against each other,
which one shared 1000000 cannot. A guard test freezes each file’s current
values and fails on any value that file did not already use, so converting a
file to the scale is a reviewed change that regenerates the baseline.
Styling classes must not be the contract with JavaScript. Renaming a class for styling reasons silently breaks the behaviour that reads it, with no error anywhere: neither the CSS nor the JavaScript complains.
Components should read data-* attributes. That is the established good pattern
in the archive gallery, where the server writes configuration onto
#gallery-config and static/common/archive/js/detail-gallery.js reads it
through galleryConfig.dataset.*, and in the event tab navigation, where each
tab carries data-nav="..." and the inline script in
templates/common/events/arsfest_base.html calls
showPanel(element.dataset.nav).
Where a class is unavoidably a hook because the markup comes from a third-party
package or a legacy template, prefix it js-. No js- prefixed hook exists in
the tree yet.
These components still hook onto styling classes or ids and are the conversion backlog:
static/common/archive/js/detail-gallery.js) reads
.grid-item, .gallery-image, .gclose, .gnext and .goverlay, alongside
the data-global-index attribute it already uses.static/common/publications/js/pdfViewer.js,
static/common/publications/js/main.js) reads #pdf-viewer and #pdf-shell,
and the publications list (static/common/publications/js/list.js) reads
.publication-thumb-canvas and .publication-card[data-pdf-url].static/common/uploads/js/upload-init.js) reads
.django-uppy-status and .django-uppy-remove next to its data-uppy-*
attributes.sf logo animation (static/sf/date/js/header-logo-animation.js) reads
.hero-text-box .albin.static/common/js/admin_translation_tabs.js)
reads .mt, the mt-field-<language> class prefix and
.translation-language-tabs.The most fragile of these is the sf logo animation. The guard near the top of
static/sf/date/js/header-logo-animation.js is
if (!svg || !paths.length) return;, so when the .hero-text-box .albin
selector misses (markup renamed, the block wrapped differently, the SVG split
into another partial) the whole animation silently does nothing. There is no
error, no warning and no test that notices. Convert this one to a data-* hook
before touching the sf header markup.
Two rules are cheap to get right and easy to break:
outline: none (or outline: 0) without a :focus-visible
replacement for the same element. Correct examples in the tree include
.publication-card:focus-visible in static/common/publications/css/list.css,
.grid-item:focus-visible and .pagination a:focus-visible in
static/common/archive/css/detailed.css, and
.translation-language-tabs button:focus-visible in
static/common/css/admin_translation_tabs.css..active class with
aria-pressed.No exceptions are documented. The hand-written stylesheets still contain 13
outline: none or outline: 0 declarations against 9 :focus-visible rules,
so several elements lose their focus ring with nothing put back. That is debt,
not a licence: a change that adds outline: none must add the replacement.
static/common/core/css/focus.css is the shared replacement for the case that
mattered most, .theme-button: it draws a ring from the palette accent for
:focus-visible only, so mouse users see no change, and the variant
static/<assoc>/core/css/base.css sheets no longer switch the ring off.
Appearance parity is proven with scripts/css_regression.py, which captures a
full-page screenshot and a computed-style snapshot per page and viewport, then
compares two captures:
export CSS_REGRESSION_USERNAME=admin
export CSS_REGRESSION_PASSWORD=admin
masks=(--mask '#clock-wrapper' --mask '.registration-timing' --mask 'img.balloon')
uv run python scripts/css_regression.py capture --label before \
--pages scripts/css_regression_pages.txt "${masks[@]}"
# make the change
uv run python scripts/css_regression.py capture --label after \
--pages scripts/css_regression_pages.txt "${masks[@]}"
uv run python scripts/css_regression.py compare --before ~/.cache/date-website/css-regression/before \
--after ~/.cache/date-website/css-regression/after
scripts/css_regression_pages.txt is the curated page list: the shared pages, each
association’s home page, the five seasonal event templates, the news article, the
archive, exam and member pages, polls, publications and the plain forms. It does not
reach every stylesheet this tree changes. publications/css/viewer.css loads on the
publication viewer, which the list has no route to, and the attendance pages
(attendance/css/attendance.css, attendance/css/report.css) are not in it either, so
a change confined to those needs its own capture or a new path, and no guard test
checks that the list covers every sheet. Without --pages the harness falls back to a
small seed list plus deterministic detail-page discovery, which does not reach the
seasonal sheets.
Some of those paths sit behind login_required. Supplied with credentials, the
harness signs in once per association, verifies the session against a page that
only resolves when signed in, and aborts the association rather than quietly
capturing the sign-in page as if it were the requested one. Credentials come from
--username and --password, or from the environment as above so a password
never reaches the process list. A manifest records, per association, whether the
capture was authenticated.
Three things on those pages change between two captures even when the change is
neutral, so a capture intended to reach a green verdict masks them: the wall
clock on /members/cert/ (#clock-wrapper), the relative sign-up timing printed
by every branch of the event sign-up block (.registration-timing, which is
“39 minuter sedan” once sign-up has closed, “om 3 dagar” before it opens, and a
running countdown while it is open), and the animated seasonal art
(img.balloon). --mask sets visibility: hidden on the matching elements
only, never display: none, so nothing below them moves, and masked elements
keep being compared, because the computed-style probe runs before the mask is applied:
the style channel sees the document as the page built it, masked elements and everything
inside them included, and only the masked pixels are ignored. A selector that matches
nothing is warned about, so a mask that quietly stopped working is visible.
A canvas that composites a blurred panel can still land a handful of pixels
differently between two runs even with Chrome’s deterministic rendering flags
enabled, so --tolerance 20 --max-percent 0.002 exists for that case. The gate above
passes no tolerance at all, and that is deliberate: the strict verdict is zero differing
pixels and zero computed-style changes, and a tolerance is something a reviewer adds to a
named run with a reason, not the default the next person inherits.
Captures land under ~/.cache/date-website/css-regression/<label>/ unless
--out names a directory. The harness covers the six shipped variants at a
1600x1000 desktop viewport and a 390x844 mobile one, which samples only the
ends of the breakpoint scale: 576, 768, 992 and 1200 are never exercised, so a rule that
only applies between two of them can change appearance invisibly. Add the widths a change
touches with --viewport NAME=WIDTHxHEIGHT (repeatable), for example
--viewport tablet=900x1000 for the 768 to 992 band. compare refuses captures whose
viewport sets differ, so both sides have to be captured with the same set.
compare exits 2 when the two captures resolved different page sets or
viewports, and exits 1 by default on any difference: --tolerance and
--max-percent default to 0, and a computed-style change fails unless
--allow-style-changes is passed. So the acceptance rule for a change claimed
to be appearance-neutral is that compare exits 0 with its defaults, meaning no
differing pixels and no computed-style change for any page or viewport. Any diff
is either a bug to fix or a change that has to be described as visual rather
than neutral.
One limit of the pixel channel is worth knowing before trusting a red run.
Adding or removing an element in <head> is reported but does not fail the gate:
no <link>, <meta>, <script> or <style> in the head is rendered on its
own, so it cannot shift layout. Any other element appearing or disappearing is
treated as a geometry change and does fail. Every page also warns when its final
URL differs from the requested one, so a redirect to the sign-in page shows up in
the report instead of being compared as the page that was asked for.
The gate is sound for what it samples and silent about the rest. Before treating a green run as proof, know the boundaries:
--viewport.common/core/css/focus.css) and any :hover rule are only visible after the
event that triggers them, and the seasonal panels that start hidden are compared in
their hidden state.@keyframes body, a duration or a delay is not visible to either channel. The style
probe records transition-property but not the animation properties.img.balloon would not be caught.note: line compare prints when the pair is more than five minutes apart.captureBeyondViewport after a
scroll to the top, not after a scroll sweep.STYLE_PROPERTIES), not every
computed property, and no pseudo-elements. core/tests/test_css_harness.py fails when a
stylesheet declares a property the list does not cover, so the list cannot silently fall
behind the sheets, but a property it deliberately omits stays invisible.A comparison is also refused when both captures came from the same working tree, because
that proves the harness is deterministic rather than proving a change was neutral. Pass
--same-tree-ok when a determinism check is what you mean. A view whose page answered
with a status other than 200, or with a different status than its counterpart, fails: a
404 or an error page photographed twice is not evidence.
The stylesheet guard tests are:
date-test core.tests.test_static_css core.tests.test_css_system
The ratchets in core/tests/test_css_system.py compare the tree against a
frozen baseline in core/tests/css_footgun_baseline.json. Regenerate it with
uv run python -m core.tests.test_css_system --write-baseline only for a
reviewed, intentional change (a breakpoint added to the documented scale, an
accepted !important, a rule removed), never to silence an accidental
regression.