Skip to main content

Documentation

  1. The package index
  2. django-brickwork
  3. brickwork design tokens: the complete reference

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 give oklch(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 to unset rather than to the literal. Since every needed derivation is expressible in color-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 in tokens.css. A verification test recomputes each color-mix() linearly in oklch and asserts the result stays within a small tolerance of the $value baseline (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 $value baseline 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-content h2 and .bw-footer both set letter-spacing to the normal tracking token; both resolve to 0em; both read as redundant. Removing either lets a wrapper's tracking through, measured at 4.8px under a letter-spacing: 0.3em ancestor.
  • Modifier reset. .bw-data-table__th--label sets letter-spacing: var(--bw-font-tracking-normal) to cancel the tracking its own base class .bw-data-table__th sets 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-prose floor and are legal on every shell, including the docs shell. .bw-callout is the first example (icvoss/django-brickwork#452): it lives in frontend/src/components.css, adjacent to .bw-prose, rather than in frontend/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-callout in components.css too.

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; wider pairs 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:

  1. A typographic reason. heading-display, heading-2xl, heading-xl and heading-section carry tight, because large display type needs negative tracking. overline carries wider, because uppercase overlines read tighter than mixed case.
  2. Holding 0em against inheritance. heading-lg and caption carry tracking: normal so their consuming rules (shell.css's .bw-content h2 and .bw-footer rules) can set an explicit 0em that blocks an ancestor's letter-spacing from 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 a 0.3em ancestor. 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).

Documentation