From 414026f0012c31ffdc19adb41d7f50f29436d81d Mon Sep 17 00:00:00 2001 From: Nicholas Butzke <137417822+DottsGit@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:39:25 -0400 Subject: [PATCH] feat(ui): every control state is checked for readability, not trusted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- debug/ui_contrast_check.gd | 229 +++++++++++++++++++++++++++++++ ui/ui_theme.gd | 269 ++++++++++++++++++++++++++++++++++--- 2 files changed, 479 insertions(+), 19 deletions(-) create mode 100644 debug/ui_contrast_check.gd diff --git a/debug/ui_contrast_check.gd b/debug/ui_contrast_check.gd new file mode 100644 index 0000000..6c51b73 --- /dev/null +++ b/debug/ui_contrast_check.gd @@ -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 diff --git a/ui/ui_theme.gd b/ui/ui_theme.gd index 92726bc..16db940 100644 --- a/ui/ui_theme.gd +++ b/ui/ui_theme.gd @@ -6,7 +6,7 @@ class_name UITheme ## ## The look is a charged cel comic — near-black violet ink, hot papaya, and a ## lightning yellow that only ever appears at the moment something is pressed. -## Three rules hold it together: +## Four rules hold it together: ## ## INK EVERYTHING every panel, chip and letter carries a heavy dark edge. ## It is what makes flat colour read as drawn rather than @@ -18,11 +18,28 @@ class_name UITheme ## VOLT MEANS NOW yellow is reserved for the pressed state and the bolt. ## Spend it anywhere else and the moment of input stops ## standing out. +## EVERY STATE READS a fill and the text on it are chosen together, and the +## pair is checked. See `STATE_TABLE` and `contrast`. ## ## Everything here is static. Screens ask for `apply_global` once and then use ## the helpers — `heading`, `card`, `divider`, `chip_button` — instead of ## hand-rolling styleboxes, which is how the pause menu drifted 900 lines away ## from the rest of the game's look. +## +## ── Why the state table exists ──────────────────────────────────────────────── +## +## The palette has two light accents (papaya, volt) and one very dark one (ink), +## and a control changes its FILL on hover and press. Text that reads perfectly +## at rest — paper on near-black — inverts to paper-on-yellow the instant the +## pointer arrives, which is a contrast ratio of 1.1:1. It is invisible. +## +## This is the single most common way a stylised UI becomes unreadable, and it is +## also the complaint HoYoverse's own audience has levelled at Zenless Zone +## Zero's menus. The fix is not vigilance, it is arithmetic: `ink_for` picks the +## legible text colour for any fill, every state declares its pair in +## `STATE_TABLE`, and `debug/ui_contrast_check.gd` fails the build if one of them +## drops below the WCAG floor. A state cannot ship unreadable without the check +## going red. const FONT_PATH := "res://assets/ui/fonts/Bangers-Regular.ttf" @@ -42,6 +59,14 @@ const MAGENTA := Color(1.00, 0.18, 0.52) ## Older screens name the accent `TEAL`; it is the electric cyan now. const TEAL := CYAN +## Disabled: the fill goes flat and the label desaturates, but it does NOT go so +## dim that it stops being a word. A disabled control still has to say what it +## would do — "Start Match" greyed out is information, an illegible smudge is +## not. This pair sits above 5:1, where the old 0.42-grey sat at 3.2:1. +const DEAD_FILL := Color(0.10, 0.10, 0.14, 0.92) +const DEAD_EDGE := Color(0.30, 0.29, 0.36) +const DEAD_TEXT := Color(0.55, 0.54, 0.62) + ## How far a chip leans. Applied as opposite corners round and the other two ## nearly square, which is as close to a skew as a StyleBoxFlat can get. const LEAN := 18 @@ -49,6 +74,85 @@ const LEAN := 18 static var _theme: Theme = null +# ── Contrast ───────────────────────────────────────────────────────────────── + + +## Relative luminance, per WCAG 2.1. Alpha is ignored: a chip's fill is drawn +## over the panel behind it, and assuming the fill is opaque is the pessimistic +## reading, which is the one worth checking. +static func luminance(c: Color) -> float: + var ch := [c.r, c.g, c.b] + for i in 3: + var v: float = ch[i] + ch[i] = v / 12.92 if v <= 0.04045 else pow((v + 0.055) / 1.055, 2.4) + return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2] + + +## WCAG contrast ratio between two colours, 1.0 (identical) .. 21.0 (black on +## white). 4.5 is the floor for body text, 3.0 for text above ~24 px. +static func contrast(a: Color, b: Color) -> float: + var la := luminance(a) + var lb := luminance(b) + return (maxf(la, lb) + 0.05) / (minf(la, lb) + 0.05) + + +## The legible text colour for a given fill: ink on a light chip, paper on a dark +## one. This is what makes a hover safe — the fill is free to become papaya or +## volt because the label follows it. +static func ink_for(fill: Color) -> Color: + return INK if contrast(INK, fill) >= contrast(PAPER, fill) else PAPER + + +## Every (fill, text) pair the theme puts on screen, as +## `["/", fill, text, is_large_text]`. +## +## Written out rather than derived so the check has something to compare the +## built theme AGAINST — a table generated from the theme would agree with it by +## construction and catch nothing. Large text (headings, the wordmark, the HUD's +## big numerals) is allowed the 3:1 floor WCAG gives it. +static func state_table() -> Array: + var out: Array = [] + for cls in BUTTON_CLASSES: + out.append_array([ + [cls + "/normal", INK_SOFT, PAPER, false], + [cls + "/hover", PAPAYA, ink_for(PAPAYA), false], + [cls + "/pressed", VOLT, ink_for(VOLT), false], + [cls + "/focus", INK_SOFT, PAPER, false], + [cls + "/disabled", DEAD_FILL, DEAD_TEXT, false], + ]) + out.append_array([ + ["Label/on_panel", PANEL, PAPER, false], + ["Label/dim_on_panel", PANEL, PAPER_DIM, false], + ["Label/title", PANEL, PAPAYA, true], + ["LineEdit/normal", INK_SOFT, PAPER, false], + ["LineEdit/placeholder", INK_SOFT, PAPER_DIM, false], + ["ItemList/normal", PANEL_DEEP, PAPER, false], + ["ItemList/hovered", INK_SOFT, PAPER, false], + ["ItemList/selected", PAPAYA, ink_for(PAPAYA), false], + ["ItemList/selected_focus", PAPAYA_HOT, ink_for(PAPAYA_HOT), false], + ["PopupMenu/normal", PANEL_DEEP, PAPER, false], + ["PopupMenu/hover", PAPAYA, ink_for(PAPAYA), false], + ["PopupMenu/disabled", PANEL_DEEP, DEAD_TEXT, false], + ["TabContainer/selected", PAPAYA, ink_for(PAPAYA), false], + ["TabContainer/unselected", INK_SOFT, PAPER_DIM, false], + ["TabContainer/hovered", PAPAYA_HOT, ink_for(PAPAYA_HOT), false], + ["Tree/normal", PANEL_DEEP, PAPER, false], + ["Tree/selected", PAPAYA, ink_for(PAPAYA), false], + ["Card/label", INK, PAPER, true], + ["Card/hover_label", INK, VOLT, true], + ]) + return out + + +## Which theme types get the chip button treatment. Godot theme types do not +## inherit styling from Button, so each one has to be told the same thing. +const BUTTON_CLASSES := ["Button", "OptionButton", "MenuButton", "CheckBox", + "CheckButton", "LinkButton"] + + +# ── Styleboxes ─────────────────────────────────────────────────────────────── + + ## A leaning comic chip: flat fill, heavy ink edge, hard offset shadow. ## ## Kept at its original name and argument order — main_menu and match_hud both @@ -93,6 +197,24 @@ static func panel(bg: Color = PANEL, border: Color = INK, return sb +## A tight row chip — a list entry, a popup line, a tab — with the lean dropped. +## +## The lean is energy on a button the eye lands on. Repeated down twenty rows of +## a list it reads as a stack of broken rectangles, so rows get square corners +## and keep the fill and the edge. +static func row(bg: Color, border: Color = INK, border_w: int = 2) -> StyleBoxFlat: + var sb := StyleBoxFlat.new() + sb.bg_color = bg + sb.border_color = border + sb.set_border_width_all(border_w) + sb.set_corner_radius_all(4) + sb.content_margin_left = 12 + sb.content_margin_right = 12 + sb.content_margin_top = 5 + sb.content_margin_bottom = 5 + return sb + + static func build() -> Theme: if _theme: return _theme @@ -105,12 +227,10 @@ static func build() -> Theme: # ── Buttons ────────────────────────────────────────────────────────── # Dark chip with a papaya edge at rest; the chip FILLS papaya on hover and # flashes volt on press. The press state drops its shadow, so the chip - # visibly slams down into the page rather than just changing colour. - _button_look(t, "Button") - for cls in ["OptionButton", "MenuButton", "CheckBox", "CheckButton", - "LinkButton"]: - # Godot theme types do not inherit styling from Button, so each one has - # to be told the same thing. + # visibly slams down into the page rather than just changing colour. Every + # state's text colour comes from `ink_for` its own fill, so the label + # inverts WITH the chip instead of vanishing into it. + for cls in BUTTON_CLASSES: _button_look(t, cls) # ── Labels: ink outline everywhere, for readability straight over 3D ── @@ -125,45 +245,110 @@ static func build() -> Theme: # ── Inputs ─────────────────────────────────────────────────────────── t.set_stylebox("normal", "LineEdit", box(INK_SOFT, PAPAYA, 4, 3, false)) t.set_stylebox("focus", "LineEdit", box(INK_SOFT, CYAN, 4, 3, false)) + t.set_stylebox("read_only", "LineEdit", box(DEAD_FILL, DEAD_EDGE, 4, 3, false)) t.set_color("font_color", "LineEdit", PAPER) + t.set_color("font_uneditable_color", "LineEdit", DEAD_TEXT) t.set_color("caret_color", "LineEdit", VOLT) t.set_color("font_placeholder_color", "LineEdit", PAPER_DIM) + # Selected text: volt fill wants ink glyphs, same rule as a pressed chip. + t.set_color("font_selected_color", "LineEdit", INK) + t.set_color("selection_color", "LineEdit", VOLT) + + t.set_stylebox("normal", "SpinBox", box(INK_SOFT, PAPAYA, 4, 3, false)) + t.set_color("font_color", "SpinBox", PAPER) # ── Sliders: the filled part is the charged part ───────────────────── t.set_stylebox("slider", "HSlider", box(INK_SOFT, INK, 2, 2, false)) t.set_stylebox("grabber_area", "HSlider", box(PAPAYA, INK, 2, 2, false)) t.set_stylebox("grabber_area_highlight", "HSlider", box(VOLT, INK, 2, 2, false)) + t.set_stylebox("slider", "VSlider", box(INK_SOFT, INK, 2, 2, false)) + t.set_stylebox("grabber_area", "VSlider", box(PAPAYA, INK, 2, 2, false)) + t.set_stylebox("grabber_area_highlight", "VSlider", box(VOLT, INK, 2, 2, false)) + + # ── Progress bars ──────────────────────────────────────────────────── + t.set_stylebox("background", "ProgressBar", row(INK, INK_SOFT, 2)) + t.set_stylebox("fill", "ProgressBar", row(PAPAYA, INK, 0)) + t.set_color("font_color", "ProgressBar", PAPER) + t.set_color("font_outline_color", "ProgressBar", INK) + t.set_constant("outline_size", "ProgressBar", 6) # ── Panels / lists ─────────────────────────────────────────────────── t.set_stylebox("panel", "PanelContainer", panel()) t.set_stylebox("panel", "Panel", panel()) t.set_stylebox("panel", "ItemList", panel(PANEL_DEEP, INK, 3)) t.set_color("font_color", "ItemList", PAPER) - t.set_color("font_selected_color", "ItemList", INK) - t.set_stylebox("selected", "ItemList", box(PAPAYA, INK, 4, 2, false)) - t.set_stylebox("selected_focus", "ItemList", box(PAPAYA_HOT, CYAN, 4, 2, false)) - t.set_stylebox("hovered", "ItemList", box(INK_SOFT, PAPAYA, 4, 2, false)) + t.set_color("font_selected_color", "ItemList", ink_for(PAPAYA)) + t.set_color("font_hovered_color", "ItemList", PAPER) + t.set_color("font_outline_color", "ItemList", INK) + t.set_constant("outline_size", "ItemList", 5) + t.set_stylebox("selected", "ItemList", row(PAPAYA)) + t.set_stylebox("selected_focus", "ItemList", row(PAPAYA_HOT, CYAN)) + # Hover keeps PAPER text, so the fill must stay DARK. A papaya hover under a + # papaya selection also made the two states indistinguishable — the row you + # were pointing at looked exactly like the row you had chosen. + t.set_stylebox("hovered", "ItemList", row(INK_SOFT, PAPAYA)) + t.set_stylebox("hovered_selected", "ItemList", row(PAPAYA_HOT, CYAN)) + t.set_stylebox("cursor", "ItemList", row(Color(0, 0, 0, 0), CYAN)) + t.set_stylebox("cursor_unfocused", "ItemList", row(Color(0, 0, 0, 0), PAPER_DIM)) + + # ── Trees (settings, loadout lists) ────────────────────────────────── + t.set_stylebox("panel", "Tree", panel(PANEL_DEEP, INK, 3)) + t.set_color("font_color", "Tree", PAPER) + t.set_color("font_selected_color", "Tree", ink_for(PAPAYA)) + t.set_color("font_outline_color", "Tree", INK) + t.set_constant("outline_size", "Tree", 5) + t.set_stylebox("selected", "Tree", row(PAPAYA)) + t.set_stylebox("selected_focus", "Tree", row(PAPAYA_HOT, CYAN)) + t.set_stylebox("hovered", "Tree", row(INK_SOFT, PAPAYA)) + t.set_stylebox("hovered_selected", "Tree", row(PAPAYA_HOT, CYAN)) + + # ── Scrollbars: visible at rest, charged under the thumb ───────────── + for cls in ["HScrollBar", "VScrollBar"]: + t.set_stylebox("scroll", cls, row(INK, INK_SOFT, 1)) + t.set_stylebox("grabber", cls, row(PAPER_DIM, INK, 1)) + t.set_stylebox("grabber_highlight", cls, row(PAPAYA, INK, 1)) + t.set_stylebox("grabber_pressed", cls, row(VOLT, INK, 1)) # ── Tabs ───────────────────────────────────────────────────────────── t.set_stylebox("panel", "TabContainer", panel()) t.set_stylebox("tab_selected", "TabContainer", box(PAPAYA, INK, 6, 3, false)) t.set_stylebox("tab_unselected", "TabContainer", box(INK_SOFT, INK, 6, 3, false)) t.set_stylebox("tab_hovered", "TabContainer", box(PAPAYA_HOT, INK, 6, 3, false)) - t.set_color("font_selected_color", "TabContainer", INK) + t.set_stylebox("tab_disabled", "TabContainer", box(DEAD_FILL, DEAD_EDGE, 6, 3, false)) + t.set_color("font_selected_color", "TabContainer", ink_for(PAPAYA)) t.set_color("font_unselected_color", "TabContainer", PAPER_DIM) - t.set_color("font_hovered_color", "TabContainer", INK) + t.set_color("font_hovered_color", "TabContainer", ink_for(PAPAYA_HOT)) + t.set_color("font_disabled_color", "TabContainer", DEAD_TEXT) + t.set_color("font_outline_color", "TabContainer", INK) + t.set_constant("outline_size", "TabContainer", 5) # ── Popups (OptionButton dropdowns) ────────────────────────────────── + # + # The dropdown is the one surface where a missing hover colour is fatal: a + # PopupMenu draws its hover fill but keeps `font_color` unless + # `font_hover_color` is set, so a papaya row under paper text was the least + # readable thing in the game and it appeared on every settings menu. t.set_stylebox("panel", "PopupMenu", panel(PANEL_DEEP, PAPAYA, 3)) - t.set_stylebox("hover", "PopupMenu", box(PAPAYA, INK, 4, 0, false)) + t.set_stylebox("hover", "PopupMenu", row(PAPAYA, INK, 0)) t.set_color("font_color", "PopupMenu", PAPER) - t.set_color("font_hover_color", "PopupMenu", INK) + t.set_color("font_hover_color", "PopupMenu", ink_for(PAPAYA)) + t.set_color("font_disabled_color", "PopupMenu", DEAD_TEXT) + t.set_color("font_accelerator_color", "PopupMenu", PAPER_DIM) + t.set_color("font_separator_color", "PopupMenu", PAPAYA) + t.set_color("font_outline_color", "PopupMenu", INK) + t.set_constant("outline_size", "PopupMenu", 5) # ── Dialogs ────────────────────────────────────────────────────────── t.set_stylebox("panel", "AcceptDialog", panel(PANEL_DEEP, PAPAYA, 4)) t.set_stylebox("embedded_border", "Window", panel(PANEL_DEEP, PAPAYA, 4)) t.set_color("title_color", "Window", VOLT) + # ── Tooltips ───────────────────────────────────────────────────────── + t.set_stylebox("panel", "TooltipPanel", panel(PANEL_DEEP, VOLT, 2)) + t.set_color("font_color", "TooltipLabel", PAPER) + t.set_color("font_outline_color", "TooltipLabel", INK) + t.set_constant("outline_size", "TooltipLabel", 5) + _theme = t return t @@ -172,15 +357,28 @@ static func _button_look(t: Theme, cls: String) -> void: t.set_stylebox("normal", cls, box(INK_SOFT, PAPAYA)) t.set_stylebox("hover", cls, box(PAPAYA, INK)) t.set_stylebox("pressed", cls, box(VOLT, INK, 10, 3, false)) + # Hover-while-pressed is its own stylebox on a toggle button. Without it a + # CheckButton the pointer is over reverts to the hover fill, so a toggle + # reads as OFF for as long as you are touching it. + t.set_stylebox("hover_pressed", cls, box(VOLT, CYAN, 10, 3, false)) t.set_stylebox("focus", cls, box(INK_SOFT, CYAN)) - t.set_stylebox("disabled", cls, box(Color(0.10, 0.10, 0.14, 0.85), Color(0.28, 0.27, 0.33))) + t.set_stylebox("disabled", cls, box(DEAD_FILL, DEAD_EDGE)) t.set_color("font_color", cls, PAPER) - t.set_color("font_hover_color", cls, INK) - t.set_color("font_pressed_color", cls, INK) + t.set_color("font_hover_color", cls, ink_for(PAPAYA)) + t.set_color("font_pressed_color", cls, ink_for(VOLT)) + t.set_color("font_hover_pressed_color", cls, ink_for(VOLT)) t.set_color("font_focus_color", cls, PAPER) - t.set_color("font_disabled_color", cls, Color(0.42, 0.41, 0.48)) + t.set_color("font_disabled_color", cls, DEAD_TEXT) t.set_color("font_outline_color", cls, INK) t.set_constant("outline_size", cls, 5) + # The icon has to invert with the label. A paper glyph on a volt chip is the + # same 1.1:1 the text would have been, and a CheckBox is ALL icon. + t.set_color("icon_normal_color", cls, PAPER) + t.set_color("icon_hover_color", cls, ink_for(PAPAYA)) + t.set_color("icon_pressed_color", cls, ink_for(VOLT)) + t.set_color("icon_hover_pressed_color", cls, ink_for(VOLT)) + t.set_color("icon_focus_color", cls, PAPER) + t.set_color("icon_disabled_color", cls, DEAD_TEXT) ## Apply the theme to the whole root window. Idempotent, and cheap enough that @@ -223,6 +421,17 @@ static func heading(text: String, size: int = 32) -> Label: return l +## A quieter line of body copy — a hint, a port number, a mode's description. +static func caption(text: String, size: int = 20) -> Label: + var l := Label.new() + l.text = text + l.add_theme_font_size_override("font_size", size) + l.add_theme_color_override("font_color", PAPER_DIM) + l.add_theme_color_override("font_outline_color", INK) + l.add_theme_constant_override("outline_size", 5) + return l + + ## A bolt-struck rule, for separating sections. static func divider(strike_at: float = 0.34) -> BoltRule: var b := BoltRule.new() @@ -240,6 +449,28 @@ static func card() -> PanelContainer: return p +## The one loud button on a screen — the thing the player came here to press. +## +## Papaya-filled at rest instead of ink-filled, so it is the first thing the eye +## lands on, and correspondingly ink-lettered. Hover goes hotter and press still +## flashes volt, so the state ladder is unchanged; only the resting colour moves. +static func primary_button(text: String, size: int = 44) -> Button: + var b := Button.new() + b.text = text + b.add_theme_font_size_override("font_size", size) + b.add_theme_stylebox_override("normal", box(PAPAYA, INK, 12, 4)) + b.add_theme_stylebox_override("hover", box(PAPAYA_HOT, VOLT, 12, 4)) + b.add_theme_stylebox_override("pressed", box(VOLT, INK, 12, 4, false)) + b.add_theme_stylebox_override("focus", box(PAPAYA, CYAN, 12, 4)) + b.add_theme_color_override("font_color", ink_for(PAPAYA)) + b.add_theme_color_override("font_hover_color", ink_for(PAPAYA_HOT)) + b.add_theme_color_override("font_pressed_color", ink_for(VOLT)) + b.add_theme_color_override("font_focus_color", ink_for(PAPAYA)) + b.add_theme_color_override("font_outline_color", Color(INK.r, INK.g, INK.b, 0.55)) + b.add_theme_constant_override("outline_size", 4) + return b + + ## Sounds and the hover kick for every button under `root` (recursive). ## ## The kick is a 4% scale-up on hover and a snap back on exit. It is small on