feat(ui): every control state is checked for readability, not trusted

The palette has two light accents and one very dark one, and a control
changes its FILL on hover and press. Paper text that reads at 18:1 on the
resting near-black chip inverts to paper-on-yellow the moment the pointer
arrives, which is 1.1:1 — invisible. That is the standard way a stylised UI
becomes unreadable, and it was live on the OptionButton dropdown that every
settings row uses: PopupMenu draws its papaya hover fill but keeps
`font_color` unless `font_hover_color` is set, and it was not set.

So the label now follows the fill. `ink_for(fill)` picks the legible glyph
colour by contrast ratio, and every state's text, icon and outline is derived
from its own fill through it — including the states nobody remembers exist:
`hover_pressed` on a toggle (a CheckButton read as OFF while you touched it),
icon colours on a CheckBox that is all icon, and the list hover that used to
be the same papaya as list SELECTION, so the row you pointed at looked like
the row you had chosen.

debug/ui_contrast_check.gd interrogates the BUILT theme rather than the
palette — a table compared against itself agrees by construction and catches
nothing — and fails below the WCAG floor. It found the PopupMenu gap, a Tree
hover asking for a `font_hovered_color` Godot does not have, and the health
bar's readout at 2.4:1 over its own fill.

That last one is not fixable as a colour pair: a centred readout straddles a
hot papaya fill and a near-black trough, and no single colour beats both. What
carries it is the heavy ink outline this theme puts on every glyph, which is
its first stated rule and the same mechanism that keeps menu text legible
straight over the 3D scene. The check models that as a fallback route —
outline vs backdrop 3:1, glyph vs outline 4.5:1, at least 4 px — granted only
where the backdrop genuinely varies, and substituting two ratios for one
rather than waiving the requirement.

Disabled text moves from 3.2:1 to 5.2:1 on the way past. A greyed-out
"Start Match" is information; an illegible smudge is not.

Also styled, because the theme had simply never mentioned them and Godot's
defaults are grey-on-grey: scrollbars, Tree, SpinBox, ProgressBar, tooltips,
LineEdit read-only and selected text.

