brickwork design tokens: the complete reference
Status: canonical token reference for brickwork 0.11.0 (ADR-054 Phase a,
tier re-grammar per ADR-054 section 7).
Authority: ADR-054 in the umbrella
(oss/docs/adrs/ADR-054-brickwork-token-system-and-theme-layers.md) governs
the decisions; the umbrella spec (oss/docs/specs/django-brickwork/) governs
the contract rules; this file is the per-token reference (names, default
values, derivation rules). Where this file and ADR-054 differ, ADR-054 wins,
except for the fallback-emission correction recorded in section 3, which
supersedes the ADR's stated mechanism for the reason given there.
Companion: BRANDING.md explains how a brand overrides these
tokens; brand-pack.md is the portable per-brand documentation
unit (measured prose bound to an override delta); APPEARANCE.md
is the closed appearance-option grammar (surface, elevation, region recipes);
this file defines what the tokens are.
The founding statement this vocabulary serves: brickwork is the building blocks a user needs to build beautiful interfaces; our defaults should be beautiful. This is a design requirement, not a claim that every component has received visual acceptance. Accessibility checks and independent rendered reviews establish different evidence; neither token membership nor composition alone proves beauty. The current owner direction and evidence boundary are in POSITIONING.md. The meet-or-beat kit panel and scorecard live in VISUAL-BAR.md.
Phase (a) kept the shipped flat naming (--bw-color-*, --bw-size-*,
--bw-density-*, --bw-font-*) through 0.10.0; the tier re-grammar
(--bw-space-*, --bw-radius-*, --bw-component-*) landed in 0.11.0 per
ADR-054 §7, inside the 0.x window before 1.0 freezes the names. Every
renamed token keeps its old name as a courtesy alias (see section 11).
1. The three-layer model (summary)
- Substrate: the token names and their meanings. Semver-public.
- base-theme: the default values for every token, light and authored dark. The root every brand inherits from. This file documents base-theme's values.
- brand themes: a delta of ~7-14 load-bearing tokens; everything else derives.
--bw-primitive-* is build-time input only, as of 0.11.0. The raw
colour/scale ramps behind the DTCG source are consumed by the build to
compute the semantic and component tokens documented in this file; they are
no longer emitted to tokens.css or available on :root (they were,
through 0.10.0). A consumer never references --bw-primitive-* directly;
every documented token in this file is the correct name to use.
2. The load-bearing minimum
A brand authors these; base-theme derives the rest.
| # | Token | Why not derivable |
|---|---|---|
| 1 | --bw-color-surface |
the paper colour |
| 2 | --bw-color-fg |
the ink colour |
| 3 | --bw-color-border |
grey or brand-tinted, a judgement call |
| 4 | --bw-color-accent |
the brand colour |
| 5 | --bw-color-danger |
distinct destructive meaning |
| 6 | --bw-color-success |
distinct positive meaning |
| 7 | --bw-color-warning |
distinct attention meaning |
| 8* | --bw-color-surface-inverse |
only when ink is not the inverse surface (defaults to := fg) |
Also brand-authorable but rarely: --bw-color-info (base-theme ships a
distinct cyan; a 3-role brand sets --bw-color-info: var(--bw-color-accent)
in one line), --bw-color-fg-on-accent (contrast pick; verify at 4.5:1),
the font families (--bw-font-family-sans/-display/-mono). Dark mode doubles
the authored set (dark values are authored, never computed from light,
BR-BW-TOK-002). Full brand light+dark: ~14-16 values.
The load-bearing set is machine-readable (brickwork#39): the DTCG source
carries $extensions.bw.loadBearing plus constraint metadata (contrast
pairs, allowed ranges) alongside each load-bearing token, and the build
emits dist/token-manifest.json, which holds the load-bearing set with its
constraints and the full overridable --bw-* vocabulary. The Python
accessor is services/token_manifest.py: load_bearing(),
overridable_names(), and is_overridable(). This file remains the
narrative reference; the manifest is the machine-checkable source of truth
for tooling (validation, the brand-CSS emitter in section 3a, brand
scaffolding). The other two versioned-contract siblings live beside it in
the same dist directory: template-manifest.json (named {% block %} /
{% partialdef %} names) and interaction-manifest.json (Alpine.data
names, bw: events, and package-owned HTMX target IDs;
icvoss/django-brickwork#229). Catalogue taxonomy is a fourth generated
file (catalogue-manifest.json) and is not a BR-BW-VER-001 surface.
3. Derivation mechanism
Derived tokens are live CSS expressions over the load-bearing set, so a brand
(or runtime tenant override) that sets --bw-color-accent recolours the whole
derived family in-browser with no rebuild.
color-mix(in oklab, ...)is the primary mechanism (color-mix is Baseline Widely Available since Nov 2025; shipped with no fallback). Every tint, shade, and mix below uses it. The mix space is OKLAB, not oklch, deliberately: browsers giveoklch(1 0 0)an explicit hue of 0 rather than a powerless one, so an oklch-space mix interpolates the hue ANGLE toward 0 and rotates every tint (amber 58 renders at hue 4, i.e. pink). Oklab is rectangular: an achromatic partner (black, white, transparent, a chroma-0 surface) scales chroma and preserves the source hue exactly, and for black/white shades the result is identical to the ideal oklch shade. Authored token VALUES stay oklch throughout (BR-BW-TOK-003 is about authoring notation, not the mix space).- Relative colour
oklch(from ...)is avoided in 0.3.0. ADR-054 §3 proposed dual declarations (literal fallback, then live expression, last valid wins). That mechanism does not work for custom properties: any token stream is a valid custom-property declaration, so the live expression always wins at declaration time and an unsupporting engine only fails later, at the use site, falling back tounsetrather than to the literal. Since every needed derivation is expressible incolor-mix(), 0.3.0 ships color-mix only and no relative-colour syntax; this correction supersedes the ADR's fallback-emission mechanism and is recorded here deliberately. - Source encoding: in the DTCG source a derived token keeps its shipped
literal/alias as
$value(documentation of the resolved default and the regression baseline) and carries the expression in$extensions.bw.derived. The build emits the expression as the token's value intokens.css. A verification test recomputes eachcolor-mix()linearly in oklch and asserts the result stays within a small tolerance of the$valuebaseline (guarding both the formula constants and future default-value edits). Hue is included: an achromatic mix partner (black, white, transparent, or a chroma-0 surface) is hue-powerless per CSS Color 4, so the resolved hue equals the source token's hue, and every$valuebaseline carries that true resolved hue (asserted within 4 degrees wherever the computed chroma is perceptible, >= 0.02). Where a ramp step's hue drifts from the resolved hue, the baseline is a literal, not the ramp alias. - Every derived token remains individually overridable. Derivation is base-theme's default, never a lock: a brand may set any derived name to a flat value and the cascade wins.
- The same derivation graph applies in dark, with dark-specific constants (and, for three tokens, a flipped direction, noted inline below). Dark load-bearing values remain authored (BR-BW-TOK-002 is untouched: the load-bearing set is authored per theme; only within-theme fine tokens compute).
Percentage constants below are tuned so the computed result reproduces the shipped 0.2.4 literal for the default palette (verified tolerance ΔL ≤ 0.015, ΔC ≤ 0.02, Δalpha ≤ 0.01); the implementation verifies each constant against the authored source values and adjusts within that tolerance where the source literal differs from the figures used here. Where no constant can reach the authored literal within tolerance, the token's $value baseline is updated to the literal the expression computes (a permitted MINOR default-value change, section 11), so $value always documents the value the live expression resolves to.
0.7.0 craft round: the components gained drawn form controls (select indicator, checkbox/radio), solid-button borders in their own hue, input focus halo under the a11y outline, light-theme surface top-light, selection wash and thin scrollbars; all colour maths measured in the CSS comments, per the owner directive that token identity alone still read mechanical next to Radix-grade control drawing.
0.4.0 polish round: the sunken depth, muted ink, dark surface ladder, dark intent and accent tint volumes, heading-xl weight, content measure, and dark low-level elevation defaults were retuned per the synthesised four-critic design review under the owner directive "brickwork is the building blocks to build beautiful interfaces; our defaults should be beautiful"; every retuned constant re-baselined its $value per the paragraph above.
3a. The brand-CSS emitter
[NEW 0.11.0] (brickwork#40). render_brand_css(light, dark=None, *,
validate=True) -> str in services/brand_css.py (re-exported from
services/tokens) takes a brand's override values (light, and optionally
dark) and emits the :root / [data-theme="dark"] override blocks a
consumer includes after tokens.css. With validate=True (the default) it
checks every supplied name against the token manifest (section 2:
overridable and not build-time-only) and enforces the fg-on-accent contrast
constraint, raising rather than emitting CSS that would ship an invalid
name or fail AA. This is the supported primitive behind BRANDING.md's
per-tenant runtime-branding recipe; this section is a pointer only, the
worked recipe (multi-tenant lookup, caching, template wiring) lives in
BRANDING.md.
3b. Cascade claims are verified by rendering, never by reading
A claim about what the cascade does is verified by rendering it. Reading the CSS is not evidence. This is an instrument rule, not a caution: three separate cascade claims in this package were wrong, every one survived a careful source read, and every one died to a browser measurement.
The operational test when judging whether a declaration is redundant is not "does an ancestor set this property" but "does anything else set this property on a path that reaches this element", which covers ancestors and same-element competitors in one question. An auditor with the narrow version finds one mechanism and deletes the other.
Two mechanisms make a declaration load-bearing, and neither is visible in the declaration itself:
- Inheritance barrier. For an inherited property, setting it to its own
initial value blocks an ancestor's value.
.bw-contenth2 and.bw-footerboth setletter-spacingto thenormaltracking token; both resolve to0em; both read as redundant. Removing either lets a wrapper's tracking through, measured at 4.8px under aletter-spacing: 0.3emancestor. - Modifier reset.
.bw-data-table__th--labelsetsletter-spacing: var(--bw-font-tracking-normal)to cancel the tracking its own base class.bw-data-table__thsets for uppercase column headers. No ancestor is involved; the competitor is a rule on the same element.
The corollary is that an explicit initial value is not evidence of
redundancy, and the inverse also holds: an omitted property is not
automatically evidence that the property is unwanted, either; it has to be
checked, not assumed either way. .bw-prose code and pre bind no
letter-spacing, so an ancestor's tracking reaches them; binding
--bw-text-code-tracking would have resolved to 0em and removed that
inherited value rather than adding an unused one, which is a real behaviour
change, not a no-op. Checking it (icvoss/django-brickwork#312) found no rule
on the path to either element that needed to block a non-0em ancestor, so
the omission stood and the token was removed rather than wired; see section
7.4 for the two reasons that do make -tracking load-bearing elsewhere
(heading-lg, caption).
The three instances behind this rule: !important does not beat a
descendant's own value, so a hiding mechanism must target descendants
explicitly; the two 0em barriers above; and a paragraph in section 7.4 that
stated the tracking case exactly backwards until it was measured.
4. Colour
4.1 Surfaces
| Token | LB/D | Light derivation | Dark derivation |
|---|---|---|---|
--bw-color-surface |
LB | authored | authored |
--bw-color-surface-sunken |
D | color-mix(in oklab, var(--bw-color-surface) 96.5%, black) (0.4.0: deepened from 98.5%, which was indistinguishable from white; lands on gray.100) |
color-mix(in oklab, var(--bw-color-surface) 76%, black) (dark sunken is darker than surface) |
--bw-color-surface-raised |
D | var(--bw-color-surface) (light differentiates via shadow) |
color-mix(in oklab, var(--bw-color-surface) 93%, white) (dark differentiates via lightness) |
--bw-color-surface-inverse |
D | var(--bw-color-fg) |
var(--bw-color-fg) |
--bw-color-surface-overlay [NEW] |
D (light) / authored (dark) | color-mix(in oklab, var(--bw-color-surface-inverse) 50%, transparent) |
authored oklch(0 0 0 / 0.6): a scrim is dark-over-content in both themes, and dark surface-inverse (:= fg) is near-white, so the derivation would yield a light wash |
--bw-color-surface-marketing-tint [NEW 1.2.0] |
D | color-mix(in oklab, var(--bw-color-accent) 8%, var(--bw-color-surface)) |
AUTHORED per BR-BW-TOK-002 (not mechanically derived from light): color-mix(in oklab, var(--bw-color-accent) 12%, var(--bw-color-surface)), mirroring the light shape (mix toward surface, not toward black like accent-subtle) since a mix-toward-black at a low percentage on an already-near-black dark surface would barely tint |
--bw-color-stage-wash [NEW 4.2.0] |
D | color-mix(in oklab, var(--bw-color-accent) 8%, transparent) |
AUTHORED per BR-BW-TOK-002: color-mix(in oklab, var(--bw-color-accent) 16%, transparent), doubled from light so the wash still reads against an already-dark surface. Mixed toward transparent rather than toward surface (unlike surface-marketing-tint): this is a decorative layer painted behind the copy (BR-BW-MKT-007), not a background fill, so alpha alone carries the effect |
--bw-color-stage-grid [NEW 4.2.0] |
D | color-mix(in oklab, var(--bw-color-border) 60%, transparent) |
AUTHORED per BR-BW-TOK-002: color-mix(in oklab, var(--bw-color-border) 70%, transparent), a stronger line so the grid stays visible on graphite |
Rule: any component at elevation 2+ uses background:
var(--bw-color-surface-raised), both themes.
4.2 Foregrounds
| Token | LB/D | Light | Dark |
|---|---|---|---|
--bw-color-fg |
LB | authored | authored |
--bw-color-fg-muted |
D | color-mix(in oklab, var(--bw-color-fg) 62%, var(--bw-color-surface)) (0.4.0: raised from 56%; measures 5.82:1 on surface, 5.26:1 on the sunken canvas) |
same shape, 72% (0.4.0: raised from 67% to companion the dimmed dark fg; 7.58:1 on surface, 6.68:1 on raised) |
--bw-color-fg-subtle [NEW] |
D | color-mix(in oklab, var(--bw-color-fg) 37%, var(--bw-color-surface)) |
same shape, 49% |
--bw-color-fg-on-accent |
authored | contrast pick, stays a literal; the one derived-looking token every brand must verify at 4.5:1 | authored |
--bw-color-fg-on-inverse |
D | var(--bw-color-surface) |
var(--bw-color-surface) |
--bw-color-icon-muted |
D | var(--bw-color-fg-subtle) |
var(--bw-color-fg-subtle) |
Contrast note: fg-muted text must not sit inside hover-overlaid containers.
The 4 percent hover wash (4.6) sinks muted text below AA; muted copy inside a
hoverable row or item either steps up to fg on hover or lives outside the
overlaid element.
4.3 Borders
| Token | LB/D | Light | Dark |
|---|---|---|---|
--bw-color-border |
LB | authored | authored |
--bw-color-border-strong |
D | color-mix(in oklab, var(--bw-color-border) 94%, black) |
direction flips: color-mix(in oklab, var(--bw-color-border) 86%, white) (a dark theme's emphasis border must get lighter) |
--bw-color-border-control [NEW] |
D | color-mix(in oklab, var(--bw-color-fg) 44%, var(--bw-color-surface)) (measured 3.23:1 on the default surface) |
color-mix(in oklab, var(--bw-color-fg) 45%, var(--bw-color-surface)) (measured 3.38:1 on the 0.4.0 graphite surface) |
border-control exists because the divider border token stays deliberately
light for calm chrome, while an input or control boundary is a visual cue
WCAG 1.4.11 requires at 3:1 against the adjacent surface. Inputs, selects,
and similar controls take border-control; dividers and card outlines keep
border.
4.4 Accent
| Token | LB/D | Light | Dark |
|---|---|---|---|
--bw-color-accent |
LB | authored | authored |
--bw-color-accent-hover |
D | color-mix(in oklab, var(--bw-color-accent) 89%, black) |
color-mix(in oklab, var(--bw-color-accent) 88%, black) |
--bw-color-accent-subtle |
D | color-mix(in oklab, var(--bw-color-accent) 7%, var(--bw-color-surface)) |
color-mix(in oklab, var(--bw-color-accent) 30%, black) (0.4.0: lowered from 40% so selection recedes behind content) |
--bw-color-focus-ring |
D | color-mix(in oklab, var(--bw-color-accent) 95%, black) |
color-mix(in oklab, var(--bw-color-accent) 82%, black) |
accent-hover is retained as the flat-colour fallback; transient hover
feedback on non-accent surfaces uses the state overlays (4.6), which is why a
brand whose hover is "a brightness shift" authors zero hover tokens.
4.5 Intents (danger / success / warning / info)
Six tiers per intent so an alert, badge, or toast never invents a value.
X ranges over danger, success, warning, info.
| Token | Tier | LB/D | Light | Dark |
|---|---|---|---|---|
--bw-color-X |
base | LB (info: authored cyan default, := accent is the documented 3-role collapse) |
authored | authored |
--bw-color-X-subtle |
tinted bg | D | color-mix(in oklab, var(--bw-color-X) 7%, var(--bw-color-surface)) |
color-mix(in oklab, var(--bw-color-X) P%, black) (danger 26%, success 23%, warning 22%, info 26%; 0.4.0: lowered from 41/37/36/42 so the dark tints whisper like light; X-fg measures 7.22 to 10.77 on them) |
--bw-color-X-border [NEW] |
tinted border | D | color-mix(in oklab, var(--bw-color-X) 30%, var(--bw-color-surface)) (the badge/alert border, replacing invented inline mixes) |
color-mix(in oklab, var(--bw-color-X) 55%, black) (clears the black-mixed subtle tint by delta-L >= 0.08, verified 0.184-0.254) |
--bw-color-X-strong [NEW] |
border/emphasis | D | color-mix(in oklab, var(--bw-color-X) 88%, black) |
flips: color-mix(in oklab, var(--bw-color-X) 96%, white) |
--bw-color-X-fg [NEW] |
text on X-subtle (also painted on plain surface: .bw-field__error, .bw-trend--good/.bw-trend--bad and their retained aliases opt-in sentiment modifiers, .bw-account-menu__item--danger; icvoss/django-brickwork#388 moved the direction modifiers .bw-trend--up/--down/--flat off X-fg onto --bw-color-fg-muted, since direction is not itself a judgement) |
D | color-mix(in oklab, var(--bw-color-X) P%, var(--bw-color-status-fg-ink)) (danger 87%, success 69%, warning 68%, info 75%), mixed toward the dedicated --bw-color-status-fg-ink token, NOT var(--bw-color-fg) (brickwork#289 round 2; see the trap note below): default measures 5.33-5.40 against X-subtle and 5.74-5.84 against plain surface (all at surface L=1.0), and stays >= 4.5:1 against BOTH backgrounds for --bw-color-surface overridden anywhere down to L 0.94 (conservative published floor; measured worst-family crossing L 0.9390), independent of any --bw-color-fg override |
color-mix(in oklab, var(--bw-color-X) P%, white) (danger 73%, success 78%, warning 78%, info 78%; brickwork#289: danger alone tightened from 78% because its plain-surface pairing degraded once dark surface lightened past its true ceiling, so danger does NOT share the other three families' constant, unlike every other row in this table); lightness boost for AA on the dark tint |
--bw-color-status-fg-ink [NEW, brickwork#289 round 2] |
fixed mix partner for X-fg |
not load-bearing (overridable, rarely authored) | oklch(0.205 0.005 265) (= the light fg default, gray.900, but a DISTINCT token) |
oklch(0.93 0.002 265) (= the dark fg default; unreferenced by any dark derivation, kept only so the name exists in both theme files) |
--bw-color-X-on-fg [NEW] |
text on solid X |
authored | oklch(1 0 0) for danger only (4.83, passes); near-black ink for warning, success, and info (white fails AA on the solid amber, green, and cyan bases: 3.19, 3.32, 3.83) |
authored per hue, same rule (all four take near-black ink: the dark bases sit on the lighter 400/500 ramp steps, where white fails AA) |
The dark subtle tier (accent included) mixes toward black, not the
surface, a correction from the draft: the authored 950-ramp tints carry far
more chroma than a mix with the near-achromatic dark surface can reach
(ΔC ≈ 0.04 at any lightness-correct constant), and because the dark surface
is not chroma-free its 265 hue would dominate the interpolated hue, dragging
a danger tint toward violet. Mixing toward black preserves the intent hue
(black's hue is powerless) and reaches the authored depth.
brickwork#289: X-fg tracks --bw-color-surface but not --bw-color-fg,
closing two silent AA traps in sequence. Through 3.12.0, X-fg mixed
toward literal black regardless of surface, while X-subtle mixed toward
var(--bw-color-surface). Since --bw-color-surface is a documented,
load-bearing, brand-overridable token, an ordinary customisation (darkening
surface, with no status colour touched at all) closed the gap and dropped
warning-fg/success-fg below 4.5:1 with no warning: default-surface
warning measured only 4.68:1, and any surface darker than L ~0.986 failed
(trap 1).
The first fix mixed X-fg toward var(--bw-color-fg) (the theme's own ink)
instead of literal black, so darkening surface darkened X-fg too, the same
direction X-subtle moves. That traded trap 1 for an equivalent trap one
token over: --bw-color-fg is ITSELF one of the seven load-bearing tokens
BRANDING.md tells every brand to author, so a brand authoring an ordinary,
documented fg override (no surface or status colour touched at all) could
silently drop X-fg contrast below AA the same way (trap 2; a light fg
override to oklch(0.95 0.01 265) measured danger-fg on danger-subtle
at 3.55:1, verified through render_brand_css()'s own resolver).
The shipped fix mixes X-fg toward --bw-color-status-fg-ink, a
dedicated token distinct from both --bw-color-surface and
--bw-color-fg, defaulting to the same ink value fg defaults to
(oklch(0.205 0.005 265), gray.900) but never moved by an override of
either. This closes trap 2 without reopening trap 1: the numeric floor
against a --bw-color-surface override is unchanged from the var(fg)
attempt (status-fg-ink's default equals that attempt's resolved value), and
an --bw-color-fg override now has zero effect on status-text contrast.
What this guarantees, stated precisely: X-subtle is the binding
constraint that sets the published floor. X-fg is verified to hold >= 4.5:1
against BOTH X-subtle and plain surface (the pairing .bw-field__error,
.bw-trend--good/.bw-trend--bad (and their retained aliases), and
.bw-account-menu__item--danger actually use) by
DEFAULT for --bw-color-surface overridden anywhere down to L 0.94 (a
conservative published floor, rounded toward safety from the measured
worst-family crossing points: subtle L 0.9390, plain surface L 0.9177), but
across the whole surface-L range X-subtle is always the tighter constraint
for every family (bisected: at L 0.94, danger subtle 4.51 vs plain 4.90,
warning 4.53 vs 5.24, success 4.57 vs 5.28, info 4.55 vs 5.11), so it is
X-subtle, not plain surface, that determines where the floor sits and what
render_brand_css() reports first when a surface override pushes past it.
Plain surface is declared as its own contrastPairs entry and verified to
exceed AA at that floor, for defence in depth: the pairing those three
components actually use stays covered by the gate even though it is not
currently the constraint that binds. If a future token change makes plain
surface the tighter constraint for some family, the published floor moves,
and the error render_brand_css() raises will name that pairing instead.
Independently of any --bw-color-fg value: an ordinary fg override,
the documented load-bearing case, has zero effect on status-text contrast
because status-fg-ink is a separate input fg does not touch. Beyond that,
render_brand_css()'s contrastPairs check is not scoped to surface alone:
it re-resolves BOTH sides of EVERY declared pairing from whichever inputs the
caller actually supplied (--bw-color-surface, the relevant --bw-color-X
status base, or --bw-color-status-fg-ink itself), so an extreme override of
any of those three raises the same loud BrandValidationError, not only a
surface override. What is NOT caught: a caller who authors --bw-color-X-fg
or --bw-color-X-subtle directly as a literal (bypassing the derivation
entirely) is validating a hand-picked value, not the formula, and must verify
it themselves the way fg-on-accent is verified (docs/BRANDING.md); the
check resolves the derivation's inputs, not an override of the derived
name itself. Below the published floor, or under a large enough override of
any of the three checked inputs, the pairing may still fail and raise; a
brand pushing that far sees the exact ratio in the error rather than a
silent ship. The token-manifest.json contrastPairs section (distinct
from loadBearing: none of X-fg, X-subtle, or plain surface need be
brand-authored for this check to apply) declares BOTH backgrounds X-fg is
actually painted on (the -subtle tint and plain surface), and
render_brand_css() enforces both even when the caller's override touches
only --bw-color-surface or only the status base, with neither derived
token named explicitly.
Branding note: the account-menu's destructive item renders danger-fg
text on surface-raised in dark. Dark's X-fg mixes toward literal white
(never fg or status-fg-ink), so it is immune to any brand override by
construction and carries no contrastPairs entry; only a dark
--bw-color-surface override can move this pairing, since surface-raised
derives from it. The default pair passes AA comfortably, and the dark
constant was tightened from 78% to 73% toward white (brickwork#289) so this
pairing holds for a dark --bw-color-surface override up to L 0.29
(conservative published ceiling; verified true crossing point L 0.302,
tighter than the double-the-default L 0.35 previously published here, which
was itself an unverified overstatement); warning, success, and info already
held at their existing 78% constant and are unchanged. Independently, the
pairing holds for a dark --bw-color-danger override down to L 0.52
(conservative published floor, rounded up from the measured true crossing
point L 0.500 for the same reason as the light floor above: publishing the
exact crossing point would leave zero margin against rounding). A brand
whose dark danger is darker than L 0.52, or whose dark surface is lighter
than L 0.29, must re-verify the account-menu danger-on-raised pair and, if
needed, author --bw-color-danger-fg directly.
4.6 Interactive state
Hybrid model: transient feedback is a translucent overlay applied over whatever surface is beneath; persistent state is a dedicated token.
| Token | LB/D | Light | Dark |
|---|---|---|---|
--bw-state-hover-overlay [NEW] |
authored | oklch(0 0 0 / 0.04) |
oklch(1 0 0 / 0.06) |
--bw-state-active-overlay [NEW] |
authored | oklch(0 0 0 / 0.08) |
oklch(1 0 0 / 0.12) |
--bw-state-selected-bg [NEW] |
D | var(--bw-color-accent-subtle) |
same |
Application mechanism: for elements with a transparent resting background
(nav links, ghost buttons, table rows, menu items) hover sets
background-color: var(--bw-state-hover-overlay) directly. For elements that
already carry a background (active nav item, selected row, primary button)
the overlay layers on top via
background-image: linear-gradient(var(--bw-state-hover-overlay), var(--bw-state-hover-overlay)),
so hover composes with, rather than replaces, the underlying state.
Stacking order (normative for data_table and lists): zebra stripe
(surface-sunken on odd rows) → selected (--bw-state-selected-bg replaces
the stripe) → hover overlay (always layers last, on top of whichever
background resolved). Disabled: flat colour tokens
(--bw-color-action-disabled-{bg,text,border}, COL-018 /
icvoss/django-brickwork#282), never opacity. Nav disabled rows use
--bw-component-nav-item-disabled-text on the same flat-colour principle
(the former "nav exception" is now the package pattern).
--bw-component-disabled-opacity remains for non-colour dimming only
(examples, HTMX indicator comparisons); it is not the disabled encoding.
Contrast note: fg-muted text must not sit inside hover-overlaid
containers; the 4 percent wash sinks it below AA (see 4.2).
4.7 Component roles (flat names in 0.3.0; component tier in 0.11.0)
The nav, breadcrumb, and skeleton per-component colour roles re-tier from
--bw-color-* to --bw-component-* in 0.11.0 (each row's "was" name is
kept as a courtesy alias). They remain theme-variant: the derivation
mechanism, load-bearing status, and computed values below are unchanged,
only the names move.
| Token | LB/D | Light | Dark |
|---|---|---|---|
--bw-component-nav-item-active-bg (was --bw-color-nav-item-active-bg through 0.10.0; kept as a courtesy alias) |
D | var(--bw-color-accent-subtle) |
same |
--bw-component-nav-item-active-text (was --bw-color-nav-item-active-text through 0.10.0; kept as a courtesy alias) |
D | var(--bw-color-accent-hover) |
branches: color-mix(in oklab, var(--bw-color-accent) 25%, white) (a high-lightness accent step; the light-mode borrow of accent-hover fails contrast on the dark tint) |
--bw-component-nav-item-active-border (was --bw-color-nav-item-active-border through 0.10.0; kept as a courtesy alias) |
D | var(--bw-color-accent) |
same |
--bw-component-nav-item-disabled-text [NEW] (was --bw-color-nav-item-disabled-text through 0.10.0; kept as a courtesy alias) |
D | var(--bw-color-fg-subtle) |
same |
--bw-component-nav-section-text [NEW] (was --bw-color-nav-section-text through 0.10.0; kept as a courtesy alias) |
D | var(--bw-color-fg-muted) |
same |
--bw-component-breadcrumb-current [NEW] (was --bw-color-breadcrumb-current through 0.10.0; kept as a courtesy alias) |
D | var(--bw-color-fg) |
same |
--bw-component-breadcrumb-separator [NEW] (was --bw-color-breadcrumb-separator through 0.10.0; kept as a courtesy alias) |
D | var(--bw-color-fg-subtle) |
same |
--bw-component-skeleton-bg (was --bw-color-skeleton-bg through 0.10.0; kept as a courtesy alias) |
D | color-mix(in oklab, var(--bw-color-surface-sunken) 96%, black) (0.4.0: constant retuned 94% to 96% to hold the gray.200 landing, one step deeper than the deepened 96.5% sunken) |
direction flips: color-mix(in oklab, var(--bw-color-surface-sunken) 82%, white) |
--bw-component-skeleton-shimmer (was --bw-color-skeleton-shimmer through 0.10.0; kept as a courtesy alias) |
D | color-mix(in oklab, var(--bw-component-skeleton-bg) 96%, black) |
direction flips: color-mix(in oklab, var(--bw-component-skeleton-bg) 86%, white) |
4.8 Chart tokens (categorical series and chart chrome) [NEW]
Fourteen tokens, fixed by ADR-082 in the umbrella. Eight categorical series
plus six chrome tokens. Every one is authored per theme, carrying
authoredPerTheme: true in the token source, and none derives from its light
counterpart.
| Token | LB/D | Light | Dark |
|---|---|---|---|
--bw-color-chart-1 .. -chart-8 |
authored | authored per series, in gamut | authored per series (hue held, L and C re-authored), in gamut |
--bw-color-chart-axis |
authored | oklch(0.45 0.004 265), 7.44:1 on surface |
oklch(0.62 0.004 265), 4.55:1 on raised |
--bw-color-chart-grid |
authored | oklch(0.922 0.003 265), 1.40:1 |
oklch(0.33 0.004 265), 1.39:1 |
--bw-color-chart-axis-label |
authored | oklch(0.48 0.003 265), 6.54:1 |
oklch(0.72 0.003 265), 6.69:1 |
--bw-color-chart-tooltip-bg |
authored | oklch(0.205 0.005 265) (inverts) |
oklch(0.279 0.006 265) (lifts, never inverts) |
--bw-color-chart-tooltip-text |
authored | 17.2:1 on tooltip-bg | 14.0:1 on tooltip-bg |
--bw-color-chart-tooltip-border |
authored | subtle lift, not a contrast affordance | subtle lift |
Why authored and not derived, since the rest of section 4 derives heavily.
BR-BW-TOK-002 already requires the load-bearing set be authored per theme, and
these join it for a reason specific to a categorical palette: a single uniform
formula applied across eight hues degrades the separation between them. Measured,
mixing each colour toward white by a fixed percentage drops the all-pairs
minimum from 0.1031 to 0.0877, 0.0774 and 0.0670 at 15, 25 and 35 percent. It
does not destroy separation, so derivation is not impossible; it spends margin
the brand retints need. --bw-color-fg-on-accent is the existing precedent for
authoring where a value's correctness is a verified property of the specific
colours rather than a percentage mix.
Hue is invariant across themes. A series' identity is its hue, so only lightness and chroma are re-authored for dark. If a series changed hue between themes, a user switching theme would see it change identity and any legend or support conversation naming a colour would break.
A series must also stay clear of the chart's own chrome. No series may sit within 0.08 in oklab of the axis, axis-label or gridline tokens, in either theme. That floor is lower than the 0.12 used between series, deliberately: two series must be told apart to read the data correctly, while a series confused with an axis label is a legibility annoyance rather than a misreading, and applying the stricter floor to the looser task would over-constrain a palette already carrying six other properties. The shipped palette holds 0.0870 light and 0.1202 dark.
The series palette carries a guarantee, with a stated scope. Any two series
stay distinguishable (all-pairs minimum 0.1453 light, 0.1520 dark against a
0.12 floor, measured at the worst point of the retint envelope rather than at
full chroma),
hold at least 38 degrees of hue separation, and clear WCAG 1.4.11's 3:1 against
their own surface (minimum 3.23:1 light, 3.27:1 dark), across brand retints down to
70 percent of shipped chroma (docs/BRANDING.md).
Every one of those figures is measured on the colour a browser paints, not the value authored. An oklch colour outside the sRGB gamut is clipped to the gamut boundary before it reaches a display, so an out-of-gamut palette's measured properties describe colours nobody sees. The whole palette is therefore solved to keep real headroom inside the gamut rather than merely inside it, and a test asserts that headroom as a positive margin. Sitting on the boundary is not good enough: an earlier palette passed a gamut check with a 0.001 tolerance while one series sat at -0.00053, outside the gamut and passing only because the tolerance was wider than the excursion. This is not theoretical: an earlier version of this palette had eleven of sixteen series out of gamut and measured 0.1632 as authored against 0.0787 as displayed, failing its own floor on any ordinary monitor.
All six properties are executable in tests/test_chart_series_contract.py,
which reads the built tokens.css rather than these sources. Contrast figures
throughout this section are true sRGB relative luminance (oklch to oklab to
linear sRGB, then the WCAG coefficients), not an approximation from oklch
lightness.
The guarantee is scoped to normal colour vision. Eight categorical colours cannot be made mutually distinguishable under dichromatic vision: the best achievable all-pairs floor across normal vision and all three dichromacies is 0.1025, below the range this package's own evidence treats as distinguishable, and this palette measures 0.0430 at worst (deuteranopia, light). That is a property of eight-colour palettes, not of this one. COL-030's standing NO carries the case instead: meaning never rides on colour alone, and CHT-014's swatch-plus-text-label pairing is the mechanism. The measured figures are pinned by tests so they cannot degrade unnoticed.
Gridlines are deliberately exempt from the 3:1 floor. A gridline is decoration that must recede behind the data, not a graphical object conveying information. The axis line, the axis label and the series are all held to a floor; the grid is not, and that is a decision rather than an oversight.
Tooltip series swatches are suppressed by a conforming adapter, not solved
in the palette. Series against --bw-color-chart-tooltip-bg is an ADR-082
pairing resolved by suppression, not by re-solving the series colours.
Recorded against icvoss/django-brickwork#301 and umbrella ADR-082.
The shipped CHT-006 adapter is
static/brickwork/js/chart-theme.js. brickworkChartTokens(el) is the
engine-agnostic token read; brickworkChartTheme("chartjs"|"apexcharts", …)
returns an engine-shaped options fragment with suppression baked in
(plugins.tooltip.displayColors: false for Chart.js;
tooltip.marker.show: false for ApexCharts). No charting engine is bundled
(CHT-011). An engine that cannot suppress its tooltip swatch is not a
supported adapter target until the pairing is solved for it. Adoption recipe:
INTEGRATION.md section 5a. Regression:
tests/test_chart_theme_adapter.py.
5. Elevation
Six levels, values adopted from Tailwind 4's --shadow-* ramp (industry
consensus geometry), colours expressed in oklch, dark variants authored
(shadows vanish on dark surfaces: higher alpha, plus a faint inset top
highlight at every level from 1 to fake ambient light; 0.4.0 extended the
highlight from levels 3+ down to 1 and 2 at fainter alphas, so dark cards
get depth instead of outlines only). Theme-variant: light values on :root,
dark under [data-theme="dark"].
| Token | Light | Dark |
|---|---|---|
--bw-elevation-0 |
none |
none |
--bw-elevation-1 |
0 1px 2px 0 oklch(0 0 0 / 0.05) |
0 1px 2px 0 oklch(0 0 0 / 0.3), inset 0 1px 0 0 oklch(1 0 0 / 0.03) |
--bw-elevation-2 |
0 1px 3px 0 oklch(0 0 0 / 0.1), 0 1px 2px -1px oklch(0 0 0 / 0.1) |
0 1px 3px 0 oklch(0 0 0 / 0.36), 0 1px 2px -1px oklch(0 0 0 / 0.3), inset 0 1px 0 0 oklch(1 0 0 / 0.04) |
--bw-elevation-3 |
0 4px 6px -1px oklch(0 0 0 / 0.1), 0 2px 4px -2px oklch(0 0 0 / 0.1) |
0 4px 6px -1px oklch(0 0 0 / 0.4), 0 2px 4px -2px oklch(0 0 0 / 0.3), inset 0 1px 0 0 oklch(1 0 0 / 0.04) |
--bw-elevation-4 |
0 10px 15px -3px oklch(0 0 0 / 0.1), 0 4px 6px -4px oklch(0 0 0 / 0.1) |
0 10px 15px -3px oklch(0 0 0 / 0.45), 0 4px 6px -4px oklch(0 0 0 / 0.35), inset 0 1px 0 0 oklch(1 0 0 / 0.05) |
--bw-elevation-5 |
0 20px 25px -5px oklch(0 0 0 / 0.1), 0 8px 10px -6px oklch(0 0 0 / 0.1) |
0 20px 25px -5px oklch(0 0 0 / 0.5), 0 8px 10px -6px oklch(0 0 0 / 0.4), inset 0 1px 0 0 oklch(1 0 0 / 0.06) |
Component map: card / auth panel / centred panel / data-table wrap
resting = 1 (auth and centred panels: 2, they sit alone on a sunken page);
interactive card hover = 2; dropdown, popover, account-menu panel = 3 (the
fix for the hardcoded 0 4px 12px literal); mobile drawer panel and modal
= 4; toast = 5; topbar = border-only by default (a brand may add 2).
Primary, secondary, and danger buttons rest on
--bw-component-button-elevation (default var(--bw-elevation-1)), with
hover reading --bw-component-button-elevation-hover and optional
--bw-component-button-hover-translate (icvoss/django-brickwork#649), so a
brand can lift buttons without moving card/panel elevation. The modal,
toast, and popover entries are forward-looking (each row lands with its
component); the card row shipped in 0.5.0 (_card.html).
6. Spacing, radius, borders, z-index, sizing
6.1 Spacing (--bw-space-*; values are Tailwind --spacing multiples)
Was --bw-size-space-* through 0.10.0; kept as a courtesy alias. Values
unchanged.
Shipped: 0, 1 (0.25rem), 2 (0.5), 3 (0.75), 4 (1), 5 (1.25), 6 (1.5),
8 (2), 10 (2.5), 12 (3).
New: px (1px), 0-5 (0.125rem), 1-5 (0.375rem), 7 (1.75rem),
9 (2.25rem), 11 (2.75rem), 16 (4rem), 20 (5rem), 24 (6rem).
-0-5 (not -px) replaces the hardcoded 2px gaps/paddings so they scale
with root font size; -px exists for true hairline spacing that must not
scale. -11 deliberately equals the touch-target minimum. No negative ramp
(use calc(var(--bw-space-N) * -1) at the point of use).
6.2 Radius (--bw-radius-*)
Was --bw-size-radius-* through 0.10.0; kept as a courtesy alias. Values
unchanged.
Shipped: sm 0.25rem, md 0.375rem, lg 0.5rem, full 9999px.
New: none 0, xl 0.75rem, 2xl 1rem (Tailwind-aligned values).
Component map: skeleton sm; button, input, nav link, form-errors, badge-square md; card, auth/centred panel, filter bar, alert, dropdown, account-menu panel lg (0.4.0: alert up from md to join the stacked card family; dropdown and account-menu panel down from xl so the panel radius is concentric with its item radius); popover, toast, modal xl; badge, avatar, pill full. The card, modal, toast, popover, avatar, pill, and badge-square entries are forward-looking (each lands with its component); the card row shipped in 0.5.0.
6.3 Border widths
| Token | Value | Use |
|---|---|---|
--bw-size-border-hairline [NEW] |
1px |
every default border/divider |
--bw-size-border-thick [NEW] |
2px |
emphasis borders |
--bw-size-border-nav-marker [NEW] |
3px |
the nav active marker |
6.4 Focus ring (a11y floor: not brand-tunable except colour)
| Token | Value |
|---|---|
--bw-focus-ring-width [NEW] |
2px |
--bw-focus-ring-offset [NEW] |
2px |
--bw-focus-ring-style [NEW] |
solid |
--bw-color-focus-ring |
derived default; tenant accents receive an explicit render_brand_css() value verified at ≥3:1 against every relevant surface in the theme (WCAG 1.4.11) |
Composed global rule:
outline: var(--bw-focus-ring-width) var(--bw-focus-ring-style) var(--bw-color-focus-ring); outline-offset: var(--bw-focus-ring-offset);
6.5 Z-index scale (--bw-z-*) [NEW]
base 0, dropdown 10, sticky 20, drawer 30, overlay 40, modal 50,
skip-link 60, toast 70, tooltip 80.
Replaces the bare 10 (topbar → sticky 20), 20 (drawer panel → drawer 30),
100 (skip link → 60), and the account-menu panel's 20 (→ dropdown 10).
Note the deliberate renumbering: dropdown content that opens inside the
sticky topbar must not fight it, and sticky chrome sits below overlay
surfaces.
6.6 Sizing
The icon-size scale is --bw-component-icon-size-* (was, through 0.10.0,
two duplicate names for the same values, --bw-icon-size-* and
--bw-size-icon-*; both are kept as courtesy aliases, and 0.11.0 dedups
them to the single component-tier name). This is the scale itself (-sm
through -2xl); it is distinct from the inline instance property
--bw-icon-size (no step suffix, set per-icon by {% bw_icon %}), which is
unchanged and is not a scale token.
The un-infixed component tokens below (button radius, button elevation,
button hover translate, icon stroke width,
content max-width, topbar position, disabled opacity, menu min-width,
select indicator, checkbox glyph, stat-tile value size, drawer width, toast
max-width, htmx indicator opacity) move to the --bw-component-* grammar in
0.11.0; each row below carries its "was" name as a courtesy alias.
| Token | Value | Notes |
|---|---|---|
--bw-component-button-radius |
var(--bw-radius-md) |
button corner radius; tracks the md radius step so an L3 radius pack moves buttons |
--bw-component-button-elevation [NEW, #649] |
var(--bw-elevation-1) |
resting shadow for primary / secondary / danger (non-disabled); override to separate buttons from card/panel elevation |
--bw-component-button-elevation-hover [NEW, #649] |
var(--bw-component-button-elevation) |
hover shadow for those variants; defaults to resting so hover is a no-op until authored |
--bw-component-button-hover-translate [NEW, #649] |
0 |
translateY() offset on hover; default 0 is byte-identical; suppressed under prefers-reduced-motion: reduce |
--bw-component-icon-size-2xl [NEW] |
2.5rem |
empty-state hero icons; was --bw-icon-size-2xl / --bw-size-icon-2xl through 0.10.0, kept as courtesy aliases |
--bw-size-control-height-sm [NEW] |
2rem |
fixed, not density-scaled (sm × compact would break touch targets) |
--bw-size-control-height-md [NEW] |
var(--bw-density-control-height) |
alias of the density token |
--bw-size-control-height-lg [NEW] |
3rem |
fixed |
--bw-size-touch-target-min [NEW] |
2.75rem |
WCAG 2.5.5 floor; never density-scaled |
--bw-size-max-width-prose [NEW] |
65ch |
long-form measure |
--bw-size-max-width-form [NEW] |
32rem |
formalises the empty-state body literal |
--bw-size-max-width-modal-sm [NEW] |
28rem |
matches the auth panel |
--bw-size-max-width-modal-md [NEW] |
32rem |
|
--bw-size-max-width-modal-lg [NEW] |
48rem |
|
--bw-component-drawer-width [NEW] |
min(20rem, 80vw) |
formalises the drawer literal; was --bw-drawer-width through 0.10.0, kept as a courtesy alias |
--bw-component-menu-min-width [NEW] |
12rem |
minimum width for dropdown-shaped panels; the account-menu literal becomes its consumer; was --bw-menu-min-width through 0.10.0, kept as a courtesy alias |
--bw-component-toast-max-width [NEW 0.9.0] |
24rem |
toast readable measure; the ramp has no step between modal-sm (28rem) and the drawer width, and a toast wants a narrower glanceable line; was --bw-toast-max-width through 0.10.0, kept as a courtesy alias |
--bw-component-stat-tile-value-size [NEW 0.5.0] |
var(--bw-font-size-3xl) |
stat tile numeral size (VIZ-001); a live reference so the font scale cascades, and the size modifiers re-point it per tile; was --bw-stat-tile-value-size through 0.10.0, kept as a courtesy alias |
--bw-component-select-indicator [NEW 0.7.0] |
chevron SVG data URI | select dropdown indicator, drawn at a mid-grey that reads as decorative on both themes; was --bw-select-indicator through 0.10.0, kept as a courtesy alias |
--bw-component-checkbox-glyph [NEW 0.7.0] |
tick SVG data URI (alpha mask) | consumed via mask-image and painted with fg-on-accent, so the glyph stays AA in both themes; was --bw-checkbox-glyph through 0.10.0, kept as a courtesy alias |
--bw-component-icon-stroke-width |
2 |
shipped, previously undocumented; the stroke width for the icon set; was --bw-icon-stroke-width through 0.10.0, kept as a courtesy alias |
--bw-component-content-max-width |
72rem |
0.4.0: was none; a default measure cap so bands stop degrading into full-bleed wires on wide monitors (invisible at 1280px); a consumer overrides it, including back to none. Token itself was --bw-content-max-width through 0.10.0, kept as a courtesy alias |
--bw-component-content-max-width-marketing [NEW 1.2.0] |
80rem |
ADR-055 marketing tokens: a wider content cap than the app shell's content-max-width (72rem), since a marketing canvas wants a wider column than a console. Used by shell/marketing.html's content wrapper. Was --bw-content-max-width-marketing at the raw name before the bw-component-* rename applied at build time; the raw name is not kept as an alias (introduced post-0.10.0, so no prior consumer depends on it) |
--bw-component-docs-nav-inline-size [NEW, #671] |
16rem |
Docs shell rail column width at the lg breakpoint (.bw-docs-layout grid-template-columns). Override when document titles need a wider rail; do not rewrite the grid |
--bw-component-section-gap-marketing [NEW 1.2.0] |
6rem |
ADR-055 marketing tokens: the vertical rhythm between stacked marketing sections (hero, feature grid, pricing, CTA), larger than the app's --bw-density-section-gap (2rem comfortable) since marketing pages read as fewer, more generous blocks. Raised from 4rem in the Phase 3 visual-bar craft wave (icvoss/django-brickwork#510). Density-agnostic (not itself part of the density axis) |
--bw-component-hero-copy-max-width [NEW, #640] |
var(--bw-size-max-width-prose) |
Hero copy-column measure (.bw-hero__copy, including media-behind). Defaults to the prose measure so package-default layout matches the pre-token binding. Raise this (and keep or raise heading) when a display heading needs a wider column than body copy |
--bw-component-hero-heading-max-width [NEW, #640] |
100% |
Hero heading measure (.bw-hero__heading). Defaults to 100% so the heading fills the copy column at package default. Override after widening the copy column when the heading should sit at a specific width inside it |
--bw-component-hero-lede-max-width [NEW, #640] |
100% |
Hero lede measure (.bw-hero__lede). Defaults to 100% (fills the copy column). When the copy column is widened for the heading, set this to var(--bw-size-max-width-prose) or 62ch so the lede stays at a readable measure without scoping a rule to .bw-hero__* |
--bw-component-logo-height [NEW, unreleased] |
2rem |
brickwork#83 (ADR-054 beautiful-by-default): the default cap the marketing shell applies to an img/svg dropped into brand_logo or brand_wordmark (block-size capped, width follows the intrinsic ratio), so an unconstrained mark/lockup renders at a sensible header size out of the box instead of a full-height banner. 2rem is the 32px end of the conventional 28-32px header-logo range. Applied through the brickwork-owned .bw-site-header__brand-mark / __brand-wordmark wrappers at zero specificity (:where), so a one-class consumer rule overrides it; or override the token itself to resize. Raw --bw-logo-height ships as a build alias of the canonical name |
--bw-component-hero-decoration-inset [NEW, #645] |
2rem |
Block-start offset for _hero.html's decoration watermark; CSS flips the sign for inset-inline-end so the mark hangs slightly past the trailing edge by the same magnitude. Applied only when the decoration slot has content |
--bw-component-hero-decoration-size [NEW, #645] |
14rem |
Default inline size of the hero decoration mark, further capped at 50% of the hero in CSS |
--bw-component-hero-eyebrow-marker-inline-size [NEW, #659] |
2.4rem |
Inline size of the leading accent rule on .bw-hero__eyebrow--rule (::before). Applied only when eyebrow_marker="rule" |
--bw-component-hero-eyebrow-marker-block-size [NEW, #659] |
2px |
Block size of the leading accent rule on .bw-hero__eyebrow--rule |
--bw-component-hero-eyebrow-marker-color [NEW, #659] |
var(--bw-color-accent) |
Paint colour for the eyebrow marker rule. Own token so a brand can colour the mark differently from the eyebrow text (which stays --bw-color-accent); must not use currentColor. Default matches accent so package-default rule heroes stay coherent |
--bw-component-hero-media-circle-size [NEW, #672] |
14rem |
Max inline size of .bw-hero__media when media_shape="circle". Caps a circular portrait so it cannot stretch to the full media column under media_placement="beside" |
--bw-component-topbar-position |
sticky |
shipped, previously undocumented; a consumer sets static to unstick the topbar; was --bw-topbar-position through 0.10.0, kept as a courtesy alias |
The --bw-size-icon-* / --bw-icon-size-* duplication (0.3.0 added 2xl
under both names for parity, deferring the dedup) resolves in 0.11.0: both
collapse to the single --bw-component-icon-size-* name, per the tier
re-grammar in section 4.7.
6.7 Density (all three modes; compact / comfortable / spacious)
Shipped seven unchanged. New:
| Token | Compact | Comfortable | Spacious |
|---|---|---|---|
--bw-density-topbar-height [NEW] |
3rem | 3.5rem | 4rem |
--bw-density-section-gap [NEW] |
1.5rem | 2rem | 3rem |
--bw-density-card-padding [NEW] |
0.75rem | 1rem | 1.5rem |
--bw-density-page-gutter-inline [NEW] |
1rem | 1.5rem | 2rem |
--bw-density-rail-width [NEW, #700/#701] |
3.25rem | 3.25rem | 3.5rem |
--bw-density-rail-width-expanded [NEW, #701] |
12.5rem | 13.75rem | 14rem |
rail-width is the resting capability-rail track for layout="regions" and
for {% bw_nav_rail density="icons" %} (52px at comfortable). rail-width-expanded
is the hover/focus-within expand target. Override either in brand CSS; do not
style against private .bw-nav-rail__* internals for width.
section-gap is the gap BETWEEN page sections; stack-gap stays the gap
WITHIN a stack. page-gutter-inline frees row-padding-inline to mean only
in-row padding. Never density-variant: font sizes and line heights (WCAG
1.4.4 in spirit), radius, elevation, border widths, touch-target minimum,
the spacing ramp itself.
6.8 Breakpoints (--bw-breakpoint-*) [NEW]
Authority: ADR-079, correcting SHL-014's originally-ratified @theme
inline mechanism, which cannot work because a media query cannot resolve
var().
| Token | Value | Notes |
|---|---|---|
--bw-breakpoint-sm [NEW] |
40rem |
|
--bw-breakpoint-md [NEW] |
48rem |
the shell's sidebar-to-drawer collapse point (SHL-017) and _data_table's responsive="stack" threshold (TBL-013); both reuse this one value so the two surfaces change together |
--bw-breakpoint-lg [NEW] |
64rem |
|
--bw-breakpoint-xl [NEW] |
80rem |
reserved: named by SHL-014 and emitted for completeness; no internal call site references it yet |
Emitted as literals on :root in tokens.css, like every other public
token, so a JS integration or a container-query component can read the
current value. Also emitted as a second, plain @theme block in
tailwind-theme.css (section 12 explains why that file now carries two
@theme blocks). Both are generated from the single source
frontend/src/tokens/source/breakpoint.tokens.json.
The honest limit. Unlike every other token in this file, overriding
--bw-breakpoint-* in a consumer's own brand CSS does not move
brickwork's own shipped brickwork.css breakpoints: those are compiled
into the static CSS the package ships via --theme() range-syntax
references, resolved at brickwork's own build time, not read live via
var() at browser runtime. An override changes where the consumer's own
Tailwind sm:/md:/lg:/xl: utilities break, and the literal value a
JS integration reads. It does not change where brickwork's shipped sidebar
collapses or where _data_table switches to stack mode; that requires a
package rebuild. This is the one axis in the token system that is
build-time only; every other axis in this file (brand, theme, density) is
genuinely live via var().
6.9 App-surface arrangement (.bw-band-grid, .bw-band-heading) [NEW]
Authority: ADR-090. Two classes, and together they are the complete and
closed app-family arrangement vocabulary (decision 1): how an app-surface
page arranges its bands side by side, and how it titles one. Public API under
the package's versioning contract, in the same sense as a template block name
or a token name, even though neither is itself a --bw-* token.
.bw-band-grid (bare, aliasing --2) plus .bw-band-grid--3 and
.bw-band-grid--4 are the app family's own N-column primitive, matching
.bw-stat-grid's own auto-fit/minmax(min(X, 100%), 1fr) idiom and its
min-inline-size: 0 child guard (icvoss/django-brickwork#136) and :empty
suppression (BR-BW-TPL-006). The floors step down as the explicit column
count rises, so a wide viewport does not fragment a small count into narrow
columns: bare/--2 floor at 24rem (two comfortable reading-width columns),
--3 at 18rem, --4 reuses .bw-stat-grid's own 14rem floor. Deliberately
content-driven, not breakpoint-driven: a band grid reflows when its own
columns no longer fit, which is a property of the band's width, not the
viewport's, so it does not consume the --bw-breakpoint-* tokens in section
6.8. A consumer needing viewport-keyed arrangement writes it themselves.
.bw-band-heading is a standalone visible <h2> caption for one band on
an app-surface page, outside bw-prose. Sized at the heading-lg type role
(section 7.4): one step under the page title's heading-xl, and deliberately
not heading-md, which is reserved for the card-title role
(.bw-card__title); a band heading sits at the page's own structural level,
not inside a card region. It carries no inline-size rule of its own,
which is the fix for the trap that motivated it: wrapping a band in
bw-prose to get a sized heading borrows that class's whole contract along
with the size, and bw-prose's first declaration is a 65ch content-measure
cap, not a heading utility, so it narrows whatever it wraps. Measured: a band
wrapped in bw-prose for this reason rendered 655px against its siblings'
976px.
The family boundary is a test, not a habit. bw-feature-grid--2/3/4,
bw-media-text and bw-listing-list__item ship in the same compiled
brickwork.css bundle an app-surface page loads, and work there
mechanically. They are marketing-family regardless: styled for marketing
surfaces and restyled for marketing reasons, so permitting them on an app
page would make a marketing visual change silently an app-surface regression
(ADR-090 decision 2). A template extending an app-family shell (app.html,
auth.html, centred.html) must not use a class frontend/src/marketing.css
defines a rule for. tests/test_family_boundary.py is the normative
statement of this rule: it derives the marketing-family class list from the
CSS itself, so a new marketing class is covered the moment it ships, and it
fails a build using one of them on an app-family template. The derivation and
its two vacuity guards (a template-count floor, and a positive check that it
can find known marketing classes) are not decoration; replacing either with a
hand-maintained list is the failure mode the rule exists to prevent.
Shipped since 3.14.0, adopted by a template since the following release:
src/brickwork/examples/ops/analysis-dashboard.html is the first shipped
consumer of both classes (icvoss/django-brickwork#438), converting its two
side-by-side ranked lists to .bw-band-grid--2 and giving three of its four
bands a visible .bw-band-heading in place of a hidden one. A CSS primitive
in this package is not complete until a shipped template emits it (ADR-090
decision 5): a class nothing renders is outside the contract surface, and an
edit breaking its child guard or :empty rule would fail nothing until then.
6.10 Placement: content primitives versus surface chrome [NEW]
Authority: ADR-090, amended 2026-08-31. A class's family is a property of its kind, not of the file its rules happen to sit in. Two kinds:
- Surface chrome (arrangement bands, heroes, pricing, testimonials) is
styled and restyled for marketing reasons. It is marketing-family, lives in
frontend/src/marketing.css, and stays forbidden on app-family surfaces under the family-boundary test in section 6.9. - Content primitives (notices, code display, figures, citations,
cross-links, tabs) are family-neutral: they compose with the
.bw-prosefloor and are legal on every shell, including the docs shell..bw-calloutis the first example (icvoss/django-brickwork#452): it lives infrontend/src/components.css, adjacent to.bw-prose, rather than infrontend/src/marketing.css, because it is content a docs or blog page pulls out of its own flow, not marketing surface chrome..bw-pager(icvoss/django-brickwork#460) is the cross-link example this section already names: a document's own predecessor/successor is content, not a surface, so it lives beside.bw-calloutincomponents.csstoo.
The file a rule sits in follows this classification; it never decides it.
tests/test_family_boundary.py derives the marketing-family set from
whatever marketing.css defines a rule for, so moving a content primitive
into components.css is what makes it usable on the docs shell, not a
docstring or an allowlist entry.
6.11 Content accessibility rules [NEW]
Authority: Wave 2 roadmap deliverable (docs/ROADMAP.md). Six rules for
long-form and docs-shell content: heading order, landmark structure, code
labelling, table responsiveness, reading measure, and reading progress.
Heading order. A component that renders content hardcodes the heading
level its slot occupies; it does not let a page override that level, and a
consuming page must not introduce a competing heading at the same level. The
shipped components fix a level each: _page_header.html renders the page's
own <h1> and states in its own accessibility note that "a consuming page
must not render a second one"
(src/brickwork/templates/brickwork/components/_page_header.html:48-49);
_card.html's title block renders <h2 class="bw-card__title">, both when
filled by hand and via the title context variable
(src/brickwork/templates/brickwork/components/_card.html:34,65,132).
.bw-prose then styles the full h1-h6 run for content that supplies its
own outline (frontend/src/components.css:4095). Visual size is not heading
level: .bw-band-heading is sized at the heading-lg role, deliberately one
step under the page title, and deliberately not heading-md, which
DESIGN.md's component map (section 7.4) reserves for the card-title role
(frontend/src/components.css:964-968, ADR-090 context section 2). A
component that wants a band caption the size of a card title still does not
render an <h2> competing with _card.html's own; size and level are
independent choices and only the second one affects the outline.
Landmark structure. The docs shell ships two, deliberately distinct,
header/footer block pairs, and filling the wrong one is the single most
likely mistake a consumer makes here
(src/brickwork/templates/brickwork/shell/docs.html:43-59). docs_header
and docs_footer are page-local, scoped inside the <article>, inside
<main id="bw-main">. docs_site_header and docs_site_footer are
site-wide chrome (brand mark, primary site nav, legal links) and sit outside
<main> entirely. Filling docs_header when the content is site nav puts
that nav inside the article, after the skip link's target and ahead of the
rail, in the accessibility tree, which is not where a repeating site
landmark belongs. A landmark choice in this shell is a decision, not a
default: read the shell's own header comment before choosing a block.
The axe gate does not cover this. a11y/axe.spec.mjs:36 sets
WCAG_TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"] and
:60 runs .withTags(WCAG_TAGS). Every axe landmark rule is tagged
best-practice and carries no WCAG tag, so none of them is ever evaluated
under this configuration. A green a11y gate is evidence about the WCAG rules
it runs, not about landmark structure: it is silent on landmark placement,
not clean on it. A change that touches which block a template fills needs
deliberate review, because the gate that is green either side of the change
cannot tell the two states apart.
Code labelling. A scrollable code region is reachable by keyboard and
carries an accessible name; a scroll container a keyboard user cannot focus
is a WCAG 2.1.1 failure. The language of a code sample is conveyed as text,
never colour alone (WCAG 1.4.1), the same principle the callout applies to
its own kind label (src/brickwork/examples/sections/content/callout.html:11-12,
frontend/src/components.css:4427-4430). The package ships no syntax
highlighting (icvoss/django-brickwork#259): where a consumer supplies
highlighted markup, the contrast of that consumer's own token colours is the
consumer's responsibility, not the package's. The rule is the boundary, not
a specific markup shape: a code display component states which side of that
line it sits on and does not silently absorb a contrast obligation it cannot
verify.
Table responsiveness. A table that overflows scrolls inside its own
container rather than widening the document. .bw-prose__table-wrap sets
overflow-x: auto plus contain: paint, and its own comment records why
both are load-bearing: overflow-x: auto alone does not stop a wide table
dragging the whole document sideways if an ancestor the consumer wraps it in
still overflows, so contain: paint guarantees the wrap is the clipping
boundary regardless of what an ancestor does
(frontend/src/components.css:4242-4254). That scroll container carries the
same focusability and accessible-name treatment as any other scrollable
region (rule above): the shipped examples give .bw-prose__table-wrap
tabindex="0" role="region" aria-label="..."
(src/brickwork/examples/sections/content/prose-block.html:91). The
_data_table.html component ships the same precedent independently: its
wrap scrolls unconditionally under the default responsive="scroll"
(src/brickwork/templates/brickwork/components/_data_table.html:123-124).
Reading measure. .bw-prose's first declaration caps content at a 65ch
measure (--bw-size-max-width-prose: 65ch,
src/brickwork/static/brickwork/dist/tokens.css:46,
frontend/src/components.css:4080-4081). A measure belongs to running text;
applying .bw-prose to something that is not running text imports a measure
it did not ask for. .bw-band-heading carries no inline-size rule of its
own for exactly this reason: wrapping a band in .bw-prose purely to get a
heading rendered narrowed it to 655px against its siblings' 976px, because
borrowing the class borrows its whole measure contract, not just its type
styles (frontend/src/components.css:955-962, ADR-090 context section 2).
Reach for .bw-band-heading or the page title's own heading role instead of
.bw-prose when the goal is a heading's type, not a paragraph's measure.
Reading progress. _progress.html ships a determinate progressbar:
role="progressbar", an enforced accessible name from the required label,
aria-valuemin/aria-valuemax always present, aria-valuenow present on
the determinate leg and omitted on the indeterminate leg, and a fill driven
by the --bw-progress-value custom property
(src/brickwork/templates/brickwork/components/_progress.html:44-61). A
continuously-updating aria-valuenow on a scroll-linked progress indicator
is announced repeatedly by some screen readers and becomes noise rather than
information. A decorative scroll indicator is aria-hidden; a genuine
progress control keeps its accessible name and its value. This choice is
deliberate and recorded at the point a scroll indicator is built, never left
to default. Its motion is CSS-owned (BR-BW-TOK-009) and the reduced-motion
floor applies (section 8.4): under prefers-reduced-motion, _progress.html
already replaces its own animated sweep with a static partial fill so
reduced motion never leaves a blank track with no indication of progress
(src/brickwork/templates/brickwork/components/_progress.html:20-24).
7. Typography
7.1 Families (the brand's dial)
--bw-font-family-sans: system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, "Noto Sans", sans-serif,
"Apple Color Emoji", "Segoe UI Emoji";
--bw-font-family-display: ui-serif, "Iowan Old Style", "Palatino Linotype",
Palatino, Georgia, "Times New Roman", Times, serif;
--bw-font-family-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo,
Consolas, "Liberation Mono", "Courier New", monospace;
Display is a deliberate system serif pairing (icvoss/django-brickwork#511),
not an alias of sans: heading-display, heading-2xl, heading-xl and
heading-section
consume it so hierarchy is not size-only out of the box. Brands override
--bw-font-family-display (and/or sans) with their own faces; see the
northline brand-pack example.
7.2 Size scale (--bw-font-size-*; every shipped value preserved)
| Token | rem | px | Use |
|---|---|---|---|
2xs |
0.6875 | 11 | chrome text only (overlines, micro-labels); never sentences. Package policy floor for chrome; content floor is xs (TYP-022) |
xs |
0.75 | 12 | captions, help, errors, timestamps; content / legibility floor |
sm |
0.875 | 14 | dense UI text, table cells |
md |
1 | 16 | body, inputs, buttons |
lg |
1.125 | 18 | emphasised body, card titles |
xl |
1.5 | 24 | page/dialog titles |
2xl |
2 | 32 | section/display headings |
3xl [NEW] |
2.5 | 40 | large page titles, h2-equivalent |
4xl [NEW] |
3 | 48 | hero headings (auth/centred shells) |
5xl [NEW] |
3.75 | 60 | display numerals (error pages) |
7.3 Line heights, weights, tracking
--bw-font-line-height-*:none 1[NEW],tight 1.25,snug 1.375[NEW],normal 1.5,relaxed 1.625[NEW],loose 2[NEW] (Tailwind names/values).--bw-font-weight-*:normal 400, medium 500, semibold 600, bold 700(unchanged; 300/800 excluded because the fallback stacks cannot honour them).--bw-font-tracking-*[NEW]:tight -0.025em, normal 0em, wide 0.025em, wider 0.05em(Tailwind names/values;widerpairs with uppercase overlines, which read tighter than mixed case).
7.4 Type roles (--bw-text-<role>-*) [NEW]
A role carries the properties it makes a decision about, not a fixed
five (CHANGED, icvoss/django-brickwork#312). -family, -size,
-line-height and -weight are near-universal; -tracking is carried
only where there is a reason. Every value a role does carry is a var()
reference into the scales above so overrides cascade. Components consume
roles, never raw scale steps, so nothing drifts out of rhythm.
-tracking is carried for one of two reasons, and both must be named,
because a rule naming only the first would license deleting the second:
- A typographic reason.
heading-display,heading-2xl,heading-xlandheading-sectioncarrytight, because large display type needs negative tracking.overlinecarrieswider, because uppercase overlines read tighter than mixed case. - Holding
0emagainst inheritance.heading-lgandcaptioncarrytracking: normalso their consuming rules (shell.css's.bw-contenth2 and.bw-footerrules) can set an explicit0emthat blocks an ancestor'sletter-spacingfrom inheriting through. This is the mechanism section 3b describes: a claim about what the cascade does is verified by rendering it, and removing either declaration was measured in Chromium to let a wrapper's tracking through at 4.8px under a0.3emancestor. Do not delete either on the strength of the consumption test alone.
heading-md, heading-sm, body-lg, body-md, body-sm and
code carry neither reason: each resolves to var(--bw-font-tracking-normal)
(0em, the browser default) and no rule on any path to them sets a
non-0em ancestor tracking that would need blocking, so the property was a
dead lever, not a spare. Their -tracking properties were removed
(icvoss/django-brickwork#312), the same disposition #288 gave the equivalent
dead -family tokens below. A future role that needs -tracking for either
of the two reasons above adds it back; the default for a new role is to omit
it, and to justify the addition against this list, not the other way round.
label carries tracking: normal again (icvoss/django-brickwork#642).
.bw-btn binds the whole label bundle, including letter-spacing, so a brand
can set button tracking once on the ladder without scoping to
.bw-btn--primary. The default is 0em (byte-identical at package defaults);
the wired declaration also holds that 0em against an ancestor's
letter-spacing, the same mechanism caption and heading-lg use.
A role token that no rule consumes is a false affordance, not a spare.
Shipping a property on a role promises a consumer that setting it changes
the render; a token the build emits and nothing reads breaks that promise
silently, since neither the type system nor a visual check flags an
override with no effect (icvoss/django-brickwork#288, 7 of 13 -family
tokens shipped this way with no accessibility impact and no broken render;
icvoss/django-brickwork#312, 7 of 13 -tracking tokens the same way). Each
property a role carries is therefore either wired into a consuming rule or
removed from the role's source definition; a property is never left defined
"for completeness" once a decision has been made about whether the role
binds it. tests/test_tokens.py::test_every_text_family_token_is_consumed_by_a_font_family_rule
enforces this for -family specifically, deriving the shipped set from the
token manifest rather than a hardcoded list, so a newly added dead token
fails the build instead of shipping unnoticed.
Not every role carries a -family token (CHANGED, icvoss/django-brickwork#288). body-lg
and body-sm deliberately have no -family property: those roles
inherit the sans stack from .bw-body rather than pinning it per role, so a
per-size body family cannot be set without also touching --bw-font-family-sans,
which is the intended override point. A per-size body family would make body
copy internally inconsistent rather than giving a consumer a real theming
lever, so this is a deliberate design choice, not an oversight.
label carries -family again (icvoss/django-brickwork#642). Buttons are the
load-bearing consumer of the label role, and a brand that wants a distinct
button face (for example monospace) must override --bw-text-label-family
without also retargeting every sans role. The default remains
var(--bw-font-family-sans), so package defaults stay byte-identical;
.bw-btn is the consuming rule.
code is wired (icvoss/django-brickwork#293). .bw-prose code consumes
--bw-text-code-family; .bw-prose pre consumes --bw-text-code-family,
--bw-text-code-size and --bw-text-code-line-height. Neither rule binds
-weight, and font-weight is inert here: nothing on a path to either rule
sets it, so binding it would change nothing today. --bw-text-code-weight
is therefore left defined but unwired, decided on its own merits separately
from #312 rather than swept into either the family wiring or the tracking
deletion. code's -tracking carried neither reason from the list above:
letter-spacing inherits, and no rule on the path to .bw-prose code or
.bw-prose pre needed to block a non-0em ancestor value from reaching it,
so --bw-text-code-tracking was removed with the other six
(icvoss/django-brickwork#312).
Wiring -family was a visual no-op at package default: --bw-text-code-family
already resolved to var(--bw-font-family-mono), and --bw-text-code-size
and the previously-read --bw-text-body-sm-size both resolved to
var(--bw-font-size-sm); --bw-text-code-line-height likewise matches the
--bw-font-line-height-normal the pre rule read directly before. Inline
code's font-size: 0.9em is untouched: a deliberate relative unit so
inline code tracks whichever role it is nested in, not a raw scale-step
reference. --bw-text-code-family is consumed like every other role's
-family token; the exemption that previously excluded it from
test_every_text_family_token_is_consumed_by_a_font_family_rule is
removed, not left empty.
| Role | Family | Size | Leading | Weight | Tracking |
|---|---|---|---|---|---|
heading-display [NEW 1.2.0] |
display | 5xl | tight | bold | tight |
heading-2xl |
display | 2xl | tight | bold | tight |
heading-xl |
display | xl | tight | semibold (0.4.0: was bold; 600 at 24px is the product-chrome cut, 2xl keeps bold for true display use) | tight |
heading-section [NEW, #643] |
display | xl | tight | semibold | tight (defaults match heading-xl so package-default renders stay identical; independently overridable so a brand can size marketing section intros without moving the page-header title) |
heading-lg |
sans | lg | snug | semibold | normal (holds 0em against inheritance; see above) |
heading-md |
sans | md | snug | semibold | (not carried) |
heading-sm |
sans | sm | snug | semibold | (not carried) |
body-lg |
(none, inherits .bw-body) |
lg | relaxed | normal | (not carried) |
body-md |
sans | md | normal | normal | (not carried) |
body-sm |
(none, inherits .bw-body) |
sm | normal | normal | (not carried) |
label |
sans | sm | none | medium | normal (button ladder theming + holds 0em; see #642) |
caption |
sans | xs | normal | normal | normal (holds 0em against inheritance; see above) |
overline |
sans | 2xs | none | semibold | wider |
code |
mono | sm | normal | normal | (not carried; weight defined, not wired, see below) |
Overline / 2xs consumer rule (TYP-022 / #281): chrome text only. Never
apply --text-overline or --bw-font-size-2xs to body or prose, interactive
control labels, form help or error text, or table cell content. The package's
own sites (nav section labels, stat labels) are the load-bearing chrome
exceptions; the Tailwind --text-overline utility is the consumer-facing
bypass and carries the same restriction. 3xs was removed in 4.0.0 (icvoss/django-brickwork#281 / TYP-022). The size floor remains 2xs for chrome and xs for content.
Component map: page-header title heading-xl (description body-md +
fg-muted); empty-state heading heading-lg, body body-md + fg-muted +
max-width-form; alert title heading-sm; form-errors title heading-sm; card
title heading-md; table th overline voice: xs semibold wide-tracked
uppercase fg-muted (0.4.0: was label; the header band now reads as column
apparatus, not a first data row; td body-sm; definition-mode label column
body-sm + fg-subtle); field label label, help and errors caption; button
label label; badge caption size at medium weight, tabular-nums; marketing hero
heading heading-display, marketing section intro heading heading-section
(icvoss/django-brickwork#643; defaults match the former heading-xl binding so
package-default visuals stay identical while brands theme section intros and
page titles independently; ADR-055 marketing tokens, brickwork.marketing); nav link
body-sm (0.4.0: was body-md; chrome sits a step below content), nav
section-label overline (uppercase at the component); breadcrumbs body-sm +
fg-muted, current crumb breadcrumb-current; account-menu item body-sm
(0.4.0: was body-md, completing the 14px chrome scale), secondary line
caption; pagination status caption + fg-muted; dialog/modal title
heading-sm. The dialog/modal entry is forward-looking (it lands with the
component); the card-title row shipped in 0.5.0 (_card.html, heading-md).
8. Motion
8.1 Durations (--bw-duration-*) [NEW]
instant 1ms (fires transitionend, unlike 0), fast 100ms, normal 200ms,
moderate 250ms, slow 400ms, plus loop timings shimmer 1500ms,
spin 700ms (formalising the two hardcoded loops).
0.9.0 interaction-set additions (MINOR, section 11):
| Token | Value | Use |
|---|---|---|
--bw-duration-toast-short [NEW 0.9.0] |
4000ms |
toast auto-dismiss, duration="short" (CBH-009); read by bwToast via getComputedStyle, never a transition timing |
--bw-duration-toast-normal [NEW 0.9.0] |
6000ms |
toast auto-dismiss, the "normal" default |
--bw-duration-toast-long [NEW 0.9.0] |
10000ms |
toast auto-dismiss, duration="long" |
--bw-debounce-search [NEW 0.9.0] |
300ms |
the combobox server-filter debounce window (CBH-019 path); JS-consumed, retunable in the token layer without touching templates |
8.2 Easings (--bw-ease-*) [NEW]
linear, standard cubic-bezier(0.4, 0, 0.2, 1),
in cubic-bezier(0.4, 0, 1, 1) (exits),
out cubic-bezier(0, 0, 0.2, 1) (entrances),
in-out cubic-bezier(0.4, 0, 0.2, 1) (moves),
emphasised cubic-bezier(0.2, 0, 0, 1) (energetic entrances).
8.3 Composite transitions (--bw-transition-*) [NEW]
| Token | Value |
|---|---|
colors |
color var(--bw-duration-fast) var(--bw-ease-standard), background-color var(--bw-duration-fast) var(--bw-ease-standard), border-color var(--bw-duration-fast) var(--bw-ease-standard) |
opacity |
opacity var(--bw-duration-fast) var(--bw-ease-standard) |
transform |
transform var(--bw-duration-normal) var(--bw-ease-in-out) |
shadow |
box-shadow var(--bw-duration-fast) var(--bw-ease-standard) |
Usage: transition: var(--bw-transition-colors);. Never transition: all.
Interaction map: hover colour shifts fast/standard; pressed and focus-ring
instant; disclosure and caret rotate normal; spinner spin/linear; shimmer
shimmer/linear; skip-link reveal fast.
8.4 Keyframes and reduced motion
@keyframes bw-fade-in-up (opacity 0→1, translateY(-4px)→0) joins
bw-spin/bw-shimmer as the entrance treatment for dropdown-shaped panels
(animation: bw-fade-in-up var(--bw-duration-normal) var(--bw-ease-out)).
The global prefers-reduced-motion floor is unchanged, !important,
non-tokenised infrastructure; .bw-spinner and the account-menu caret gain
explicit animation: none / no-transition overrides under it (a frozen
mid-state reads as broken). --bw-motion-scale remains a documented,
unshipped extension point.
9. Opacity
| Token | Value | Role |
|---|---|---|
--bw-component-disabled-opacity |
0.5 |
Not the disabled encoding (COL-018 / #282). Retained for non-colour dimming and legacy courtesy alias --bw-disabled-opacity. Disabled controls use --bw-color-action-disabled-{bg,text,border} |
--bw-opacity-muted [NEW] |
0.7 |
de-emphasis that is not disabled; conservative floor, re-verify contrast per use |
--bw-opacity-sort-idle [NEW] |
0.4 |
names the shipped sort-caret literal (decorative only) |
--bw-component-htmx-indicator-opacity [NEW 0.9.0] |
0.6 |
STA-006 in-flight dimming of an htmx swap target via the htmx-request class convention; between former disabled (0.5) and muted (0.7) so in-flight never reads as disabled; was --bw-htmx-indicator-opacity through 0.10.0, kept as a courtesy alias |
--bw-component-hero-decoration-opacity [NEW, #645] |
0.16 |
Default fade for _hero.html's decoration watermark/stamp (icvoss.com evidence). Decorative only; never relied on for contrast |
10. Reserved names (documented, deliberately not shipped)
Two kinds of reservation, which this section previously conflated. A name
here is undecided: documented so it is not accidentally reused, shipped only
when a real consumer names the need. That is different from a name the spec has
already committed to, whose shipping date alone is open. --bw-color-chart-1
through -chart-8 were the second kind before 3.13.0 and are now shipped; the
avatar-fallback series below (ILL-006, --bw-avatar-fallback-bg-{1..6}) is also
spec-committed rather than undecided, and is listed here only because it has no
implementation yet. The distinction matters because a spec-committed name is not
a candidate for reuse or renaming the way an undecided one is.
--bw-avatar-size-{sm,md,lg,xl}, --bw-aspect-ratio-avatar,
--bw-aspect-ratio-media, --bw-motion-scale, --bw-backdrop-blur,
--bw-focus-ring-offset-color, --bw-opacity-backdrop (superseded by
--bw-color-surface-overlay), --bw-font-size-scale-multiplier. Each ships
only when a real consumer names the need (YAGNI; ADR-054 §6).
Marketing tokens (v1.2.0, shipped). ADR-055 §6 named the likely-new design
surface for the opt-in brickwork.marketing sub-app, and the four tokens it
anticipated have landed as MINOR additions (ADR-054 §8), entered into their
matching sections above rather than kept as one-off marketing-scoped classes:
--bw-text-heading-display-* (a heading-display type role, §7.4),
--bw-component-content-max-width-marketing and
--bw-component-section-gap-marketing (§6.6), and
--bw-color-surface-marketing-tint (§4.1). Hero copy-column measures
(--bw-component-hero-copy-max-width,
--bw-component-hero-heading-max-width,
--bw-component-hero-lede-max-width, §6.6, icvoss/django-brickwork#640)
are the same shape: component tokens consumed by .bw-hero__*, so a brand
can widen the display heading without also widening the lede. Marketing-scoped
CSS classes are used only where a token would be the wrong shape (a layout
construct, not a themeable value, e.g. .bw-hero, .bw-pricing-tier). See
ADR-055 in the
umbrella (oss/docs/adrs/ADR-055-brickwork-marketing-kit-opt-in-subapp.md)
and the wider trajectory this opens
(oss/docs/plans/brickwork-templates-catalogue-direction.md).
11. Semver
Adding a token is MINOR. Renaming or removing a semantic/component token is
MAJOR (relaxed to direct minors inside 0.x with courtesy aliases). A
base-theme default-value change is MINOR (names and meanings are the
contract, values are brand-overridable) with two guardrails: a default change
that breaks WCAG AA contrast is a defect, gated by the axe contrast run in
light and dark; and changing a token's meaning while keeping its name is a
rename in disguise (MAJOR). Accessibility floors (--bw-focus-ring-width,
--bw-size-touch-target-min, the reduced-motion block) are never
brand-configurable.
0.11.0 tier re-grammar: the space, radius, icon-size, and component
tiers were renamed as a batch (--bw-space-*, --bw-radius-*,
--bw-component-icon-size-*, --bw-component-*), each old name kept as a
courtesy alias per ADR-054 section 7, exercising the "relaxed to direct
minors inside 0.x" allowance above while the grammar is still free to move
before 1.0 freezes the names.
12. Tailwind projection (tailwind-theme.css) [NEW 0.10.0]
Authority: ADR-054 section 4 (the hybrid: brickwork owns --bw-*,
projects into @theme), amended into the umbrella spec
(04-interfaces.md, 0.10.0) and verified by AC-BW-095. Through 0.9.0 the
shipped tailwind-theme.css was a placeholder (self-referential --bw-*
identity mappings, which sit in no Tailwind utility namespace and generate
no utilities); from 0.10.0 it is the real projection.
The fragment carries two @theme blocks, generated together but
serving different mechanisms (ADR-079).
The first is a plain @theme block bridging --breakpoint-sm/md/lg/xl
to literal values (section 6.8), so a consumer's own sm:/md:/lg:/xl:
utilities align with brickwork's breakpoints. It is deliberately not
inline: a media query cannot resolve var(), so viewport breakpoints must
be literals wherever Tailwind consumes them, and a consumer's own build
compiles them at build time like any other @theme value. This is the one
axis in the projection that is NOT live: overriding these tokens changes the
consumer's own Tailwind output on their next build, never at runtime.
The second is the original @theme inline block mapping the semantic
--bw-* contract into Tailwind 4's utility namespaces, so a consumer's
own utilities (bg-accent, rounded-md, shadow-3, text-heading-lg,
p-4) inherit the brand. Every mapped value is a var(--bw-*) reference,
never a literal, and inline means Tailwind bakes those references
straight into the generated utilities: switching data-theme or
data-bw-brand recolours consumer utilities live, with no rebuild.
12.1 Consumption
Consumer recipe (preferred): INTEGRATION.md §1.3.
Sync the fragment into the frontend tree with
manage.py sync_brickwork_projection so Vite can import it; the shell's
brickwork.css link already supplies the --bw-* values the projection
references at runtime.
Import the fragment in the consumer's entry CSS AFTER the line that pulls in Tailwind itself:
@import "tailwindcss";
@import "../src/brickwork-theme.css"; /* from sync_brickwork_projection */
If you are not using Vite and can resolve package static paths directly:
@import "tailwindcss";
@import "<static path>/brickwork/dist/tailwind-theme.css";
12.2 Namespace coverage
The table below covers the @theme inline semantic block only. The plain
@theme breakpoint block (12, ADR-079) is separate: 4 declarations,
--breakpoint-sm/md/lg/xl, from --bw-breakpoint-sm/md/lg/xl (section 6.8).
| Tailwind namespace | Mapped from | Count | Example utility |
|---|---|---|---|
--color-<name> |
every semantic --bw-color-<name> |
51 | bg-accent, text-fg-muted, border-danger-border |
--radius-<step> |
every --bw-radius-<step> (was --bw-size-radius-<step> through 0.10.0) |
7 | rounded-md, rounded-full |
--shadow-<level> |
the elevation ladder --bw-elevation-<level> |
6 | shadow-3 |
--text-<role> + --text-<role>--line-height |
the type roles --bw-text-<role>-size / -line-height |
13 roles (26 keys) | text-heading-lg, text-body-md |
--font-<name> |
the font stacks --bw-font-family-<name> |
3 | font-sans, font-display, font-mono |
--spacing |
the dynamic base --bw-space-1 (was --bw-size-space-1 through 0.10.0) |
1 | p-4, gap-2, mt-8 |
--default-font-family, --default-mono-font-family |
--bw-font-family-sans / -mono |
2 | preflight body and code text |
94 declarations in total, regenerated by npm run build:tokens from the
same token data as tokens.css, never hand-listed.
Collision posture: the semantic colour names ADD to Tailwind's palette
rather than replacing it (bg-accent joins bg-blue-500; a consumer may
still want a raw palette colour for one-off content). --font-sans and
--font-mono override Tailwind's same-named defaults deliberately, so the
brand stacks win; --radius-* steps land on Tailwind's own step names
with brickwork's values. The one global override is --spacing (12.3).
12.3 The --spacing base trick
The space scale is authored as Tailwind --spacing multiples of 0.25rem
(section 6.1), which is deliberate: projecting the SINGLE dynamic base
(--spacing: var(--bw-space-1), was --bw-size-space-1 through 0.10.0,
kept as a courtesy alias) makes every numeric spacing utility compute
through the brand token (p-4 becomes calc(var(--bw-space-1) * 4) under
@theme inline). Defaults are byte-for-byte Tailwind's rhythm (p-4 is
still 1rem), yet a brand or density override of --bw-space-1 rescales
the whole utility grid live. The raw --bw-space-<n> steps are NOT
projected individually: the dynamic base covers the entire numeric scale.
12.4 Exclusions (deliberate)
The projection is the semantic visual contract only, per the tier rule.
NOT projected: component-tier tokens (a consumer styles components through
the components, not by borrowing their internals as utilities), state
overlays, z-index (brickwork's layer ladder is not a consumer utility
scale), opacity, motion (durations, easings, composite transitions), the
focus ring geometry tokens --bw-focus-* (accessibility floors are never
restyled through utilities; the focus ring COLOUR is a semantic colour and
projects with that family as --color-focus-ring), density tokens (they
flow through the semantic tokens they feed), border
widths, icon sizes, and the raw primitive ramps and raw font scales (the
type roles and font stacks are the consumer surface). The --bw-* names
remain the authored contract; a Tailwind namespace change is a
projection-layer change, not a contract break (ADR-054 section 4).