The project supports multiple site variants selected by PROJECT_NAME. Each variant has its own settings file under core/settings/<variant>.py, which builds the Django TEMPLATES DIRS list with the shared helpers in core/settings/common.py:
# Variant with no inherited templates (date, kk, pulterit):
TEMPLATES = build_templates('date')
# Variant inheriting another variant's templates (biocum, sf, impuls):
TEMPLATES = build_templates('biocum', parent_variants=('date',))
build_templates(variant, parent_variants=()) puts the variant’s own
directory first, then the inherited parents, then the shared common
dirs, so Django finds association templates before falling back to
templates/common. Use the helpers when adding a variant; do not
construct TEMPLATES by hand. Static dirs are built the same way with
build_static_dirs(variant); note that static dirs do not inherit
parent variants.
Association templates extend the common equivalents using the same logical path:
templates/<association>/core/footer.html → {% extends "core/footer.html" %}
↓
templates/common/core/footer.html
Django resolves the path in {% extends %} by skipping the file currently being loaded (avoiding infinite recursion) and finding the next match — the common version.
This means:
templates/common/ defines the base structure and all available {% block %} slots.templates/<association>/ overrides only the blocks it needs; everything else falls through to the common template.templates/impuls, then templates/date, then templates/common so it can reuse the DaTe homepage layout while overriding only Impuls branding and copy.{% extends %} resolves to the parent variant’s override when one exists, so blocks DaTe fills are inherited too. templates/date/core/footer.html puts the DaTe-only AA partner badge in footer_right; templates/biocum/core/footer.html empties that block again, and Impuls replaces it. templates/sf/core/footer.html does not extend the common footer at all (it is a standalone footer with no footer_right block), so SF replaces the whole footer. Check biocum, impuls and sf when adding content to a templates/date/ override.If an association template needs to inject content into an area that has no block slot yet, the slot must be added to the common template — even as an empty block:
{# templates/common/core/some_template.html #}
{% block my_new_slot %}{% endblock %}
The association template then fills it:
{# templates/<association>/core/some_template.html #}
{% block my_new_slot %}
...association-specific content...
{% endblock %}
Adding an empty block to the common template is safe: associations that don’t override it render nothing, so existing variants are unaffected.
You cannot inject content into the middle of a parent template’s HTML from a child template — only into defined {% block %} slots. A block defined in a child template that has no corresponding slot in the parent is a no-op; the content is silently discarded.
A component that several associations render belongs in templates/common/, even
when each association already carries its own working copy. The copies are what
drift: the homepage event list existed as five copies, two of them byte
identical, and every difference any of them carried was a handful of lines.
Put the whole component in the shared template, with a block for each piece that really differs, and give an association that differs a short file that extends it:
templates/common/date/components/events.html the component, and a block per icon
templates/sf/date/components/events.html {% extends %} plus four block overrides
The association file then holds only its difference, so a change to the shared
structure reaches every association at once and a reviewer can see the whole
per-association delta in one screen. The same rule covers the seasonal event
pages: templates/common/events/arsfest_base.html is the shared page, and the
association files should be overrides of it rather than standalone copies.
Two things that are not blocks:
{% partialdef %} with {% partial %},
which Django 6 provides natively. The partial renders nothing where it is
defined unless it is declared inline.template.html#partial syntax, so one template can
serve both the full page and the fragment.Before adding a template under templates/<association>/, check whether the
same path already exists for another association. Two association copies of one
component are almost always one shared component plus two overrides, and
templates/ should not gain a third.
core/tests/test_template_duplication.py enforces that. It compares the copies of
every logical path across the six associations and the shared tree, and fails when
two copies of 15 lines or more are 70 percent alike, unless the pair is frozen in
core/tests/template_near_copy_baseline.json with a reason. A frozen pair that
gets closer also fails, and so does a baseline entry whose copies have since
diverged, so the frozen set stays honest. Regenerate it with
uv run python -m core.tests.test_template_duplication --write-baseline after a
deliberate exception, and add the reason to the REASONS mapping in the test.
Each association lists its footer buttons in CONTENT_VARIABLES["SOCIAL_BUTTONS"] as [icon, url] pairs; an entry with a blank URL is skipped. The shared footer (templates/common/core/footer.html, footer_social_buttons block) picks the icon for each entry:
core/svg/social/<icon>.svg resolves through the configured template search path, it is included inline. The social_icon_template tag in date/templatetags/social_icons.py asks Django’s template loader for that path, so the whole search path is used and not just the shared directory.fab <icon>, e.g. fa-facebook-f.So platforms Line Awesome covers keep using fa-* names, and a platform it lacks needs one SVG file to work for every association:
templates/common/core/svg/social/<name>.svg. Name it with lowercase letters, digits and hyphens only (anything else is ignored). Give the <svg> a viewBox, height="1em", fill="currentColor", focusable="false" and style="vertical-align: -0.125em" so it sizes, colors and aligns like the font icons beside it without any CSS, and keep the icon’s license comment.<svg> role="img" and an aria-label with the platform’s name spelled the way the brand writes it (aria-label="TikTok"). The button takes its accessible name from the icon, so this is the label screen readers announce; the file name can’t carry it because it has to stay lowercase.["<name>", "<url>"] to the association’s SOCIAL_BUTTONS.To give one association a different icon, put a file at the same path in its own template directory (templates/<association>/core/svg/social/<name>.svg); the search order finds it before the shared file. A variant that layers another variant’s templates (impuls and biocum layer date) or an app’s templates/ directory can supply or override an icon the same way.
Shared icons so far: tiktok, linktree (Font Awesome Free 7.3.1, CC BY 4.0). sf renders its own complete footer and does not use this lookup: templates/sf/core/footer.html has one branch per platform (instagram, facebook, x, linkedin, tiktok), so a new platform for sf needs a branch added there. Its tiktok branch includes the shared tiktok.svg.
An association’s stylesheets are loaded on every page of that association, and a selector that matches a bare HTML element therefore reaches parts of the page it was not written for. Two rules are enforced:
nav.navbar { ... }. core/tests/test_static_css.py 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.static/common/core/css/pagination.css from every site shell and keep its rules under django-tables2’s .table-container wrapper, so the news, publications and gallery paginations keep their own styling. The guard test fails on a templates/**/core/base.html that neither links the sheet nor extends the common shell, and date/tests.py renders every association’s shell and asserts the link is present.The rest of the contract, including why element selectors are the trap, the palette tokens, load order, !important, breakpoints, z-index and JavaScript hooks, is in Shared CSS & Association Overrides.