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