Skip to main content

Documentation

  1. The package index
  2. django-brickwork
  3. Branding brickwork: theming a consuming app token-first

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):

  1. --bw-color-surface (the paper)
  2. --bw-color-fg (the ink)
  3. --bw-color-border
  4. --bw-color-accent
  5. --bw-color-danger
  6. --bw-color-success
  7. --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; a prefers-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

``` A one-line server-side or JS bridge from your existing dark signal to `data-theme` keeps brickwork's authored dark palette while honouring the user's preference. This is the recommended path; `data-theme` stays the contract. - **Density (`data-density="compact|comfortable|spacious"`)** scales spacing only, never colour. Set it on `` (or per-region) from a user preference. - **Direction (`dir="ltr|rtl"`)** is handled by logical CSS properties throughout; set the `dir` attribute and the browser resolves the rest. No token or stylesheet swap.

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 `