Frontend¶
The frontend is server-rendered Django templates styled with Tailwind CSS and made interactive with HTMX. There is no JavaScript build step and no SPA framework: pages are HTML, and small fragments are swapped in over the wire. The only hand-written JavaScript is the dark-mode toggle.
Tailwind CSS v4¶
Styling uses Tailwind CSS v4 through the standalone binary, so there is
no Node toolchain to install. The dev setup script downloads the binary (or symlinks one already on
PATH) into the virtualenv
(scripts/dev-setup.sh),
then compiles the stylesheet:
# Watch and rebuild on change (DEBUG mode)
tailwindcss -i ./assets/css/input.css -o ./static/css/tailwind.min.css --watch
# One-off minified build (production / collectstatic)
tailwindcss -i ./assets/css/input.css -o ./static/css/tailwind.min.css --minify
The source stylesheet is
assets/css/input.css.
It uses Tailwind v4's CSS-first configuration:
@sourcedirectives tell Tailwind to scantemplates/**.htmlandstatic/js/**.jsfor the classes actually used.@themedefines the brand palette (the PyCon DE / PyData blues, greens, yellow, red, pink), typography, spacing, breakpoints, and easing as CSS custom properties.@layer componentsdefines reusable classes such as.btn-primary,.card,.badge-*,.alert-*, and the.app-body/.app-nav/.app-main/.app-footerlayout shell, each with light and dark variants.@layer utilitiesadds neutral text and border tones (.text-muted,.border-weak, ...) and the talk-state helpers (.card-bg-current,.ring-upcoming, the schedule-grid overrides).
The compiled output is served as a regular static file, linked from base.html.
HTMX¶
HTMX drives every dynamic interaction. The script is vendored under
static/js/htmx.min.js
and loaded in base.html, alongside idiomorph-ext.min.js for morph-style swaps. The django_htmx
app and its middleware are installed so views can detect HTMX requests.
The shared rule on the server side is simple: a request is from HTMX when it carries the
HX-Request: true header. The helper is_htmx_request() in
talks/utils.py
encapsulates that check, and views branch on it to return either a full page or just the fragment
that changed.
Common patterns¶
- Live dashboard updates. The home page (
home.html) has two containers that poll on a timer:hx-getthedashboard_statsandupcoming_talkspartials withhx-trigger="load, every 300s"andhx-swap="innerHTML". They render once on load and refresh every five minutes without a full reload. Theupcoming_talksview is additionallycache_page-d for 30 seconds and varies on the session cookie. - Search and filtering. The talk list (
talks/talk_list.html) wires the search box and the filter selects tohx-getthe sametalk_listURL into#talks-container, withhx-triggerdebouncing keystrokes (keyup changed delay:300ms) and reacting to select changes.hx-push-url="true"keeps the URL shareable,hx-includesubmits both forms together, and anhx-indicatorspinner shows during the request. The list container itself also polls (every 300s) so a long-open list stays current. Matched terms are wrapped with thehighlightfilter. - Ratings. The rating widget posts a score (and optional comment) and the view returns the re-rendered widget. It also uses out-of-band swaps to update the star summary shown next to the talk title elsewhere on the page, so a single response updates two places at once.
- Q&A voting. The vote button (
questions/vote_button.html) issueshx-posttoquestion_vote, passing the currentstatus_filterviahx-valsand the CSRF token viahx-headers, then swaps the whole#question-list(hx-swap="outerHTML") so the re-sorted, re-counted list comes back in one round trip. - Saving talks. The save button (
partials/save_button.html) posts totoggle_save_talkand replaces just its own#save-btn-{pk}container with the toggled state.
CSRF with HTMX
Interactive hx-post controls pass the CSRF token explicitly with
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}', since the request does not go through a normal
form submission.
Template layout¶
Templates live under
templates/, loaded with
Django's cached loader. The structure:
base.html- the shell every page extends: the<head>, the top navigation, the content block, the footer (built from thebrand_*context variables), and the dark-mode script.home.html,404.html,500.html- top-level pages.partials/- cross-app fragments (event_selector.html,alert_error.html,useful_link_card.html).- Per-app directories:
talks/(withtalks/partials/andtalks/questions/for HTMX fragments),users/,account/,socialaccount/, andadmin/overrides.
The HTMX fragments (the *_fragment.html, *_widget.html, and button partials) are deliberately
small and self-contained so a view can render exactly one of them in response to an interaction.
SVG icons¶
Icons are inlined from .svg files rather than referenced with <img src>, which lets them inherit
color and size from Tailwind classes. The files live in the top-level
svg/ directory, and the svg
template tag in
talks/templatetags/svg_tags.py
reads and injects them:
The tag reads svg/<name>.svg, optionally injects the given CSS classes onto the <svg> element,
and marks the result safe. Reads are cached with lru_cache, and the resolved path is checked to
stay inside the svg/ directory to guard against path traversal. The event logo is rendered the
same way, using the event's logo_svg_name.
Dark mode¶
Dark mode is class-based: the dark class on <html> flips every dark: Tailwind variant. The
toggle logic is inline in
base.html:
- A small script in
<head>runs before first paint, reading the saved preference fromlocalStorage(or the OSprefers-color-scheme) and adding thedarkclass immediately. This prevents a flash of the wrong theme. - The toggle button swaps a sun/moon icon, toggles the class, persists the choice to
localStorage, and adds a short transition class for a smooth fade. - When no explicit preference is stored, the page follows live OS theme changes.
The stylesheet declares the variant with @variant dark (&:where(.dark, .dark *)) and includes dark
overrides for elements that use inline Tailwind classes (the schedule grid, prose blocks, and form
inputs).
Template tags¶
The custom tags and filters live in
talks/templatetags/:
| Module | Provides |
|---|---|
svg_tags.py |
{% svg name css_class %} - inline an SVG icon (see above). |
rating_tags.py |
{% star_rating average count %} - render filled, half, and empty stars from the star SVG. |
saved_tags.py |
{{ talk.pk\|is_in:saved_talk_ids }} - set-membership filter for the saved-talk highlight. |
schedule_tags.py |
{% schedule_cell grid slot room_id as talk %} - look up a talk in the schedule grid. |
stat_tags.py |
{% stat_card title value %} - an inclusion tag rendering a dashboard stat card. |
highlight.py |
{{ value\|highlight:query }} - wrap matched search terms in <mark>. |
time_filters.py |
{{ seconds\|format_seconds }} - format a second count as H:MM:SS or MM:SS. |