This is the contract for building a page on DataTalks.Club. It covers the tokens, the page shell, the bands and the shared primitives. Where a rule is enforced by a test, the test is named — that test, not this document, is the thing that fails.
The system lives in three places:
templates/core/_design_system.html— the shared stylesheet partial: tokens, reset, masthead, bands, cards and every shared primitive.templates/core/_site_shell_head.html/_site_shell_foot.html— the shared shell markup: skip link, dark-mode bootstrap, impersonation banner, masthead and announcement; footer, theme toggle and script set.templates/core/content_page.html— the parent template ordinary pages extend.
Every public page ships one inline <style> element and zero
<link rel="stylesheet"> — enforced by
core/tests/test_homepage.py::test_homepage_carries_its_own_stylesheet_and_loads_no_legacy_css.
A shared external CSS file is therefore not an option. Each page includes the
partial inside its own style element:
<style>
{% include "core/_design_system.html" %}
/* page-specific rules follow */
</style>Page-specific rules live in the page template; shared primitives live in the partial. If two pages need the same rule, it belongs in the partial.
Page styles are emitted after the design system inside the same <style>
element, so an equal-specificity copy of a shared rule silently overrides it. A page
that restates a primitive to get a different value is drifting, not designing.
Template source must satisfy the readability contract in
content/tests/test_public_template_source.py: one opening tag per line, and Django
structural tags ({% include %} and friends) alone on their line.
Ordinary public course/platform pages extend templates/core/content_page.html.
The parent owns the document shell, the normal content width and the two surfaces:
- a cream
band-creamheader holding breadcrumbs, the title and header actions; - a lavender
band-lavendercontent surface holding the page body.
Children fill document_title, metadata, page_styles, page_header,
page_content and page_scripts, and preserve their view context, forms, metadata,
scripts and routes inside those blocks. They do not recreate the doctype,
masthead/footer or top-level bands, and do not create a competing normal width.
A genuinely wide table or diagram may use .shell-breakout inside the content
surface, with a page-specific reason.
The only top-level layout exceptions are the homepage (core/home.html) and the
authentication pages. Auth may use its own entrance width and composition, but
its content surface stays lavender. A registration page, dashboard, enrollment page,
leaderboard, homework or statistics page, module/unit page and project page are all
ordinary pages and use the shared parent.
Enforced by courses/tests/test_content_page_shell.py.
Every colour goes through a token. Tokens are defined on :root and mirrored on
body.dark-mode in the partial. If the colour you need is not here, add a token —
never an inline hex in a page. The only sanctioned literals are the fixed #ffffff
text on green surfaces and the few body.dark-mode counterparts (#14172a on bright
green, the #0d0f1c ink band), which exist because those pairings must not flip with
the theme.
A colour one page needs is added as a token in that page's own style block rather
than in the shared partial. A page that implements a designer comp names the comp's
one-off colours there — the catalogue's comp palette, the family landing's
--band-green family — as a page-local :root block mirrored in body.dark-mode
(a comp ground deepens in dark rather than flipping), so no rule carries a
light-only literal. The rule the page cannot escape is the one above: hexes live in
the token block, never inline in a rule, and every text pair that sits on a
page-local colour holds 4.5:1 in both themes.
| Token | Light | Dark | Role |
|---|---|---|---|
--cream |
#fdfaf3 |
#14172a |
page/band background, "paper" |
--lavender |
#eff1fc |
#191d33 |
content band, tinted panels |
--mint |
#eff5ea |
#15251d |
events band, "build" panels |
--card |
#ffffff |
#1e2338 |
card and control surfaces |
--ink |
#1e2136 |
#f2f1ec |
headings, borders, the dark band |
--body-text |
#4c5066 |
#c6cad9 |
body copy |
--muted |
#5c6076 |
#a4a9bd |
secondary copy, metadata |
--line |
#1e2136 |
#454b6b |
2px component borders |
--line-soft |
#d6d9e8 |
#333855 |
dashed rules, quiet borders |
--green |
#2b7a35 |
#4cb85a |
primary action, hub accents |
--green-bright |
#3ea94b |
#4cb85a |
live markers, progress fill |
--green-deep |
#41704e |
#8fd39c |
quiet green notes |
--indigo |
#5a62c4 |
#a6adf0 |
links, kickers, time marks |
--indigo-soft |
#c9cdf2 |
#3b4270 |
soft indigo fills, avatars |
--shadow |
rgba(30,33,54,.9) |
rgba(0,0,0,.7) |
hard CTA shadow |
--shadow-soft |
rgba(30,33,54,.16) |
rgba(0,0,0,.45) |
hard card shadow |
--focus |
#1e2136 |
#f2f1ec |
focus outline |
--page |
var(--cream) |
#14172a |
body background |
| Token | Light | Dark | Role |
|---|---|---|---|
--lavender-deep |
#dfe2f7 |
#2d3565 |
status pills, player stripes |
--sand |
#f3f0e8 |
#2a2740 |
waitlist/past pills, readonly fields |
--olive |
#6b6034 |
#cbb98a |
text on --sand |
--green-ship |
#1f6b2c |
#93c7a5 |
"you'll ship" lines, mint-pill text |
--bubble |
#b9bff0 |
same | brand bubble: play discs, number discs, CTA shadow |
--on-bubble |
#1e2136 |
same | glyphs and digits on --bubble |
--clay |
#e0847a |
same | YouTube platform dot |
--gold |
#e2c67a |
same | RSS platform dot |
--graph-edge |
#b9bfd8 |
same | drawn edges of a knowledge graph |
Most extended tokens are surfaces and marks that the dark grounds already carry, so
they do not restate themselves in dark. --green-ship does, because it is the one
extended token used as text. When a page first needs --sand or
--lavender-deep as a text pairing in dark, move that token and its text partner
together — never one of the pair alone.
The system has no danger colour and does not need one: an error reads as
--olive on the page ground and inside the --sand error summary (≈5.5:1 in both
themes). Colour is reinforcement; the words are the message.
The faces are system stacks with one self-hosted variable face. Do not add
@font-face rules and do not ship font binaries.
| Token | Stack | Used for |
|---|---|---|
--font-sans |
"Quicksand", ui-rounded, "Segoe UI", system-ui, … |
everything except mono metadata. Quicksand (variable, 300–700) is self-hosted at core/static/core/fonts/quicksand-latin-var.woff2 and loaded by the partial |
--font-mono |
ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, … |
machine-flavoured metadata: status pills, spec labels, timestamps, due dates, dates in list rows, the events date rail |
The original mockups were set in different faces, so rendered type is close to them but will never match exactly. That is not a bug, and the scale must not be tuned chasing a pixel match.
The scale (16px root; computed size at 1440px in brackets):
| Level | Rule | Notes |
|---|---|---|
| Page h1 | clamp(2.1rem, 4.9vw, 3.4rem) [54.4px] |
hero/index titles; letter-spacing: -0.03em |
| Featured h3 | clamp(1.6rem, 3.2vw, 2.35rem) [37.6px] |
featured-card course title |
| Band h2 | clamp(1.4rem, 3vw, 1.87rem) [29.9px] |
.band-head h2 |
| Card h3 | 1.05–1.15rem |
card titles |
| Stat number | 1.6rem |
.stat-tile strong |
| Body | 1rem / 1.6, weight 500 |
the base |
| Lede | clamp(0.95rem, 1.5vw, 1.05rem) |
hero/lede paragraphs |
| Prose h2 / h3 | 1.35rem / 1.1rem |
headings inside .prose |
| Prose h4 / h5 / h6 | 1.05rem / 0.9rem uppercase |
deeper source headings |
| Reading body | 1.05rem / 1.75 |
.prose-reading — a page read end to end |
| Prose lede | clamp(1.05rem, 2vw, 1.2rem) |
.prose-lede, the standfirst |
| Meta | 0.85–0.92rem |
sublines, notes, fineprint |
| Chip / kicker | 0.72rem |
uppercase, letter-spaced |
| Status pill / mono label | 0.66rem |
mono, uppercase, letter-spacing: 0.09–0.1em |
Headings are weight 800, letter-spacing: -0.015em, line-height 1.12 (tighter at
the biggest sizes). The prose rows apply only inside .prose. A body's own
headings stay below the band h2, so a section of prose never competes with the
heading of the band it sits in.
- Spacing scale:
0.35 / 0.5 / 0.6 / 0.75 / 0.85 / 1 / 1.25 / 1.5 / 2 / 2.5rem. Card gap0.6reminside,1.25rembetween cards (0.9rembetween narrow rows). - Band padding:
padding-block: 2.25rem, growing to2.9remat ≥62rem. - Shell:
.shellcentres content at--shell=76rem,padding-inline: 1rem(1.25remat ≥40rem). - Measure:
--measure=38rem, about 70 characters..prose-readingand.prose-ledeare held to it. Ordinary pages use--content-width=56rem(the course platform's.app-content-shellwidth) through.content-shellrather than choosing a local max width; prose inside the column stays at the measure. - Radius:
0.45remstatus pills ·0.5–0.7remsmall controls and CTAs ·0.9remcards, modules, player frame ·1.1reminset panels ·1.5remthe featured slab ·999pxpills, discs, avatars. - Borders:
2px solid var(--line)on components;1.5pxon chips and status pills;3px solid var(--ink)only on the featured slab;2px dashed var(--line-soft)for band and masthead separators. - Shadows are hard offsets, never blurs: cards
0.3rem 0.3rem 0 var(--shadow-soft); CTAs0.3rem 0.3rem 0 var(--shadow); the featured slab0.45rem 0.45rem 0 var(--shadow-soft); the ink/bubble CTA pair0.25rem 0.25rem 0 var(--bubble); the signup pill0 3px 0 var(--shadow).
The system never marks a block with a single-side rail — no border-inline-start, no
border-left, no shorthand equivalent, however quiet the colour.
A block that has to stand apart takes a surface and a full border
(background: var(--card) with 2px solid var(--line-soft), the tint carrying the
tone — --sand for attention, a dashed border for the quietest notice) or the
system's dashed rule above it.
A border that separates siblings (the dashed rule between spec cells), a table cell rule, an input outline, or a shape drawn out of borders is not an accent rail and is allowed. The difference is whether the border marks one block or divides two.
Enforced by core/tests/test_design_accent_borders.py, which fails on any new
border-left / border-inline-start that is not a removal or a reviewed structural
row in that test's table.
A page is a stack of full-width section.band elements, each with a .shell inside.
| Class | Background | When |
|---|---|---|
band-cream |
--cream |
the hero — the first band on a page, and only that |
band-lavender |
--lavender |
the content ground — every band after the hero |
band-mint |
--mint |
an events section inside a page about several things |
band-ink |
--ink |
the closing call-to-action band only |
Every band ends with the dashed separator (border-bottom: 2px dashed var(--line-soft)); band-ink has none because it closes the page. Do not invent
new band colours.
The hero keeps cream. Everything after it is lavender.
- The first band is the hero: masthead-adjacent, breadcrumb,
h1, byline. It takes the warm paper. Recolouring the ground directly under the shared masthead makes a page look like it belongs to a different site. - Every band after the hero is lavender. A reader moving from
/eventsto/blogto a person profile should meet the same ground under the same kind of content. - The warm band marks where the page starts; it is not the page. Everything below the seam is content, and content is cool. Above it: the masthead, breadcrumb, title block and dateline, plus the actions and facts belonging to the title block (an event's links, a course's spec strip, an index's filter pills). A page whose heading block is shaped differently puts the seam somewhere else, not nowhere.
- Consecutive lavender bands are expected. What separates two sections is the
heading and the space, not the colour — give every band after the hero a real
.band-head.
Enforced by content/tests/test_band_grounds.py, which reads the band sequence out
of every public page template and pins where each page's body sits.
--page follows the cool ground. --page draws the strip below the last band.
A page whose last band is lavender sets, in its own style block:
:root {
--page: var(--lavender);
}or that strip snaps back to cream between the lavender it continues and the cream
footer. The dark theme keeps its own --page from the partial, so the override names
the light token only.
Mint marks an events section inside a page that is about several things — it
says "this part is events" only where the rest of the page is not. On a page that is
itself about an event, or about one kind of work among four, mint says nothing the
heading did not and breaks the content ground. --mint remains correct as a panel
(.panel-mint) and a pill (.status-pill-mint) on any ground.
The homepage is the exception. templates/core/home.html alternates cream,
lavender, mint and ink deliberately — it is a magazine cover, not a content page, and
the alternation is the drawing. test_the_homepage_keeps_its_own_alternation holds
the cream hero, the closing ink band and the fact that it uses more than two grounds.
A page that implements a designer comp may close on the comp's own band. The
family landing's mock closes on a full-bleed green band, and the comp palette is
available to such a closing through page-local tokens — but the band system has
no green ground, so the course family landing closes on the sanctioned mint
panel on the lavender ground instead: the action line at display size, the
strongest honest actions beside it, the page's last word before the shell takes
over. The catalogue draws the comp green on controls only. A comp band never
recolours the hero or the shared content ground, and a page with no comp behind
it does not get one. An interior beat of the same comp follows the same rule:
the family landing sets its mock's graduate-stories chapter on the comp's
full-bleed ink ground — the mock draws that chapter on ink — from the page-local
--band-ink tokens through the .shell-breakout mechanism, with the section's
own card tokens re-declared so no text pair sits on a colour that flips with the
theme.
Outside the rule for now: the course platform's task surfaces (homework,
submissions, statistics, the leaderboard family, enrolment, registration,
wrapped) and Studio. The dashboard no longer belongs on this list: it read as a
landing page at the full 76rem shell, so it now sits at the same narrow
.content-shell default as its sibling task surfaces instead of overriding it.
Contrast when a band changes ground. --cream, --lavender and --mint all
flip with the theme, so a band that reads in light must be checked in dark. Muted
text clears AA on every ground in both themes, but check any muted text you move onto
a new ground rather than assuming.
Markup shapes below are the contract; the CSS lives in the partial. Reach for this inventory before writing CSS. If a primitive is missing, add it to the partial and to this document — do not fork it locally.
-
.shell— the centred content column; direct child of every band. -
.band-head— a band's heading block: adivholdingh2plus an optional sublinep, and a right-hand action (.band-linkor.kicker). Stacked on narrow screens; from 69rem the action sits on the heading's baseline row..band-head-secondaryadds top margin for a second head inside one band.<div class="band-head"> <div> <h2 id="…">Heading</h2> <p>Subline.</p> </div> <a class="band-link" href="…">action →</a> </div>
The action above sits beside the heading when it points to another index the band is teasing (a course catalogue, the full events list) — most of the site's bands, reviewed and shipped this way. A page that reads as one continuous walkthrough rather than a set of independent index teasers instead places that action under the section's own content, immediately after its last block, as a plain
.band-link— it then reads as the section's own last word rather than a second thing competing with the heading for the reader's eye first./tour(templates/core/tour.html) follows this second pattern throughout, via the page-local.tour-content-linkspacing hook; it is that page's own convention for now, not a retroactive rule for the bands shown above. -
.kicker— uppercase indigo eyebrow. The mono variant is.mono-label.mono-label-indigo. -
.band-link— underlined indigo action link. -
Eyebrow rule — an eyebrow is optional context, never a second title. Do not use one to repeat the page title, course/cohort name, year, or anything the breadcrumb or heading already supplies. The homepage and the sign-in/sign-up entrances are the only page-level exceptions; a
.mono-labelinside a card or spec row is metadata, not a page eyebrow. -
.breadcrumbs— the trail on a detail page. Links take the.band-linktreatment at the meta size, the current page is--muted, and the/separator is CSS-drawn with empty alternative text (content: "/" / "") so it is never announced. Links keep the2.75remminimum target height. Markup contract: anavwith an accessible name holding anol, oneliper level, every ancestor a link, and the current page onli[aria-current="page"]as plain text — never a link.Build the trail with the shared
{% breadcrumbs %}tag rather than hand-writing list items; an empty level is skipped.Use it on pages below an index. Index pages and the homepage do not carry one — the masthead already marks them with
aria-current="page". A page offers one way back, not two: a page with a back-link does not also carry a trail, and a page whose parent index does not exist carries neither.
-
.card— white slab, 2px ink border,0.9remradius, hard shadow, column flex withgap: 0.6rem.margin-top: autoon a child pins it to the card foot. -
.card-grid+.card-grid-2/.card-grid-3— the band's card columns; they collapse to one column on narrow screens. -
.interactive-card.interactive-lift— the whole-card link treatment. Hover and keyboard focus lift the surface 2px and reveal a fixed bottom-right→by opacity only. The arrow never travels or bounces; reduced-motion removes the lift while preserving the keyboard cue. -
.event-card— the card for one event. The whole card is the link: the title is the one real anchor and its::aftercovers the card, so the accessibility tree holds exactly one link per event. Anything else interactive inside sits above that overlay withposition: relative; z-index: 1. -
Stage card (
.stage-card, numbered.step-numberbadge) — one numbered stage on a plain.card: badge, illustration, heading, short description. It is a real shared include,templates/core/_stage_card.html, not just shared CSS — the homepage's "climb" section and a course family landing's "journey" section both render it, so the two cards cannot drift apart the way their hand-copied badge sizes once did.{% include "core/_stage_card.html" with step_index=1 heading="…" description="…" illustration_template="core/_home_illustration.html" illustration_variant="stuck" %}step_index(1/2/3) picks the badge digit and its.step-number-2/.step-number-3fill.illustration_templateis the artwork include rendered inside, withillustration_variant/illustration_loadingpassed through to it.kickeris an optional label paragraph (the family page's only).on_railswitches the shape: unset draws the homepage's plain grid-flow card (illustration first, the badge grouped with the heading);on_rail=Truedraws the badge astride the card's top-left corner for a page that already draws its own dashed connecting rail behind the row (.journey-grid::beforestays page-specific, along with the grid itself). -
.chip— round uppercase mini-pill. Variants:.chip-plain,.chip-green,.chip-live,.chip-ink. -
.avatar— striped round placeholder disc,2.6rem. -
.person-chip— one credited person: a small portrait and their linked name. Every surface that names a person draws this.{% include "public/_person_chip.html" with person=speaker|person_chip %}The chip is an inline-flex atom, so it wraps like a word.
.person-chipsis the wrapping row;.person-chips-labelsets the optional "By" / "With".There is one face size for the whole site (
1.6rem): a chip is a credit beside a title, not a portrait shot. The one exception is.person-chip-lead(3rem), for a block whose subject is that person.On a card, pair
.person-chipswith.card-creditso the credit is the card's last line and pins to the floor of a fixed-height column.Three facts can each be missing and each degrades on its own: no portrait keeps
.avatar.avatar-striped(aria-hidden); no profile keeps the name as a plainspan.person-chip-name, not a link to nowhere; the portrait always carriesalt=""because the name beside it is already the credit and a screen reader must not hear it twice. Give the picture its intrinsic square so the line cannot shift.
.cta— the block action: 2px ink border,0.7remradius, hard shadow,min-height: 3.25rem. Variants:.cta-primary(green, white text),.cta-secondary(card surface, ink text),.cta-subtle(transparent),.cta-ink(ink fill, cream text, bubble shadow),.cta-lift(bubble shadow on any CTA),.cta-compact(row-sized:min-height: 2.75rem)..pill-button— round bordered control (theme toggle, subscribe links) with an optional.dotswatch:.dot-green,.dot-bubble,.dot-clay,.dot-gold..signup-button— the masthead's green sign-up pill.
-
Status pills — mono uppercase state markers, soft-rectangle (
0.45remradius), deliberately distinct from the round.chip:<span class="status-pill status-pill-live"> <span class="status-dot status-dot-pulse" aria-hidden="true"></span> active </span>
Variant Surface Use (bare) cream, soft border quiet facts: "enrolled · week 9 of 10" status-pill-live--green, white texta running cohort, a live event status-pill-open--lavender-deep, ink textregistration open, next cohort dates status-pill-wait--sand, olive textwaitlist, finished courses, past events status-pill-mint--mint,--green-shiptextpodcast kind, season/episode The words carry the state; colour is reinforcement only.
.status-dotis the small green disc;.status-dot-pulseadds the pulse, whichprefers-reduced-motiondisables. -
.mono-label— uppercase mono eyebrow; variants.mono-label-green,.mono-label-indigo. -
.mono-note— small mono note (date ranges, submission counts). -
.mono-code— an inline literal inside running copy: a Slack channel, a command, a file name. Mono face at0.92em, no surface of its own. Put it on thecodeelement the content already deserves. It is a typographic mark, not a state marker (.status-pill) and not a metadata line (.mono-note). -
.ship-line— italic bold--green-ship; sits directly under a course title everywhere a course is sold. -
.stat-tiles/.stat-tile— lavender tiles with ink border and hard shadow, in a 2×2 grid: a number asstrong, a label asspan..stat-tiles-rowis the one-row modifier for a strip of 2-4 live facts (the course family landing's outcome numbers, the courses index's own facts strip): shorter tiles, and the tile count itself decides the column count — a lone odd tile spans the full row below 40rem, and a fourth tile opens a 4-column row at 40rem instead of wrapping onto its own row. The two pages share this one component rather than each inventing a grid. -
Progress bar — white track, 2px ink border,
--green-brightfill. Always pair it with a.mono-notestating the numbers, and give it the full progressbar ARIA:<div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="9" aria-valuenow="7" aria-label="Homeworks submitted"> <div class="progress-fill" style="width: 78%"></div> </div>
One row of toggles where exactly one is active:
<div class="filter-pills" role="group" aria-label="Filter courses">
<button class="filter-pill" type="button" aria-pressed="true">All courses</button>
<button class="filter-pill" type="button" aria-pressed="false">Active</button>
</div>The active pill ([aria-pressed="true"], .is-active, or [aria-current="page"]
when the pills are links) fills ink with cream text. State must be expressed
through the ARIA attribute, not the class alone. A label wider than the viewport
wraps inside the pill and the pill grows, rather than pushing the page sideways.
-
Spec rows — label-over-value facts as a dashed-bounded strip:
<dl class="spec-strip"> <div class="spec"> <dt class="mono-label">starts</dt> <dd><strong>Mon, Sep 14, 2026</strong></dd> </div> </dl>
-
List rows — the dashed-divided row pattern for index surfaces:
<div class="row-list"> <div class="list-row" style="--row-cols: 1.6fr 1.4fr 1fr 0.8fr">…</div> </div>
Rows stack on narrow screens; from 48rem each row lays out to
--row-cols(defaultminmax(0,1fr) auto). The row owns the dashed bottom border — never draw separators inside cells. -
Archive row — one row of a dated index: the date on a fixed rail, the record beside it as a card. It is a partial, not a pattern to copy:
{% include "public/_archive_row.html" with row_title=record.title row_url=record.public_path row_date=record.published row_summary=record.description row_credits=record.author_profiles only %}Optional slots:
row_date(absent drops the rail),row_mark="play",row_eyebrow,row_pill/row_pill_variant/row_pill_extra,row_summary,row_credits,row_class/row_hook. Always passonly.Inside the card the order is fixed: leading mark, eyebrow, pills,
h3title, clamped summary, credited people last as.person-chips.card-credit. The rail is.date-rail, day above year.Drawn by
/blog,/books,/podcastand every contribution band of a person's profile. Not by the events index, whose rail carries three facts including a start clock — that is.whenbelow, and its card is the shared.event-card. -
Date rail (events index) — set the way
.date-railis set: position and type, no rule.<div class="when"> <strong>Aug 17, 2026</strong> <span class="when-day">Monday</span> <span class="when-time">14:00 CEST</span> </div>
-
Folded rows (
.row-fold) — a list long enough to be a wall keeps its first rows in view and puts the rest behind one<details>that names what it holds:<details class="row-fold" id="person-events-more"> <summary class="row-fold-summary"> <span class="row-fold-open">Show 44 more events</span> <span class="row-fold-close">Show fewer events</span> <span class="row-fold-marker" aria-hidden="true"></span> </summary> </details>
It sits inside the
.row-list, so folded rows keep the dashed division.<details>does the work: keyboard behaviour and announced expanded state come free and it opens with JavaScript off. A bare "Show more" makes the reader click to find out how much they are in for. -
Timestamp rows —
.timestamp-rowwith a.timestamp-markand ap. The same shape carries transcript rows with the speaker name as the mark.
.panel+.panel-mint/.panel-lavender(+.panel-outlinedfor the 2px ink border) — tinted inset blocks..play-disc— bubble-lavender disc with an ink glyph (2.4rem;.play-disc-large3.5rem;.play-disc-cardthe white disc inside a player). Use.play-disc-cardonly where the surface behind it is known — a white disc disappears on a light thumbnail..player-frameis the striped 16:9 slab..catalog-scroller— a scroll-snap column grid that hides its scrollbar and stays a carousel at every width. Pair with.scroller-controls/.scroller-button(hidden without JavaScript viahtml.js); keeptabindex="0",role="group"and anaria-labelon the strip..doodle— a decorative drawing:display: block; width: 100%. Pages constrain aspect ratio per slot. Alwaysalt=""..module/.module-list— the disclosure accordion for a surface with genuine per-module content. No page uses it today; a page whose job is to show every deadline and state draws.row-listtables instead of folding them behind a click.
<div class="field">
<label class="field-label" for="reg-email">Email</label>
<input class="field-input" id="reg-email" type="email" readonly value="…">
<p class="field-hint">Using your account email.</p>
</div>.field-input styles input, select and textarea alike; readonly and disabled
fields sit on --sand.
A disabled field drops to a --line-soft edge; a readonly field does not. WCAG
1.4.11 exempts an inactive component from the 3:1 non-text floor, and readonly is
still focusable and still in the tab order, so it keeps the full --line border.
Softening the readonly edge too would be an accessibility regression wearing a design
fix's clothes.
Mutation forms use the shared primitives so homework, project submission and peer-review forms keep one visual system even when their fields differ:
.cmp-form— the shared 46rem measure and vertical rhythm, and nothing else. The form is not a box: it already sits on the lavender content band, and a second lavender panel with its own border only frames the page again at 1.25:1 against its own identical ground. The measure and the field rhythm do the grouping..cmp-form-fields— the field group rhythm..cmp-form-actions— the dashed action rule, spacing and responsive full-width button layout. Use.cta-primary,.cta-secondaryor.cta-subtlefor intent.include/learning_in_public_links.html— the one learning-in-public field primitive. Its cap is carried indata-learning-in-public-cap..form-checkwith.form-check-input/.form-check-label— one option row. The row keeps the 2.75rem (44px) pointer target; the control itself is 1.25rem (20px) in every theme and on every page.
A form that can be wrong draws the shared accessible form marks, rendered by
core/templates/accessibility/:
.a11y-error-summary— the form-level summary (role="alert",tabindex="-1",data-focus-error-summary), one list item per error linking to the field it names..a11y-required— the*beside a required label. Always paired with a.sr-only"(required)" and a fineprint line saying what the mark means; the requirement is stated in words, never carried by the glyph alone..a11y-help/.a11y-error— hint and error under a field, linked byaria-describedby/aria-errormessagethrough theaccessible_widgettag. The error opens with a.sr-only"Error:".
The site owns one registration form, on the registration campaign page
(courses/templates/courses/register.html), with its own POST target, duplicate
detection, country and role validation and error summary. A page that wants a
registration links to it rather than growing a second copy of a mutation surface.
A page whose text arrives as typed blocks renders them inside .prose:
<div class="prose">
<h2 id="purpose-of-aa-tests">Purpose of A/A Tests</h2>
<p class="prose-paragraph">…</p>
<p class="prose-item">…</p>
</div>Rhythm comes from .prose > * + *. .prose-paragraph keeps the source's own line
breaks (white-space: pre-line), so its opening tag and value stay on one line. A
list item that arrives as its own block is .prose-item (display: list-item)
instead of the page inventing a <ul> the source never marked. Headings keep the
identifier the source gives them, which is what in-page anchors land on, and get
scroll-margin-top so an anchored heading does not land against the viewport top.
Modifiers:
.prose-reading— the modifier a page adds when its body is the page: the reading measure at1.05rem/1.75, withoverflow-wrapso a long URL breaks instead of widening the column. Without it.prosekeeps the body size of the page around it. Pair with.prose-ledefor the standfirst..prose-scroll— the frame a wide table sits in. It is a named region with its own focus stop (role="region"+tabindex="0"+ anaria-label), because a scrollable box no keyboard can reach is a barrier. Two frames in one document may not share a name..prose-embed— one already-sanitized fragment from a source that wrote its own block markup, keeping the rhythm inside it..prose-unavailable— a dashed plate naming an element this site cannot draw..prose-caption/figcaption—--mutedat the meta size.
A richer body also carries h4–h6 continuing the ladder, real ul/ol/li,
--ink strong, an italic blockquote on the card surface inside a soft 2px border,
inline code, a pre block that wraps rather than scrolling (a box that scrolls
sideways with no focus stop of its own cannot be reached from a keyboard), img
bounded to the column with its own width/height so the box is reserved, a dashed
hr, and table cells on dashed rules.
Turning a projected body into this markup is content.article_content.prose_sections
plus templates/public/_prose_blocks.html. A block that carries a source segment is
rendered through Markdown and the shared sanitizer
(content.services.sanitize_rendered_html). No page writes unsanitized external
HTML.
<form class="search-row" action="…" method="get">
<label class="sr-only" for="wiki-query">Search the Wiki</label>
<input id="wiki-query" class="field-input" name="q" type="search" required>
<button class="cta cta-compact cta-primary" type="submit">Search</button>
</form>The field grows and the button keeps its size; both wrap on a narrow screen.
/wiki/graph and the homepage wiki band draw the wiki the same way: a hub, a ring of
its real neighbours, and the edges between them, as one SVG per width with a
viewBox, so the drawing scales as a unit, a label cannot escape the node it names,
and nothing overflows a phone.
.graph-svgwith.graph-svg-wide/.graph-svg-narrow— the two arrangements of the same drawing. Both are in the markup and one isdisplay: noneat a time, which keeps the hidden one out of the tab order. Each is capped near its design size (44rem / 24rem) so labels stay label-sized..graph-svg-node(.graph-svg-shape+text) — one node, drawn as an SVG link so it is keyboard reachable;.graph-svg-hubis the green centre. The focus ring is a thickened--focusstroke, for engines that draw no outline on an SVG link..graph-svg-edge— a drawn relation, styled from the partial and never from an attribute in the page..graph-node/.graph-cloud— one node outside the drawing, and a wrapping row of them for a set too large to draw..graph-index/.graph-index-summary— the<details>folding a complete node-type list away..graph-legendwith.legend-key/.legend-swatch/.legend-hub/.legend-page/.legend-swatch-line..topic-list/.topic— the bordered topic cards beside the drawing.
The geometry is a layout constant in core/graph_layout.py, shared by both pages.
Which node sits where is read from the graph's own edges, and a hub or spoke the data
cannot supply raises rather than rendering an invented edge. A label too long for its
node wraps — the node grows taller, never wider than its frame's bound.
core/tests/test_graph_layout.py holds the result: no drawn node overlaps another or
leaves its frame.
The masthead (.masthead, .brand, .site-navigation, .masthead-actions,
.nav-link, .nav-auth, account menu) and the footer/analytics dialog are fully
styled by the partial, and their markup is shared too:
<body …>
{% include "core/_site_shell_head.html" %}
<main id="main-content" tabindex="-1">
…the page…
</main>
{% include "core/_site_shell_foot.html" %}
…the page's own scripts, if it has any…
</body>Do not copy that markup into a page. The first five pages in the system each
carried their own copy, and the copies drifted — one lost /slack from the
navigation, and one lost user_menu.js, so the account menu would not close on
Escape. core/tests/test_design_system_shell.py renders every page in the system and
compares their navigation entries and script sets to each other.
The navigation is nine entries — Events, Courses, Blog, Podcast, Wiki, Books,
Docs, FAQ, Slack. The current entry is set from primary_navigation_current
(core/context_processors.site_context), so a page never marks it by hand. The
navigation contract (toggle + #site-navigation-links, opened by
core/site_navigation.js, always visible without JavaScript) must not be altered per
page.
The brand lockup. The mark is one drawing: the bubble outline and the DTC inside
it are stroked paths in the same weight and round cap, because the illustrations this
masthead sits above are hand-lettered and a typeset DTC was the one printed-looking
thing in the mark. DataTalks.Club is a single weight throughout — it is one name,
not a light half and a bold half.
Sign up (and its closed state), sign in, sign out, the four password-reset steps, the
inactive-account notice and the provider outcomes are one page in several states.
Each is an ordinary document of this system — one inline <style>, no external CSS,
both shell partials, and the band-ground rule with :root { --page: var(--lavender); }
so the tail matches. content/tests/test_band_grounds.py reads them with the public
pages.
- The column is the reading measure (
.shell.shell-reading). A sign-up page is a single decision and does not want the full 76rem shell. - The shared block is a partial, not seven copies.
accounts/templates/account/_entrance_styles.htmlholds the family's own rules and each page includes it inside its own style element, straight aftercore/_design_system.html. Nothing in it is site-wide, so it does not belong in the partial every page loads. - The provider buttons are the primary path:
.cta.cta-secondaryat full width, one per row. Each carries a.provider-mark— a single monochrome path filled withcurrentColor, so the mark takes the control's own--inkand survives the theme without a hex or a second asset — and it isaria-hiddenbecause the button already says "Continue with GitHub". A provider with no mark keeps its words. .entrance-oris the seam between the two ways in: the dashed rule on both sides of a real word, so the choice is announced.- The email form is the alternative, in a
.cardbelow the divider, using the shared field and.a11y-*primitives. - Who may reach these pages is not a design decision. Redesigning a template changes no route, gate or rate limit.
| Query | What changes |
|---|---|
max-width: 47.99rem |
the narrow layout: reshape, not reflow |
min-width: 40rem |
shell padding grows; .card-grid-2 becomes two columns |
min-width: 48rem |
.list-row adopts its --row-cols columns |
min-width: 62rem |
band padding grows; .card-grid-3 becomes three columns; pages open their wide grids |
min-width: 69rem |
the full navigation link row replaces the Menu toggle |
Below 48rem a surface changes shape rather than merely squeezing. The homepage
hero drops its drawing and fineprint, the climb and events become dashed lists, and
the wiki graph swaps its landscape SVG ring for a portrait one. Make the same kind of
decision — the finished-course table stacks each row; the events row puts the date
rail above the card — instead of letting a grid wrap arbitrarily. Shared narrow
behaviour lives in the partial; put page reshaping in the page's own @media block
after the include.
The partial guarantees these, and every page must preserve them:
- Focus — a global
:focus-visibleoutline,3px solid var(--focus)withoutline-offset: 2px, visible on both themes.#main-contentsuppresses its own;.skip-linkbecomes visible on focus. Never remove an outline without an equal replacement. - Target sizes — interactive controls are at least
2.75rem(44px) tall. A control the design drew smaller still gets the floor; that deviation is deliberate. - Contrast — body and muted text pass 4.5:1 on every band in both themes.
--green-brightis a marker colour (progress fill, status dot), not a text surface: white on it is only 3:1, so a live pill takes--green. State is never colour-only — pills and dots always carry words. - Decorative artwork — every illustration has
alt=""; every decorative inline SVG and dot hasaria-hidden="true"andfocusable="false". - Motion —
prefers-reduced-motion: reducedisables all animation, transitions and smooth scrolling globally. - Structure — one
h1per page; bands aresectionelements labelled by their heading (aria-labelledby); the skip link precedes everything; disclosure widgets use nativedetails/summaryor a realbuttonwitharia-expanded.
Artwork lives in core/static/core/illustrations/ as trimmed transparent WebP.
- Only the outer page background is transparent. The drawing keeps its own white fills so it stays readable on the dark theme without a plate behind it.
- Files are trimmed to the ink, so natural sizes vary; the consuming slot boxes them
(
aspect-ratio+object-fit: contain). - Illustrations are decorative: always
alt="",decoding="async", andloading="lazy"below the fold. The surrounding copy carries the meaning. templates/core/_home_illustration.htmlis the worked example; give a new page its own variant map rather than overloading the homepage's.
How an illustration is produced and processed is _docs/design/illustration-assets.md.
- Extend
templates/core/content_page.htmlunless the page is the homepage or an auth entrance. If it is one of those, start fromtemplates/core/home.html's skeleton: doctype, theno-js/jsclass swap, meta/OG tags, one<style>whose first content is{% include "core/_design_system.html" %}, then page rules. - Include
core/_site_shell_head.htmlfirst in the body andcore/_site_shell_foot.htmlafter</main>. Do not copy any of it into the page, and do not setaria-currentby hand. Add the page toDESIGN_SYSTEM_TEMPLATESincore/tests/test_design_system_shell.py. - Compose the page as
section.bandelements with.shellinside; pick grounds from the band table; use.band-headfor every section heading. - Reach for the inventory before writing CSS. New page CSS is for layout that is genuinely page-specific. If a primitive is missing, add it to the partial and to this document — do not fork it locally.
- Colours only via tokens; mono only via
--font-mono; sizes from the type scale. - Design the narrow layout deliberately (reshape, not reflow) in the page's own
@media (max-width: 47.99rem)block. - Keep the template readable: one opening tag per line, structural template tags alone on their line.
- Verify: both themes, 1440×900 and 390×844 screenshots, a keyboard pass (skip link,
focus visible, menus operable), and the page-owning tests plus
uv run --frozen python scripts/ci.py lint,format-check, andtypecheck.