Feat/fidelity pass 2 #23

Merged
Dotts merged 7 commits from feat/fidelity-pass-2 into main 2026-07-28 10:31:27 -07:00
2 changed files with 479 additions and 19 deletions
Showing only changes of commit 414026f001 - Show all commits
+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
+250 -19
View File
@@ -6,7 +6,7 @@ class_name UITheme
## ##
## The look is a charged cel comic — near-black violet ink, hot papaya, and a ## 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. ## 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. ## INK EVERYTHING every panel, chip and letter carries a heavy dark edge.
## It is what makes flat colour read as drawn rather than ## 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. ## VOLT MEANS NOW yellow is reserved for the pressed state and the bolt.
## Spend it anywhere else and the moment of input stops ## Spend it anywhere else and the moment of input stops
## standing out. ## 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 ## Everything here is static. Screens ask for `apply_global` once and then use
## the helpers — `heading`, `card`, `divider`, `chip_button` — instead of ## the helpers — `heading`, `card`, `divider`, `chip_button` — instead of
## hand-rolling styleboxes, which is how the pause menu drifted 900 lines away ## hand-rolling styleboxes, which is how the pause menu drifted 900 lines away
## from the rest of the game's look. ## 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" 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. ## Older screens name the accent `TEAL`; it is the electric cyan now.
const TEAL := CYAN 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 ## 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. ## nearly square, which is as close to a skew as a StyleBoxFlat can get.
const LEAN := 18 const LEAN := 18
@@ -49,6 +74,85 @@ const LEAN := 18
static var _theme: Theme = null 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
## `["<class>/<state>", 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. ## 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 ## 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 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: static func build() -> Theme:
if _theme: if _theme:
return _theme return _theme
@@ -105,12 +227,10 @@ static func build() -> Theme:
# ── Buttons ────────────────────────────────────────────────────────── # ── Buttons ──────────────────────────────────────────────────────────
# Dark chip with a papaya edge at rest; the chip FILLS papaya on hover and # 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 # flashes volt on press. The press state drops its shadow, so the chip
# visibly slams down into the page rather than just changing colour. # visibly slams down into the page rather than just changing colour. Every
_button_look(t, "Button") # state's text colour comes from `ink_for` its own fill, so the label
for cls in ["OptionButton", "MenuButton", "CheckBox", "CheckButton", # inverts WITH the chip instead of vanishing into it.
"LinkButton"]: for cls in BUTTON_CLASSES:
# Godot theme types do not inherit styling from Button, so each one has
# to be told the same thing.
_button_look(t, cls) _button_look(t, cls)
# ── Labels: ink outline everywhere, for readability straight over 3D ── # ── Labels: ink outline everywhere, for readability straight over 3D ──
@@ -125,45 +245,110 @@ static func build() -> Theme:
# ── Inputs ─────────────────────────────────────────────────────────── # ── Inputs ───────────────────────────────────────────────────────────
t.set_stylebox("normal", "LineEdit", box(INK_SOFT, PAPAYA, 4, 3, false)) 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("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_color", "LineEdit", PAPER)
t.set_color("font_uneditable_color", "LineEdit", DEAD_TEXT)
t.set_color("caret_color", "LineEdit", VOLT) t.set_color("caret_color", "LineEdit", VOLT)
t.set_color("font_placeholder_color", "LineEdit", PAPER_DIM) 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 ───────────────────── # ── Sliders: the filled part is the charged part ─────────────────────
t.set_stylebox("slider", "HSlider", box(INK_SOFT, INK, 2, 2, false)) 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", "HSlider", box(PAPAYA, INK, 2, 2, false))
t.set_stylebox("grabber_area_highlight", "HSlider", box(VOLT, 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 ─────────────────────────────────────────────────── # ── Panels / lists ───────────────────────────────────────────────────
t.set_stylebox("panel", "PanelContainer", panel()) t.set_stylebox("panel", "PanelContainer", panel())
t.set_stylebox("panel", "Panel", panel()) t.set_stylebox("panel", "Panel", panel())
t.set_stylebox("panel", "ItemList", panel(PANEL_DEEP, INK, 3)) t.set_stylebox("panel", "ItemList", panel(PANEL_DEEP, INK, 3))
t.set_color("font_color", "ItemList", PAPER) t.set_color("font_color", "ItemList", PAPER)
t.set_color("font_selected_color", "ItemList", INK) t.set_color("font_selected_color", "ItemList", ink_for(PAPAYA))
t.set_stylebox("selected", "ItemList", box(PAPAYA, INK, 4, 2, false)) t.set_color("font_hovered_color", "ItemList", PAPER)
t.set_stylebox("selected_focus", "ItemList", box(PAPAYA_HOT, CYAN, 4, 2, false)) t.set_color("font_outline_color", "ItemList", INK)
t.set_stylebox("hovered", "ItemList", box(INK_SOFT, PAPAYA, 4, 2, false)) 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 ───────────────────────────────────────────────────────────── # ── Tabs ─────────────────────────────────────────────────────────────
t.set_stylebox("panel", "TabContainer", panel()) t.set_stylebox("panel", "TabContainer", panel())
t.set_stylebox("tab_selected", "TabContainer", box(PAPAYA, INK, 6, 3, false)) 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_unselected", "TabContainer", box(INK_SOFT, INK, 6, 3, false))
t.set_stylebox("tab_hovered", "TabContainer", box(PAPAYA_HOT, 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_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) ────────────────────────────────── # ── 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("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_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 ────────────────────────────────────────────────────────── # ── Dialogs ──────────────────────────────────────────────────────────
t.set_stylebox("panel", "AcceptDialog", panel(PANEL_DEEP, PAPAYA, 4)) t.set_stylebox("panel", "AcceptDialog", panel(PANEL_DEEP, PAPAYA, 4))
t.set_stylebox("embedded_border", "Window", panel(PANEL_DEEP, PAPAYA, 4)) t.set_stylebox("embedded_border", "Window", panel(PANEL_DEEP, PAPAYA, 4))
t.set_color("title_color", "Window", VOLT) 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 _theme = t
return 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("normal", cls, box(INK_SOFT, PAPAYA))
t.set_stylebox("hover", cls, box(PAPAYA, INK)) t.set_stylebox("hover", cls, box(PAPAYA, INK))
t.set_stylebox("pressed", cls, box(VOLT, INK, 10, 3, false)) 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("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_color", cls, PAPER)
t.set_color("font_hover_color", cls, INK) t.set_color("font_hover_color", cls, ink_for(PAPAYA))
t.set_color("font_pressed_color", cls, INK) 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_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_color("font_outline_color", cls, INK)
t.set_constant("outline_size", cls, 5) 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 ## 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 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. ## A bolt-struck rule, for separating sections.
static func divider(strike_at: float = 0.34) -> BoltRule: static func divider(strike_at: float = 0.34) -> BoltRule:
var b := BoltRule.new() var b := BoltRule.new()
@@ -240,6 +449,28 @@ static func card() -> PanelContainer:
return p 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). ## 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 ## The kick is a 4% scale-up on hover and a snap back on exit. It is small on