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