Three contrast fixes, with options

"The Forecast" went live yesterday. The accessibility pass that followed found three contrast failures, each measured against the background it actually renders on. Nothing on this page is live, and nothing here should be until you have looked at it and picked an option.

Each failure gets two or three candidate fixes below. Every ratio is computed for both themes, with the maths shown, not eyeballed. Each option carries one paragraph on what it costs and what it keeps.

The fence stays in force. Band colours mean the score. The sky owns page chrome. --chart-1/2/3 own line identity. Nothing else gets a colour. Every option below moves an existing token along its own hue, changes a chip's fill or size instead of its hue, or reuses the sky, the token that already owns chrome. None of them give the projected chip a band colour, and none of them touch assertProjectedRender() in build/prerender.js, the function that would fail the build if one did.

1. The band-word chip — --mixed and --zero

The band word is the second signal for a score. It has to work even when a reader cannot tell amber from clay. Today it fails that job in two places. --mixed reads 4.21:1 against --panel in light theme, short of the 4.5:1 bar for text. --zero is worse: 3.31 to 3.83:1 in light theme, 3.98 to 4.68:1 in dark, failing on panel and card in both themes.

Light theme

--mixedpaper / panel / card
Today
#a06400
mixed4.59--mixed
mixed4.21--mixed
mixed4.86--mixed
A · retune the token
#975e00
mixed5.06--mixed
mixed4.64--mixed
mixed5.36--mixed
B · fill the chip
#ffeccc fill, #29241c text
mixed13.29--mixed
mixed13.29--mixed
mixed13.29--mixed
C · large text, same hex
#a06400 (unchanged, 1.167rem+)
mixed4.59--mixed
mixed4.21--mixed
mixed4.86--mixed
--zeropaper / panel / card
Today
#8a8177
not yet3.61--zero
not yet3.31--zero
not yet3.83--zero
A · retune the token
#716a62
not yet5.03--zero
not yet4.61--zero
not yet5.33--zero
B · fill the chip
#e7e6e4 fill, #29241c text
not yet12.35--zero
not yet12.35--zero
not yet12.35--zero
C · large text, same hex
#8a8177 (unchanged, 1.167rem+)
not yet3.61--zero
not yet3.31--zero
not yet3.83--zero

Dark theme

--mixedpaper / panel / card
Today
#e0a53f
mixed8.17--mixed
mixed7.49--mixed
mixed6.95--mixed
A · retune the token
#e0a53f (unchanged)
mixed8.17--mixed
mixed7.49--mixed
mixed6.95--mixed
B · fill the chip
#4f370d fill, #f2e9db text
mixed9.26--mixed
mixed9.26--mixed
mixed9.26--mixed
C · large text, same hex
#e0a53f (unchanged, 1.167rem+)
mixed8.17--mixed
mixed7.49--mixed
mixed6.95--mixed
--zeropaper / panel / card
Today
#8d8172
not yet4.68--zero
not yet4.29--zero
not yet3.98--zero
A · retune the token
#978c7f
not yet5.41--zero
not yet4.96--zero
not yet4.60--zero
B · fill the chip
#332e29 fill, #f2e9db text
not yet11.16--zero
not yet11.16--zero
not yet11.16--zero
C · large text, same hex
#8d8172 (unchanged, 1.167rem+)
not yet4.68--zero
not yet4.29--zero
not yet3.98--zero

A · retune the two tokens

The smallest move on the table. Darken --mixed and --zero along their own hue and saturation until they clear 4.5:1 on the worst surface, with headroom to about 4.6. --mixed moves from #a06400 to #975e00, light theme only, since it already clears 4.5:1 everywhere in dark. --zero moves in both themes: #8a8177 to #716a62 in light, #8d8172 to #978c7f in dark. --good and --long stay exactly where they are. I would want --zero's new dark value checked next to the other three bands before it ships, since it lands close to --muted-strong, and the four-band ramp needs to keep reading as a brightness gradient, not just a hue change.

B · fill the chip

Instead of moving the hue, give the chip a solid fill in the band colour and swap the text to --ink. Contrast stops being a live constraint: 9 to 13 times what it needs, against any of these tints. This is the biggest visual change of the three. The identity signal moves from coloured text to a coloured fill, and consistency probably means restyling all four band chips at once, not just the two that are actually failing. The fill is also weak on its own terms: it measures only 1.0 to 1.5:1 against the surrounding panel, so without the border doing real work, the tint is close to invisible against the page it sits on.

C · make it large text

WCAG grades bold text at 14pt (18.67px) or larger against a 3:1 bar, not 4.5:1. Every current band value already clears 3:1 — --zero at its lowest is 3.31:1. So this option changes no colour at all. It takes the chip from .72rem (11.52px) to at least 1.167rem (18.67px) and leaves every hex value alone. The cost is size, not colour: a chip 62% larger next to every score, on every card, table row and roadmap step on the site, and every layout that assumed today's footprint needs a second look.

2. The projected chip — --proj

--proj reads 3.77:1 against --proj-bg in light theme. Dark theme already clears 4.5:1, at 5.75:1. This chip is the one place the fence is written into code. assertProjectedRender() in build/prerender.js fails the build if a projected number ever carries a band class, a [data-band], or a var(--band) reference, because green, amber and clay mean measured, and nobody has measured a fleet that has not been built yet. None of the three options below touch that rule. They move --proj and --proj-bg, and neither is a band token.

