Branding brickwork: theming a consuming app token-first
brickwork's whole point is that you theme it by overriding --bw-* tokens, not
by reaching into its component classes. The product name for that capability is
Brickwork Theme (THEME.md, ADR-110): depth levels L1 (recolour)
through L4 (rhythm), analogous to authoring a Tailwind theme on published
dials, not a free CSS fork of .bw-*.
This guide covers the override mechanism: colour, typography, and the four axes (theme, density, direction). Since 0.3.0, base-theme derives its fine colour tokens live from a small load-bearing set, so L1 recolour is a handful of authored colour values, not a full palette. Deeper look (type voice, radius, elevation, spacing/density) is still token overrides at L2 to L4, not component forks. DESIGN.md is the authoritative token reference; brand-pack.md is the portable pack unit.
The mechanism: override tokens, don't touch classes
Put your brand's values on :root (or a scoped ancestor) in your own stylesheet,
loaded AFTER brickwork's tokens.css / compiled brickwork.css. Override only
the semantic and component tiers (--bw-color-*, --bw-font-*), never the
primitives.
/* your brand.css, loaded after brickwork's tokens.css */
:root {
--bw-color-accent: oklch(0.55 0.2 265); /* your brand blue */
--bw-font-family-sans: "Inter", system-ui, sans-serif;
--bw-font-family-display: "Poppins", var(--bw-font-family-sans);
--bw-font-family-mono: "JetBrains Mono", ui-monospace, monospace;
}
Because the derived tokens are live color-mix() expressions over the
load-bearing set, overriding --bw-color-accent alone recolours the whole
accent family (hover, subtle tint, focus ring, nav active state) in the
browser, with no rebuild.
The double-include trap: a second brickwork.css undoes your brand (brickwork#271)
Load order is the whole override mechanism. If brickwork.css appears twice
in the document, with your brand sheet between the copies, the second package
sheet wins the cascade and every --bw-* value silently reverts to package
defaults. There is no error: the page looks structurally fine and merely
"unbranded".
Symptom: brand colours (especially --bw-color-accent) match the package
base theme instead of your override sheet, even though your brand CSS is
present in DevTools.
Mechanism: CSS cascade by source order. A duplicated
{% static 'brickwork/dist/brickwork.css' %} after the brand link, including
one injected when an embedded shell fragment re-renders a full document
<head>, resets tokens.
One-line check: in the browser console,
[...document.styleSheets].filter(s => (s.href || '').includes('brickwork/dist/brickwork.css')).length
must be exactly 1. With DEBUG on, the shell also emits a console warning
when more than one matching <link rel="stylesheet"> is present (same
bw_js_registration_check block as the Alpine registration detector).
Verdict (brickwork#271): documentation plus a DEBUG-only runtime warning. An idempotent include tag was declined for now: the shell already emits the canonical link once; the failure is almost always a second consumer or embedded-document link, which a template tag on the first include cannot see.
The load-bearing minimum: seven tokens make an L1 recolour
Counting note (icvoss/django-brickwork#246). This section's "seven" is the
core colour set base-theme derives fine tokens from. The machine-readable
manifest (token-manifest.json, gated in docs/POSITIONING.md) records 10
load-bearing names in total, of which 8 are unconditional:
the seven below plus --bw-color-fg-on-accent (always verify at 4.5:1), with
two conditional entries (--bw-color-surface-inverse when ink is not the
inverse surface; --bw-color-info when a three-role brand collapses info onto
accent). A minimum light plus dark brand is 14 CSS lines (seven colours x
two themes); a complete brand that authors --bw-color-fg-on-accent in both
themes is 16 lines (eight unconditional x two themes).
base-theme derives everything else from seven load-bearing colour tokens per theme (DESIGN.md section 2 is the authoritative list):
--bw-color-surface(the paper)--bw-color-fg(the ink)--bw-color-border--bw-color-accent--bw-color-danger--bw-color-success--bw-color-warning
Plus, conditionally, --bw-color-surface-inverse when your ink is not the
inverse surface (it defaults to fg). Two more are authored rather than
derived where a formula cannot make the call for you: --bw-color-fg-on-accent
(verify at 4.5:1) and, for a three-role brand,
--bw-color-info: var(--bw-color-accent) collapses info onto the accent in one
line (base-theme ships a distinct cyan by default).
A complete light plus dark brand is about fourteen lines:
/* your brand.css, loaded after brickwork's tokens.css */
:root {
--bw-color-surface: oklch(0.99 0.003 90);
--bw-color-fg: oklch(0.24 0.02 270);
--bw-color-border: oklch(0.90 0.008 270);
--bw-color-accent: oklch(0.55 0.20 265);
--bw-color-danger: oklch(0.55 0.19 25);
--bw-color-success: oklch(0.56 0.14 150);
--bw-color-warning: oklch(0.68 0.15 75);
}
[data-theme="dark"] {
--bw-color-surface: oklch(0.22 0.015 270);
--bw-color-fg: oklch(0.93 0.01 90);
--bw-color-border: oklch(0.34 0.015 270);
--bw-color-accent: oklch(0.68 0.17 265);
--bw-color-danger: oklch(0.65 0.18 25);
--bw-color-success: oklch(0.66 0.13 150);
--bw-color-warning: oklch(0.72 0.14 80);
}
That is L1 recolour: base-theme derives the hover shades, subtle tints, muted foregrounds, status tiers, and component roles from these values.
The fg-on-accent trap: do not assume white (brickwork#35)
--bw-color-fg-on-accent is the text colour that sits on the accent (button
labels, active nav text, badges). It is authored per theme, not derived, because
base-theme cannot infer contrast for you. The trap: the safe text colour flips
depending on the accent's lightness, and a brand whose dark-theme accent is a
light colour is a common case that inverts the intuition. "White on accent" is
not a safe default.
Worked failure, from a real pilot: a brand ran a light theme with a deep
aubergine accent and a dark theme whose accent was a light pink. White
fg-on-accent in both was the reflex, and it was wrong in dark. For the
example oklch values below (ratios are what render_brand_css's own contrast
check reports, so the doc and the emitter agree):
| theme | accent | white fg-on-accent |
dark ink fg-on-accent |
correct value |
|---|---|---|---|---|
| light | deep aubergine | 8.72:1 (pass) | 1.84:1 (fail) | white |
| dark | light pink | 1.52:1 (fail) | 10.55:1 (pass) | dark ink |
So the same token needs opposite values in the two themes. Author it per theme and verify each at 4.5:1 against its own accent:
:root {
--bw-color-accent: oklch(0.42 0.11 330); /* deep aubergine */
--bw-color-fg-on-accent: oklch(0.99 0 0); /* white: 8.72:1, passes */
}
[data-theme="dark"] {
--bw-color-accent: oklch(0.86 0.06 350); /* light pink */
--bw-color-fg-on-accent: oklch(0.24 0.02 330); /* dark ink: 10.55:1, passes
(white here is 1.52:1) */
}
This is the single most likely token for a distinct-brand consumer to ship broken, precisely because it is the one the derivation cannot catch for you. Measure both themes; never copy the light value into dark on reflex.
Typography (the --bw-font-* tokens)
The shell and components consume --bw-font-family-sans (body) and
--bw-font-family-display (headings), plus a size/weight/line-height scale
(--bw-font-size-*, --bw-font-weight-*, --bw-font-line-height-*). Override
the family tokens to give brickwork your typeface without touching .bw-body or
.bw-page-header__title. The default is a neutral system-font stack so an
unbranded install still looks intentional.
Colour: what base-theme now derives for you
brickwork's semantic vocabulary is intentionally richer than a minimal brand (a surface scale, five tiers per status hue, state overlays). Before 0.3.0 a lean brand had to hand-tune all of it; base-theme now derives it from the load-bearing set:
| brickwork tokens | how base-theme derives them |
|---|---|
the surface scale (-sunken / -raised / -overlay) |
derived from surface: sunken mixes a touch darker, raised differentiates by shadow in light and by lightness in dark, overlay is a scrim over content. The depth cues the components rely on come for free. |
--bw-color-surface-inverse |
defaults to var(--bw-color-fg) (your ink), used for inverted chips/badges. Author it only when your ink is not the inverse surface. |
the muted foregrounds (-fg-muted, -fg-subtle, -icon-muted) |
mixed from fg toward surface, with theme-tuned constants. |
-accent-hover and -accent-subtle |
shaded and tinted from accent. |
--bw-color-focus-ring |
base theme derives a default from accent; render_brand_css() emits a separately verified OKLCH value for every tenant accent override. |
the status tiers (X-subtle, X-strong, X-fg for danger/success/warning/info) |
derived per intent hue, so an alert, badge, or toast never invents a value. X-fg (the text painted on X-subtle, and on plain surface for .bw-field__error/the opt-in .bw-trend--good/.bw-trend--bad sentiment modifiers (icvoss/django-brickwork#388; the .bw-trend--up/--down/--flat direction modifiers use --bw-color-fg-muted instead, since direction is not a judgement)/the account-menu's danger item) tracks --bw-color-surface (brickwork#289): darkening your surface darkens the status text too, so overriding surface alone does not silently drop status contrast below AA. render_brand_css() checks BOTH backgrounds X-fg is painted on (token-manifest.json's contrastPairs) even when neither X-fg, X-subtle, nor surface is itself an override, and it re-resolves the check against a status base or --bw-color-status-fg-ink override too, so any of the three raises loudly on a definitive failure. X-fg deliberately does NOT track --bw-color-fg: it mixes toward a separate --bw-color-status-fg-ink token instead, so authoring the ordinary fg override above (the load-bearing seven) has no effect on status-text contrast at all. What render_brand_css() cannot check: a literal you author directly for --bw-color-X-fg or --bw-color-X-subtle itself, which bypasses the derivation the check verifies; that needs its own verification, the same way fg-on-accent does. |
Hand-tuning the derived families is the override path, not the primary path: a
flat value you set wins over the derivation through the CSS cascade. The focus
ring is the accessibility exception: do not override it through
render_brand_css(). Supply concrete oklch() values for an accent and any
surface it changes, and the service emits the verified ring value. The full
derivation table, with the exact color-mix() constants per theme, is
DESIGN.md section 4.
Rule of thumb: let tokens that are shades of the same idea derive (the surface scale, the tint tiers); avoid collapsing tokens that carry distinct meaning (the status hues), because the components use them as semantic signals, not decoration. Almost every brand has a red and a green intent even if not in the logo palette; author them rather than reusing the accent, so destructive and positive actions read correctly.
The retint envelope for categorical series (chroma x0.7)
One brand override carries a guarantee the others do not, and that guarantee
has a stated limit. The chart series palette (--bw-color-chart-1 through
-chart-8) promises that any two series stay visually distinguishable from
each other, including after you retint them (ADR-082). Distinguishability
between eight colours is a property of the whole set, so unlike a contrast
check it can be destroyed by a change that leaves every individual colour
looking fine.
The limit: brickwork guarantees the series stay distinguishable while a
uniform chroma scale of the shipped palette keeps at least 70 percent of
shipped chroma (each series' chroma multiplied by the same factor; lightness
and hue held). That is the transformation tests/test_chart_series_contract.py
models and the only retint shape ADR-082's scoped legs cover
(icvoss/django-brickwork#302). Below x0.7, the guarantee no longer holds and
you should verify your own palette.
Free overrides of --bw-color-chart-1 through -chart-8 are outside that
guarantee. render_brand_css accepts arbitrary series colours keyed by
token name; nothing forces those eight values to be a uniform chroma reduction
of the shipped set. Rotating one series' hue, changing lightness, or replacing
a subset of series are all possible and all unguarded for separation. An
optional warning when a free retint falls outside the envelope remains deferred
(icvoss/django-brickwork#291).
This is a statement about what the package guarantees, not a prohibition. You may retint however your brand requires. But desaturating the series heavily compresses exactly the dimension their distinctness is carried on: as chroma falls, the hue differences between series shrink with it while lightness differences do not, so a heavily desaturated palette converges toward a set of greys separated only by lightness. Measured across candidate palettes, no eight-series palette can hold the separation threshold once chroma falls to 60 percent of shipped, which is why the guaranteed envelope stops above that rather than at the point where colours become individually unattractive.
If you need a heavily desaturated chart palette, the supported route is to author the eight series yourself with lightness deliberately spread, rather than scaling the shipped values down uniformly: lightness separation survives desaturation and hue separation does not.
The envelope is stated here because an unstated one goes vacuous exactly when it matters. Without it, a consumer retints past the limit, series collide, and nothing in the documentation says whose expectation was wrong.
The four axes
- Theme (
data-theme="light|dark") is the required dark-mode mechanism, and it is deliberate. brickwork's load-bearing dark values are authored, not derived (BR-BW-TOK-002): dark is never computed from light, so dark mode is a designed surface, not an inverted one. Within a theme, the fine tokens derive from that theme's load-bearing set with dark-tuned constants, and a brand may override any of them, derived or authored. That authored-per-brand model, and the way theme composes independently with density and direction, needs an attribute the CSS can switch on; aprefers-color-scheme-only approach cannot express an authored dark palette that also composes with the other axes.
If your app drives dark mode with prefers-color-scheme or a Tailwind
dark: class, bridge it to data-theme with a few lines rather than
fighting it:
css
/* follow the OS preference, still using brickwork's authored dark values */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) { /* only when the app hasn't set an explicit theme */
/* re-point the semantic colours at brickwork's dark block, or simply: */
}
}
```html
Dynamic theming: per-request axes and per-tenant runtime brand (brickwork#36)
The four axes and the live oklab derivation are not just build-time knobs; both can be driven per request. Three recipes unlock capabilities the token architecture already supports. ### Recipe 1: per-user density / theme / direction toggle Thread a per-user (or per-request) theme, density, and direction through a `theme_resolver` and the shipped context processor maps them onto the `bw_*` shell vars for you. Point `BRICKWORK_THEME_RESOLVER` at a dotted path:# yourapp/theming.py
def theme_resolver(request):
"""Return a partial ThemeAttributes dict; only the keys you set are applied."""
prefs = getattr(request.user, "ui_prefs", None)
if prefs is None:
return {}
return {
"theme": prefs.theme, # "light" | "dark"
"density": prefs.density, # "compact" | "comfortable" | "spacious"
"dir": prefs.direction, # "ltr" | "rtl"
}
# settings.py
BRICKWORK_THEME_RESOLVER = "yourapp.theming.theme_resolver"
A partial dict is fine: only the keys you return override the defaults. With
`brickwork.context_processors.theme` installed (see
[INTEGRATION.md](INTEGRATION.md) section 3), the resolved attributes land on the
shell's `` element and the user's preference is live per request, with no
rebuild and no per-user stylesheet. Density scales spacing only; direction is
resolved by logical CSS properties; theme swaps to brickwork's authored dark
values.
### Recipe 2: per-tenant runtime brand-token injection (the multi-tenant prize)
Because the derived colour family is live `color-mix()` over the ~7 load-bearing
tokens, a multi-tenant SaaS can inject *one tenant's* load-bearing set per request
and the whole family recolours in-browser, no per-tenant build. The supported
primitive is the emitter service (brickwork#40, shipped 0.11.0):
from brickwork.services.tokens import render_brand_css
def tenant_brand_style(request):
tenant = request.tenant
css = render_brand_css(
light={
"color-surface": tenant.surface,
"color-fg": tenant.fg,
"color-border": tenant.border,
"color-accent": tenant.accent,
"color-danger": tenant.danger,
"color-success": tenant.success,
"color-warning": tenant.warning,
"color-fg-on-accent": tenant.fg_on_accent,
},
dark=tenant.dark_tokens or None, # optional; omit for light-only brands
validate=True, # reject unknown names, check fg-on-accent
)
return css # a ready :root { ... } [data-theme="dark"] { ... } block
Emit that block in a per-request `