Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ADR-0087: A semantic fill brings its own foreground

  • Status: Accepted
  • Date: 2026-08-18
  • Relates to: docs/ARCHITECTURE.md §10, docs/design-system.md §1.2 §1.5 §3, docs/core-widgets.md §3

Context

badge is the ninth entry in §3’s control table and the first that is not a control: “count/status chip, typically composed inside stack. Semantics: text.” No focus, no value, no keyboard map, no states. On the face of it the smallest widget in the catalog after spinner.

It is also the first widget that is allowed to use colour. §1.2 is strict about the aurora hues:

Aurora hues (nord11–15) appear only with semantic meaning (danger/warning/success/info, chart series) or in expressive surfaces — never as decoration on controls.

Every widget so far has obeyed the never half. A status chip is the first one whose entire job is the only half, so badge.danger and badge.success are the point of the widget rather than a skin on it.

Which puts it straight into the other half of §1.2:

Every text/surface pair meets WCAG 4.5:1 (3:1 for large text ≥ 20px). Contrast is validated in CI against both themes.

Nothing validated anything. There was no contrast check in the repository at all, and the sentence had been true-by-assertion since the design system was written.

The moment a chip is filled with a semantic hue, the theme’s text token is wrong. On the dark theme --gb-text is --nord6, a near-white, and:

fillwith --nord6with --nord0
--gb-warning (--nord13)1.358.00
--gb-success (--nord14)1.776.13
--gb-info (--nord9)2.344.64
--gb-danger (--nord11)3.553.05

Three of the four hues need the opposite end of the palette from the one the theme is built on. And --gb-danger needs something that is not in the palette at all: it carries no legible small text in either direction.

Writing the check turned up the same defect in shipped code. Seven button colour pairs are below the floor — button.danger in both themes and button.primary on light — and --gb-button-danger-text: var(--nord6) on --gb-button-danger-bg: var(--nord11) is the 3.55:1 above. It has been there since the first control shipped.

Decision

A filled element’s foreground is a property of its fill, not of its theme. Each badge variant pins its own --gb-badge-*-text beside its --gb-badge-*-bg, and for the three light aurora hues that token is --nord0 on both themes — including the dark one, where every other text token is --nord6. §1.2’s palette is theme-invariant, so a pairing that works for --nord13 works for it everywhere; the theme does not get a vote.

Where no palette entry works, the fill is derived until one does. --gb-badge-danger-bg is #a0414a — --nord11 taken down in lightness until it clears 4.5:1 under --nord6 (it reaches 5.44) — and on the light theme --gb-badge-accent-bg is #4a678b, which is --nord10 taken down the same way and is the value --gb-button-primary-bg-active already derives. Both are written beside the palette entry they came from, the convention --gb-button-primary-bg-hover already uses.

§1.2’s CI validation now exists, as ContrastTest. It resolves every text-on-fill pair the toolkit ships through the real cascade — base layer, theme, the same StyleResolver a window uses — and computes WCAG 2.1’s ratio from the background and color that come out. The seven failing button pairs are in a KNOWN_FAILURES list that is asserted exactly: a pair that gets fixed fails the test until it is removed, and a pair that newly breaks fails it immediately.

And §3’s table gained a badge row before any number was written into controls.css: height 20, padding-x 8, radius full, caption. Principle 3 — “if a screen needs a value that isn’t a token, the system gets extended deliberately”.

Alternatives considered

Let badge inherit --gb-text like everything else. This is what the cascade does for free and it is what a first draft looks like. It produces white text on a pale yellow chip at 1.35:1 — text that is not merely hard to read but genuinely invisible at 11px on a laptop screen. §1.2’s floor is not advisory.

Make the semantic variants tinted rather than filled — the hue as text and border on the theme’s own surface, no coloured fill. This is a common badge style and it dodges the whole question. It does not survive the numbers: --nord11 as text on --gb-bg is 3.05:1, so the variant that most needs to be readable is the one that fails hardest. Tinting moves the problem, it does not solve it.

Ship danger as --nord11 anyway and note the exception. The chip that says a service is down is the one chip a user must be able to read. An accessibility floor with an exception carved out for the highest-urgency case is not a floor.

Instance the whole palette against a contrast solver at theme-load time, so any fill gets a computed foreground. It removes the hand-written token pairs and would generalise to application themes. Rejected as premature and as a determinism risk: §1.1’s fifth principle is that the same markup renders identically on every machine, golden images are the arbiter, and a colour arrived at by an algorithm at runtime is a colour that changes when the algorithm is tuned. Two derived hex values reviewed once are cheaper to trust. The seam is open — the tokens are the only thing widgets consume, so a solver that emitted them later would change nothing above.

Fix the seven button pairs in this change. It is the same defect and the fix is known — each ramp’s :active end already passes, so the resting fills move one step darker. Rejected here because it recolours the most visible control in the toolkit and moves every button golden, and a change that adds a badge should not also be the change that repaints button.primary. It is recorded in status.md and in KNOWN_FAILURES, where it is loud rather than forgotten.

Scope ContrastTest to badges, so it ships green. This is ADR-0082’s trap verbatim: a check narrowed to what already passes is not a check. The sweep covers everything and the exemptions are enumerated.

Consequences

§1.2 is enforced for the first time, and it found something on the first run. Any future control that puts text on a fill is checked at the moment it is written, in both themes, with no image to eyeball.

A theme is now a slightly harder thing to write. An application supplying its own theme must supply eleven --gb-badge-* tokens, and getting them wrong is a legibility bug rather than a visual one. Mitigated by the check: an application that runs ContrastTest’s arithmetic over its own theme gets the same answer. Not mitigated by the toolkit — nothing validates a third-party theme, and that is a real gap.

Two colours in the theme files are not palette entries. #a0414a and #4a678b are derived values, and a future Nord revision would have to re-derive them. They are commented with what they came from and what they measure, which is the most that can be done without a solver.

The KNOWN_FAILURES list is a debt that announces itself. It cannot rot silently — it is asserted as an exact set — but it is still seven shipped pairs below an accessibility floor, in the toolkit’s most-used control, and that is the honest cost of not fixing them here.

badge is in the no-shrink list despite not being a control. The list’s stated rule is “a control’s metrics are fixed”; a badge is there for a different reason, that its whole content is two or three glyphs and a chip that gave width back would ellipse 99 into 9 — a clipped label is a nuisance, a clipped count is a wrong number.

There is still no minimum width, so a single-digit chip is a stadium rather than the circle a badge usually is. §8’s subset has no min-width at all; badge-digits.png records it rather than a comment claiming it, and it joins the existing open question about minimum sizes.

Nothing about a badge animates, and that is §3.1 being followed rather than an omission: it has no row, and the preamble says anything not listed does not animate. BadgeTest asserts the empty transition set, so a transition added to the shared control rules cannot reach a chip by accident.