Light theme

--proj--proj-bg (or host surface)
Today
#7c7568 on #efe9dc
projected3.77--proj / --proj-bg
A · darken text only
#6e675c on #efe9dc (bg unchanged)
projected4.62--proj / --proj-bg
B · lighten the wash, nudge the text
#777064 on #faf8f4
projected4.62--proj / --proj-bg
C · drop the tint (near-miss)
#7c7568 (unchanged) on host surface
projected4.31--proj on paper
projected3.95--proj on panel
projected4.56--proj on card

Dark theme

--proj--proj-bg
Today (already passes)
#a99e8e on #2b251d
projected5.75--proj / --proj-bg

Dark theme already clears 4.5:1. None of the three options change it.

A · darken text only

The same approach as the band chip's option A. Hold --proj-bg still and darken --proj until it clears 4.5:1. It lands at 4.62 with a little headroom, moving from #7c7568 to #6e675c. Dark theme is untouched. The projected label gets visibly heavier against its own soft background, and that softness is the cue that a number has not happened yet. I would expect this to be the change a returning reader notices first.

B · lighten the wash, nudge the text

Puts most of the work on the background instead of the text. Lighten --proj-bg toward white and nudge --proj only a little: the text moves 1.76 lightness points against 5.1 for option A. --proj-bg also fills the roadmap ring and the progress-tape background, so lightening it moves those surfaces too. By the time the ratio clears 4.6:1, the wash itself sits only a few lightness points short of --card. The muted note that marks a chip as not-yet-measured nearly disappears.

C · drop the tint (near-miss)

The cheapest change in code: delete the background rule and let --proj sit on whatever surface hosts it. It clears 4.5:1 against --card (4.56:1). It does not clear 4.5:1 against --paper (4.31:1) or --panel (3.95:1). I am listing it because it is on the table, not because it works: it fixes the failure on one of the three surfaces the chip actually renders on and leaves it broken on the other two.

3. The form-control border — --line

--line is the only border on every text input, select and textarea. WCAG 1.4.11 wants 3:1 for a control's boundary. Decorative dividers are exempt, so this is a UI-component failure, not a text failure. Today --line measures 1.20 to 1.43:1 across both themes, against all four surfaces it can sit on: --paper, --panel, --card, --field. It is the easiest of the three to fix cleanly. --line on a form control is not a band, is not the sky, and is not a chart line. It is the one token here with no fence at all.

Light theme

--linepaper / panel / card / field
Today
#e3daca
1.31--line on paper
1.20--line on panel
1.39--line on card
1.39--line on field
A · scoped --field-border
#91794d
3.93--line on paper
3.60--line on panel
4.16--line on card
4.16--line on field
B · reuse --sky at full strength
#2d5a7d
6.90--line on paper
6.33--line on panel
7.32--line on card
7.32--line on field
C · muted, desaturated sky
#475763
7.05--line on paper
6.46--line on panel
7.47--line on card
7.47--line on field

Dark theme

--linepaper / panel / card / field
Today
#3a332a
1.43--line on paper
1.31--line on panel
1.22--line on card
1.31--line on field
A · scoped --field-border
#8a7964
4.24--line on paper
3.89--line on panel
3.61--line on card
3.89--line on field
B · reuse --sky at full strength
#9dc2dc
9.49--line on paper
8.70--line on panel
8.07--line on card
8.70--line on field
C · muted, desaturated sky
#b1bec8
9.39--line on paper
8.61--line on panel
7.99--line on card
8.61--line on field

A · a scoped --field-border token

A new token, scoped to form controls only, kept on --line's own hue and darkened until the worst surface clears 3:1 with headroom (--panel in light, --card in dark). Light moves to #91794d, dark to #8a7964. Every decorative divider, card edge and ghost-button border keeps using --line exactly as it does today. The cost: a second token that means close to the same thing as the first. Two greys called "the border colour" is a real maintenance risk if a future edit touches one and forgets the other.

B · reuse --sky at full strength

--sky already exists, already owns page chrome, and is already the colour on a focused input: :focus-visible and .srch input:focus both use it. Extending it to the resting border adds no fourth owner and clears 3:1 by more than double, in both themes. The cost is specific. If the resting border and the focus border are the same colour, focus stops reading as a state change, and that trades this failure for WCAG 2.4.7, focus visible.

C · a muted, desaturated sky

Keeps --sky's hue family, still chrome, still zero new owners in the fenced sense, but cuts saturation to about a third and retunes lightness. The result reads as a muted steel-blue rather than the vivid link-blue on the focus ring. It clears 3:1 by the same wide margin as option B, without option B's collision. The cost is one more derived value to define and document, and the same scrutiny a scoped token would get over whether it is really not a new owner.

Which I would pick

Failure 1, option A. Retune the two tokens. It is the smallest move, keeps the outline chip exactly as approved, and touches only the two hex values that are actually failing.

Failure 2, option A. Darken the text. Same reasoning as failure 1, and it is the only one of the three that clears 4.5:1 on the surface the chip actually uses without also moving --proj-bg's other jobs.

Failure 3, option A. The scoped --field-border token. It avoids the focus collision in options B and C and leaves --line's decorative meaning exactly where it was.

What stays untouched