108 pairs checked, all passing.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-28 02:39:25 -04:00
co-authored by Claude Opus 5
parent f0b0d19847
commit 414026f001
2 changed files with 479 additions and 19 deletions
+229
View File
@@ -0,0 +1,229 @@
extends SceneTree
## Reads the BUILT theme and fails if any state puts text on a fill it cannot be
## read against.
##
## godot --headless --path . -s res://debug/ui_contrast_check.gd
##
## The point is that this interrogates `UITheme.build()` rather than the palette
## constants. A table of colours checked against itself agrees by construction
## and catches nothing; what goes wrong in practice is a stylebox whose fill was
## changed without the matching `font_*_color`, or a state Godot draws that
## nobody remembered exists — `hover_pressed` on a toggle, `font_hover_color` on
## a PopupMenu. Those are exactly what this finds, because it asks the theme what
## it will actually draw.
##
## WCAG 2.1 floors: 4.5:1 for body text, 3:1 for text at or above ~24 px.
##
## ── Two ways text can be legible, and only one of them is a colour pair ───────
##
## A label on a BUTTON sits on a solid chip. The fill is right behind the glyphs
## and nothing else is, so the only thing that can make it readable is the pair,
## and the fix when it fails is to invert the label with the fill. Those are
## checked as `SOLID`.
##
## A label on a PROGRESS BAR does not have one backdrop. It straddles the filled
## part and the empty part, and no single text colour can beat both a hot papaya
## and a near-black at once — the pair is unfixable by construction. What carries
## it is the heavy ink outline the theme puts on every glyph, which is the same
## mechanism that keeps menu text readable straight over the 3D scene, and it is
## the theme's first stated rule for exactly this reason.
##
## So that case is checked as `OUTLINED`, against what actually does the work:
##
## outline vs fill >= 3.0 the outline has to separate from the backdrop
## text vs outline >= 4.5 and the glyph has to separate from its outline
## outline_size >= 4 px thin enough and there is no outline to read
##
## This is a narrower allowance than it looks. It is only granted where the
## backdrop genuinely varies, and it substitutes two ratios for one rather than
## waiving the requirement — an unoutlined label over a bar still fails.
##
## Semi-transparent fills (PANEL, DEAD_FILL) are measured as if opaque. They are
## drawn over the dark 3D scene or over a darker panel, so the opaque reading is
## the pessimistic one for paper-on-dark and the accurate one for ink-on-light.
const FLOOR_BODY := 4.5
const FLOOR_LARGE := 3.0
## An outline thinner than this is a hairline, not a backdrop.
const MIN_OUTLINE := 4
const SOLID := "solid"
const OUTLINED := "outlined"
## class -> [[stylebox, font colour, large?, mode], ...]
##
## Only the pairs Godot really composites. `focus` is drawn OVER `normal` rather
## than instead of it, so it is checked against its own fill (which is the same
## fill) and its own font colour.
##
## Tree has no `font_hovered_color` in Godot 4 — a hovered row keeps `font_color`
## and a hovered-selected one keeps `font_selected_color` — so its hover fills
## are checked against the colours that will really be drawn on them. Asking for
## a colour the control does not have is itself reported, which is how this was
## found.
const PAIRS := {
"Button": [
["normal", "font_color", false, SOLID],
["hover", "font_hover_color", false, SOLID],
["pressed", "font_pressed_color", false, SOLID],
["hover_pressed", "font_hover_pressed_color", false, SOLID],
["focus", "font_focus_color", false, SOLID],
["disabled", "font_disabled_color", false, SOLID],
],
"ItemList": [
["panel", "font_color", false, SOLID],
["hovered", "font_hovered_color", false, SOLID],
["selected", "font_selected_color", false, SOLID],
["selected_focus", "font_selected_color", false, SOLID],
["hovered_selected", "font_selected_color", false, SOLID],
],
"Tree": [
["panel", "font_color", false, SOLID],
["hovered", "font_color", false, SOLID],
["selected", "font_selected_color", false, SOLID],
["hovered_selected", "font_selected_color", false, SOLID],
],
"PopupMenu": [
["panel", "font_color", false, SOLID],
["panel", "font_disabled_color", false, SOLID],
["panel", "font_accelerator_color", false, SOLID],
["hover", "font_hover_color", false, SOLID],
],
"TabContainer": [
["tab_selected", "font_selected_color", false, SOLID],
["tab_unselected", "font_unselected_color", false, SOLID],
["tab_hovered", "font_hovered_color", false, SOLID],
["tab_disabled", "font_disabled_color", false, SOLID],
],
"LineEdit": [
["normal", "font_color", false, SOLID],
["normal", "font_placeholder_color", false, SOLID],
["normal", "selection_color:font_selected_color", false, SOLID],
["read_only", "font_uneditable_color", false, SOLID],
],
# The readout straddles both halves of the bar — see the note above.
"ProgressBar": [
["background", "font_color", false, OUTLINED],
["fill", "font_color", false, OUTLINED],
],
}
## Every button-ish theme type gets Button's pair list, since `_button_look`
## gives them all the same treatment.
const BUTTON_LIKE := ["OptionButton", "MenuButton", "CheckBox", "CheckButton",
"LinkButton"]
func _init() -> void:
var t: Theme = UITheme.build()
var fails: Array = []
var checked := 0
var classes := PAIRS.duplicate()
for cls in BUTTON_LIKE:
classes[cls] = PAIRS["Button"]
for cls in classes:
for pair in classes[cls]:
var sb_name: String = pair[0]
var fg_name: String = pair[1]
var large: bool = pair[2]
var mode: String = pair[3]
if not _has_flat(t, cls, sb_name):
fails.append("%s: no StyleBoxFlat '%s'" % [cls, sb_name])
continue
var bg: Color = _fill_of(t, cls, sb_name)
# "a:b" means the fill comes from colour `a` rather than a stylebox —
# LineEdit's text selection paints a colour, not a box.
var fg_key := fg_name
if ":" in fg_name:
var parts := fg_name.split(":")
if not t.has_color(parts[0], cls):
fails.append("%s: no colour '%s'" % [cls, parts[0]])
continue
bg = t.get_color(parts[0], cls)
fg_key = parts[1]
if not t.has_color(fg_key, cls):
fails.append("%s: no colour '%s'" % [cls, fg_key])
continue
var fg: Color = t.get_color(fg_key, cls)
checked += 1
var floor_ := FLOOR_LARGE if large else FLOOR_BODY
var where := "%s/%s + %s" % [cls, sb_name, fg_key]
if mode == OUTLINED:
fails.append_array(_check_outlined(t, cls, where, bg, fg, floor_))
continue
var ratio := UITheme.contrast(fg, bg)
if ratio < floor_:
fails.append("%s = %.2f:1 (needs %.1f) fill=%s text=%s"
% [where, ratio, floor_, bg.to_html(false), fg.to_html(false)])
# The declared table too, for the pairs no theme entry describes — a Label
# over a panel, the level card's caption over its scrim.
for entry in UITheme.state_table():
var name: String = entry[0]
var fill: Color = entry[1]
var text: Color = entry[2]
var large: bool = entry[3]
checked += 1
var ratio := UITheme.contrast(text, fill)
var floor_ := FLOOR_LARGE if large else FLOOR_BODY
if ratio < floor_:
fails.append("table %s = %.2f:1 (needs %.1f)" % [name, ratio, floor_])
print("UI CONTRAST: %d pairs checked" % checked)
if fails.is_empty():
print("PASS — every state reads")
quit(0)
return
for f in fails:
print(" FAIL ", f)
print("FAIL — %d unreadable state(s)" % fails.size())
quit(1)
## Text whose backdrop varies, read either directly or against its own outline.
##
## The outline is a FALLBACK, not a replacement: where the glyph already beats the
## backdrop on its own the outline is free to be the same colour as that backdrop
## and simply do nothing, which is what a paper readout over the bar's near-black
## trough is. It is only when the direct pair fails — paper over hot papaya — that
## the outline has to carry it, and then both of its ratios must hold.
##
## Returns the failures rather than printing, so the caller keeps the tally.
func _check_outlined(t: Theme, cls: String, where: String, bg: Color, fg: Color,
floor_: float) -> Array:
var out: Array = []
if UITheme.contrast(fg, bg) >= floor_:
return out
if not t.has_color("font_outline_color", cls):
return ["%s: outlined but no font_outline_color" % where]
var edge: Color = t.get_color("font_outline_color", cls)
var width: int = t.get_constant("outline_size", cls) \
if t.has_constant("outline_size", cls) else 0
if width < MIN_OUTLINE:
out.append("%s: outline is %d px, needs %d" % [where, width, MIN_OUTLINE])
var edge_bg := UITheme.contrast(edge, bg)
if edge_bg < FLOOR_LARGE:
out.append("%s: outline vs fill = %.2f:1 (needs %.1f) outline=%s fill=%s"
% [where, edge_bg, FLOOR_LARGE, edge.to_html(false), bg.to_html(false)])
var fg_edge := UITheme.contrast(fg, edge)
if fg_edge < floor_:
out.append("%s: text vs outline = %.2f:1 (needs %.1f) text=%s outline=%s"
% [where, fg_edge, floor_, fg.to_html(false), edge.to_html(false)])
return out
## Whether the theme has a MEASURABLE box under that name. Only StyleBoxFlat
## carries a fill colour; anything else is not something to take a ratio against.
func _has_flat(t: Theme, cls: String, sb_name: String) -> bool:
return t.has_stylebox(sb_name, cls) and t.get_stylebox(sb_name, cls) is StyleBoxFlat
func _fill_of(t: Theme, cls: String, sb_name: String) -> Color:
return (t.get_stylebox(sb_name, cls) as StyleBoxFlat).bg_color