feat(menu): PLAY is one press, and the level cards are photographs

Reaching a game was Singleplayer → pick a level. Reaching a multiplayer game
was Multiplayer → Host Game → wait → pick a level → wait, and the gamemode
dropdown had exactly one entry which set a string nothing read.

PLAY now starts the last map and mode played, and says which — "Akiba Crossing
• Deathmatch" under the button, so pressing it is a promise rather than a leap.
Everything else on the home screen is a detour from that, which is the right
shape for a menu: the common case is a button, not a path. Hosting is one press
and lands in the lobby already hosting; the lobby puts map, mode, players and
start on ONE screen; Enter connects, so typing an address does not then require
reaching for the mouse.

This is the lesson HoYoverse published about Zenless Zone Zero's first months
more loudly than anything else they have written. Their postmortem on the TV
mode names three complaints — it took too long, it sat between the player and
the combat, and there was too much of it early — and 1.2 removed it from the
story entirely rather than shortening it. Time spent BEFORE the thing the
player came for is not neutral, it is a cost.

The cards were a two-stop gradient generated from two colours in a meta file,
whose hover state was `use_hdr = true` — not a visible change on any of them.
They are photographs now, shot by debug/map_preview_capture.gd, which took
three attempts to get right and each attempt is a comment in the file:

  - framing each map from OUTSIDE by merging every VisualInstance3D's AABB
    produced five tiny dioramas floating on a table, and one solid black
    rectangle. That is a minimap, and a minimap is not a photograph.
  - standing at a spawn point fixed three maps and left two black: fps_blockout
    is genuinely dark and most of its spawns face an unlit wall, and
    procedural_arena builds its geometry at runtime so no fixed offset is
    reliably inside it.
  - so the tool now RENDERS several vantages and scores each result — mean
    luminance times its standard deviation, because brightness alone picks the
    empty sky and variance alone picks a high-contrast corner of a dark room.
    Their product picks a photograph. A black rectangle passes any check that
    only asks whether the camera ended up somewhere sensible.

Also fixed, and caught by looking at the screenshot: the selected mode chip was
unreadable. Its glyph is ink on volt, and the theme gave every button a 5 px INK
outline — an ink glyph inside an ink outline is not outlined, it is five pixels
fatter, and on a small chip that is a solid blob. Button labels now carry no
outline at all, which is the same reasoning debug/ui_contrast_check.gd already
encodes: an outline separates a glyph from a backdrop it cannot beat alone, and
a label on a solid chip does not have that problem. The chips keep their heavy
ink border, so nothing about the drawn look changes.

`current_gamemode` is normalised on the way in, because it used to hold a
display string and six callers still pass one. An unrecognised id compares
unequal to GameMode.GUN_GAME and would silently disable that mode's weapon
issuing — half a mode is worse than none.

The menu's background character is the player's OWN skin holding their weapon,
rather than the box-and-capsule mannequin that stood there before.

spawn smoke 0, game modes 30/30, movement 11/11, contrast 108/108, HUD layout
PASS, weapon holds 0, dances 0.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-28 12:40:50 -04:00
co-authored by Claude Opus 5
parent 986179854d
commit 2efc21b18d
22 changed files with 1121 additions and 552 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 611 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 765 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 690 KiB

+1 -1
View File
@@ -51,7 +51,7 @@ func _process(_delta: float) -> bool:
sm.set_active_skin(_skin) sm.set_active_skin(_skin)
var nm = root.get_node_or_null("NetworkManager") var nm = root.get_node_or_null("NetworkManager")
if nm and nm.has_method("start_singleplayer_match"): if nm and nm.has_method("start_singleplayer_match"):
nm.start_singleplayer_match("Deathmatch") nm.start_singleplayer_match(GameMode.DEATHMATCH)
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn") change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
return false return false
if _frames < 160: if _frames < 160:
+1 -1
View File
@@ -41,7 +41,7 @@ func _process(_delta: float) -> bool:
if _frames == 40: if _frames == 40:
var nm = root.get_node_or_null("NetworkManager") var nm = root.get_node_or_null("NetworkManager")
if nm and nm.has_method("start_singleplayer_match"): if nm and nm.has_method("start_singleplayer_match"):
nm.start_singleplayer_match("Deathmatch") nm.start_singleplayer_match(GameMode.DEATHMATCH)
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn") change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
elif _frames >= 160: elif _frames >= 160:
for p in root.find_children("*", "CharacterBody3D", true, false): for p in root.find_children("*", "CharacterBody3D", true, false):
+1
View File
@@ -0,0 +1 @@
uid://cjpesfuv0vmcs
+201
View File
@@ -0,0 +1,201 @@
extends SceneTree
## Photograph every map, for the level select cards.
##
## godot --path . --windowed --resolution 640x400 \
## -s res://debug/map_preview_capture.gd
##
## Writes assets/ui/map_previews/<folder>.png, which the menu picks up by
## convention — no map_meta key to add, so a new map that ships a preview gets
## one and a map that does not falls back to its gradient.
##
## The cards used to be a two-stop gradient built from `color1` and `color2` in
## the map's meta file. That tells a player which card they clicked last time and
## nothing whatsoever about the map, which is the entire job of a level select.
##
## ── Framing ─────────────────────────────────────────────────────────────────
##
## The camera stands INSIDE the map, at a spawn point, at eye height, looking
## across it.
##
## The first version framed the whole level from outside by merging every
## VisualInstance3D's AABB and backing off until it fit. Every map came out as a
## tiny diorama floating on a table in the middle of an empty sky — and one came
## out entirely black, because from orbit there was nothing lit in frame. That is
## a minimap, and a minimap is a different thing from a photograph.
##
## What a player wants off a card is the FEEL of a place: its light, its colour,
## how enclosed it is, what the skyline looks like. All of that only exists from
## where a player will actually stand, so that is where the camera goes — and
## spawn points are already authored in every map, so it costs nothing.
const OUT_DIR := "res://assets/ui/map_previews"
const MAPS_DIR := "res://scenes/maps/"
## How long to let a map build itself. The procedural ones generate geometry in
## `_ready` and the neon map streams in props, so a shot taken too early is a
## photograph of an empty skybox.
const SETTLE_FRAMES := 90
func _initialize() -> void:
_run()
func _run() -> void:
await process_frame
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(OUT_DIR))
for folder in _map_folders():
var cfg := ConfigFile.new()
if cfg.load(MAPS_DIR + folder + "/map_meta.cfg") != OK:
continue
var scene_path: String = cfg.get_value("map", "scene_path", "")
if scene_path == "" or not ResourceLoader.exists(scene_path):
continue
var packed: PackedScene = load(scene_path)
if packed == null:
print("map_preview: could not load ", scene_path)
continue
var level: Node = packed.instantiate()
root.add_child(level)
for _i in SETTLE_FRAMES:
await process_frame
var shot: Image = await _best_shot(level)
var path := "%s/%s.png" % [OUT_DIR, folder]
if shot == null:
print("map_preview: no usable vantage in ", folder)
else:
shot.save_png(ProjectSettings.globalize_path(path))
print("map_preview: saved ", path)
level.queue_free()
for _i in 6:
await process_frame
quit(0)
func _map_folders() -> Array:
var out: Array = []
var dir := DirAccess.open(MAPS_DIR)
if dir == null:
return out
dir.list_dir_begin()
var name := dir.get_next()
while name != "":
if dir.current_is_dir() and not name.begins_with("."):
if FileAccess.file_exists(MAPS_DIR + name + "/map_meta.cfg"):
out.append(name)
name = dir.get_next()
out.sort()
return out
## Try several vantages and keep the one that produces the best PICTURE.
##
## Not the one that satisfies a rule about where cameras should go. Two maps
## defeated every positional rule tried: fps_blockout is a genuinely dark map
## where most spawns face an unlit wall, and procedural_arena builds its geometry
## at runtime so no fixed offset is inside it. Both came out as black rectangles,
## and a black rectangle passes any check that asks "did the camera end up
## somewhere sensible".
##
## So the tool renders each candidate and SCORES the result. The score wants an
## image that is both bright and varied: mean luminance alone picks a shot of the
## empty sky, and variance alone picks a high-contrast corner of a dark room.
## Their product picks a photograph.
func _best_shot(level: Node):
var bounds := _bounds(level)
var centre := bounds.get_center() if bounds.size.length() > 0.01 else Vector3.ZERO
var extent: float = maxf(maxf(bounds.size.x, bounds.size.z), 8.0)
var cam := Camera3D.new()
level.add_child(cam)
cam.far = maxf(bounds.size.length() * 2.0, 800.0)
# A little wider than the game's own view, so a card shows more of the space
# than a screenshot of play would.
cam.fov = 68.0
cam.current = true
var best_img: Image = null
var best_score := -1.0
for vantage in _vantages(centre, extent):
cam.global_position = vantage[0]
var look: Vector3 = vantage[1]
if cam.global_position.distance_to(look) < 1.0:
continue
cam.look_at(look)
# Two frames: one for the transform, one for the frame drawn with it.
for _i in 3:
await process_frame
var img := root.get_texture().get_image()
var score := _score(img)
if score > best_score:
best_score = score
best_img = img
cam.queue_free()
return best_img
## Candidate camera placements, as `[eye, look_at]` pairs.
##
## Every authored spawn point, plus a raised view from each side of the map for
## the levels that generate themselves and have no spawns by the time this runs.
func _vantages(centre: Vector3, extent: float) -> Array:
var out: Array = []
for s in get_nodes_in_group("spawn_points"):
if s is Node3D:
var p: Vector3 = (s as Node3D).global_position
out.append([p + Vector3.UP * 2.2, centre + Vector3.UP * 1.6])
# Raised corners, looking in. Higher than eye level, because a generated
# arena's floor is not necessarily at the origin and standing "on" it is a
# guess where looking down at it is not.
for dir in [Vector3(1, 0, 1), Vector3(-1, 0, 1), Vector3(1, 0, -1),
Vector3(-1, 0, -1)]:
var d: Vector3 = dir.normalized()
out.append([centre + d * extent * 0.42 + Vector3.UP * extent * 0.22,
centre + Vector3.UP * extent * 0.04])
return out
## How good a picture is: bright AND varied. See `_best_shot`.
func _score(img: Image) -> float:
var small := img.duplicate() as Image
small.resize(40, 24, Image.INTERPOLATE_BILINEAR)
var n := 40 * 24
var mean := 0.0
for y in 24:
for x in 40:
var c := small.get_pixel(x, y)
mean += 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b
mean /= float(n)
var variance := 0.0
for y in 24:
for x in 40:
var c := small.get_pixel(x, y)
var l := 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b
variance += (l - mean) * (l - mean)
variance /= float(n)
return mean * sqrt(variance)
func _bounds(node: Node) -> AABB:
var out := AABB()
var any := false
for child in node.find_children("*", "VisualInstance3D", true, false):
var vi := child as VisualInstance3D
# Skip anything enormous: a WorldEnvironment's sky or a directional
# light's own AABB would swallow the map and push the camera to orbit.
var box := vi.get_aabb()
if box.size.length() > 100000.0 or box.size.length() < 0.001:
continue
box = vi.global_transform * box
if not any:
out = box
any = true
else:
out = out.merge(box)
return out if any else AABB()
+1
View File
@@ -0,0 +1 @@
uid://esvuwcyncpeb
+65
View File
@@ -0,0 +1,65 @@
extends SceneTree
## One photograph of every main-menu screen, including the lobby.
##
## godot --path . --windowed --resolution 1280x720 \
## -s res://debug/menu_capture.gd -- <out_dir>
##
## The lobby is the reason this exists. It only appears after hosting, so it is
## the screen least likely to be looked at and the one where an unstyled list or
## a collapsed card would sit unnoticed — which is exactly the class of defect
## that this project keeps finding by LOOKING rather than by asserting.
const MENU := "res://ui/main_menu/main_menu.tscn"
var _out := "."
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if args.size() > 0:
_out = String(args[0])
_run()
func _run() -> void:
await process_frame
change_scene_to_file(MENU)
for _i in 60:
await process_frame
var menu := current_scene
if menu == null:
print("menu_capture: no menu")
quit(1)
return
await _shot("home")
menu._show(menu.LEVELS)
await _shot("levels")
menu._show(menu.MULTIPLAYER)
await _shot("multiplayer")
# Host for real, so the lobby is photographed in the state a player reaches
# it in — host controls enabled, own name in the list — rather than in an
# empty one that would hide a broken player list.
var nm = root.get_node_or_null("NetworkManager")
if nm and nm.host_game() == OK:
menu._on_connection_succeeded()
for _i in 20:
await process_frame
await _shot("lobby")
nm.disconnect_game()
else:
print("menu_capture: could not host, lobby not shot")
quit(0)
func _shot(tag: String) -> void:
for _i in 12:
await process_frame
root.get_texture().get_image().save_png("%s/menu_%s.png" % [_out, tag])
print("menu_capture: saved ", tag)
+1 -1
View File
@@ -25,7 +25,7 @@ func _process(_delta: float) -> bool:
sm.set_active_skin("taila") sm.set_active_skin("taila")
var nm = root.get_node_or_null("NetworkManager") var nm = root.get_node_or_null("NetworkManager")
if nm and nm.has_method("start_singleplayer_match"): if nm and nm.has_method("start_singleplayer_match"):
nm.start_singleplayer_match("Deathmatch") nm.start_singleplayer_match(GameMode.DEATHMATCH)
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn") change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
elif _frames == 160: elif _frames == 160:
for p in root.find_children("*", "CharacterBody3D", true, false): for p in root.find_children("*", "CharacterBody3D", true, false):
+1 -1
View File
@@ -30,7 +30,7 @@ func _initialize() -> void:
_out_dir = args[0] _out_dir = args[0]
var nm = root.get_node_or_null("NetworkManager") var nm = root.get_node_or_null("NetworkManager")
if nm and nm.has_method("start_singleplayer_match"): if nm and nm.has_method("start_singleplayer_match"):
nm.start_singleplayer_match("Deathmatch") nm.start_singleplayer_match(GameMode.DEATHMATCH)
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn") change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
+1 -1
View File
@@ -56,7 +56,7 @@ func _test_spawn_with_skin(skin_id: String, expect_skinned: bool) -> void:
var nm = root.get_node_or_null("/root/NetworkManager") var nm = root.get_node_or_null("/root/NetworkManager")
if not _check(nm != null, "NetworkManager autoload exists"): if not _check(nm != null, "NetworkManager autoload exists"):
return return
nm.start_singleplayer_match("Deathmatch") nm.start_singleplayer_match(GameMode.DEATHMATCH)
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn") change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
# Let the level build and the player spawn + settle. # Let the level build and the player spawn + settle.
+1
View File
@@ -0,0 +1 @@
uid://bnw4gima0hkli
+1 -1
View File
@@ -35,7 +35,7 @@ func _process(_delta: float) -> bool:
# rather than a flat colour — that its legibility has to survive. # rather than a flat colour — that its legibility has to survive.
var nm = root.get_node_or_null("NetworkManager") var nm = root.get_node_or_null("NetworkManager")
if nm and nm.has_method("start_singleplayer_match"): if nm and nm.has_method("start_singleplayer_match"):
nm.start_singleplayer_match("Deathmatch") nm.start_singleplayer_match(GameMode.DEATHMATCH)
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn") change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
200: 200:
_open("_show_main_menu") _open("_show_main_menu")
+1 -1
View File
@@ -26,7 +26,7 @@ func _process(_delta: float) -> bool:
_shot("menu") _shot("menu")
var nm = root.get_node_or_null("NetworkManager") var nm = root.get_node_or_null("NetworkManager")
if nm and nm.has_method("start_singleplayer_match"): if nm and nm.has_method("start_singleplayer_match"):
nm.start_singleplayer_match("Deathmatch") nm.start_singleplayer_match(GameMode.DEATHMATCH)
change_scene_to_file(_scene) change_scene_to_file(_scene)
elif _frames == 240: elif _frames == 240:
_shot("level") _shot("level")
+7 -1
View File
@@ -85,7 +85,13 @@ func start_singleplayer_match(gamemode: String = GameMode.DEATHMATCH) -> void:
connected_players.clear() connected_players.clear()
connected_players.append(1) connected_players.append(1)
current_gamemode = gamemode # Normalised, because `current_gamemode` used to be a DISPLAY string
# ("Deathmatch") and half a dozen callers still pass one. GameMode's lookups
# all fall back safely, but an id that is not in the table would compare
# unequal to GameMode.GUN_GAME and silently disable Gun Game's weapon
# issuing — a mode that half works is worse than one that does not load.
current_gamemode = gamemode if GameMode.MODES.has(gamemode) \
else GameMode.DEATHMATCH
player_stats.clear() player_stats.clear()
_init_player_stats(1, SettingsManager.username) _init_player_stats(1, SettingsManager.username)
+144
View File
@@ -0,0 +1,144 @@
extends Button
class_name LevelCard
## One map on the level select: its photograph, its name, and its mode.
##
## The cards used to be a two-stop gradient generated from `color1` and `color2`
## in the map's meta file. That tells a player which card they clicked last time
## and nothing at all about the map, which is the entire job of a level select —
## and the hover state was `use_hdr = true` on the gradient texture, which is not
## a visible change on any of them.
##
## The preview comes from assets/ui/map_previews/<folder>.png, shot by
## debug/map_preview_capture.gd from inside the map. A map with no preview falls
## back to its gradient, so nothing breaks for a map that has not been
## photographed yet.
##
## Built on Button rather than TextureButton so it gets focus, keyboard
## navigation and the theme's state machinery for free. The image sits inside the
## button's border rather than under it, so the border reads as a frame around a
## photograph instead of a rectangle behind one.
const INSET := 5
var _plate: ColorRect
var _label: Label
var _mode_label: Label
func setup(title: String, preview_path: String, gradient: Gradient,
card_size: Vector2) -> void:
custom_minimum_size = card_size
clip_contents = true
# The frame. Papaya at rest, volt on hover, and the fill stays transparent
# so the photograph is what the player sees.
var clear := Color(0, 0, 0, 0)
add_theme_stylebox_override("normal", _frame(clear, UITheme.PAPAYA, 3))
add_theme_stylebox_override("hover", _frame(clear, UITheme.VOLT, 5))
add_theme_stylebox_override("pressed", _frame(
Color(UITheme.VOLT.r, UITheme.VOLT.g, UITheme.VOLT.b, 0.25),
UITheme.VOLT, 5))
add_theme_stylebox_override("focus", _frame(clear, UITheme.CYAN, 5))
add_theme_stylebox_override("disabled", _frame(
Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, 0.7),
UITheme.DEAD_EDGE, 3))
if ResourceLoader.exists(preview_path):
var shot := TextureRect.new()
shot.texture = load(preview_path)
shot.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
# COVERED, not scaled: a photograph letterboxed inside a card reads as a
# thumbnail in a file browser. Filling the card and cropping reads as a
# poster, which is what this is.
shot.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
shot.mouse_filter = Control.MOUSE_FILTER_IGNORE
_fill(shot)
add_child(shot)
else:
var grad := TextureRect.new()
var tex := GradientTexture2D.new()
tex.gradient = gradient
tex.width = int(card_size.x)
tex.height = int(card_size.y)
tex.fill_to = Vector2(1, 1)
grad.texture = tex
grad.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
grad.stretch_mode = TextureRect.STRETCH_SCALE
grad.mouse_filter = Control.MOUSE_FILTER_IGNORE
_fill(grad)
add_child(grad)
# The name plate: a hard ink band across the bottom rather than a gradient
# fade, because the theme is drawn and a soft fade is not.
_plate = ColorRect.new()
_plate.color = Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, 0.88)
_plate.mouse_filter = Control.MOUSE_FILTER_IGNORE
_plate.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_WIDE)
_plate.offset_top = -62
_plate.offset_left = INSET
_plate.offset_right = -INSET
_plate.offset_bottom = -INSET
add_child(_plate)
var stack := VBoxContainer.new()
stack.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
stack.add_theme_constant_override("separation", -2)
stack.mouse_filter = Control.MOUSE_FILTER_IGNORE
_plate.add_child(stack)
_label = Label.new()
_label.text = title
_label.add_theme_font_size_override("font_size", 28)
_label.add_theme_color_override("font_color", UITheme.PAPER)
_label.add_theme_color_override("font_outline_color", UITheme.INK)
_label.add_theme_constant_override("outline_size", 6)
_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
stack.add_child(_label)
_mode_label = Label.new()
_mode_label.add_theme_font_size_override("font_size", 16)
_mode_label.add_theme_color_override("font_color", UITheme.PAPER_DIM)
_mode_label.add_theme_color_override("font_outline_color", UITheme.INK)
_mode_label.add_theme_constant_override("outline_size", 4)
_mode_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
stack.add_child(_mode_label)
# The label follows the frame, same rule as every other control: hover turns
# the frame volt, so the name turns volt with it.
mouse_entered.connect(func(): _tint(UITheme.VOLT))
mouse_exited.connect(func(): _tint(UITheme.PAPER))
focus_entered.connect(func(): _tint(UITheme.CYAN))
focus_exited.connect(func(): _tint(UITheme.PAPER))
## What pressing this card will start. Shown under the name so the player is
## never guessing which mode the button they are about to press launches.
func set_mode_text(text: String) -> void:
if _mode_label:
_mode_label.text = text
func _tint(c: Color) -> void:
if _label:
_label.add_theme_color_override("font_color", c)
func _fill(c: Control) -> void:
c.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
c.offset_left = INSET
c.offset_top = INSET
c.offset_right = -INSET
c.offset_bottom = -INSET
func _frame(fill: Color, border: Color, width: int) -> StyleBoxFlat:
var sb := StyleBoxFlat.new()
sb.bg_color = fill
sb.border_color = border
sb.set_border_width_all(width)
sb.set_corner_radius_all(5)
sb.shadow_color = Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, 0.6)
sb.shadow_size = 4
sb.shadow_offset = Vector2(5, 5)
return sb
+1
View File
@@ -0,0 +1 @@
uid://2bfw74vxcitf
+669 -532
View File
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -369,8 +369,20 @@ static func _button_look(t: Theme, cls: String) -> void:
t.set_color("font_hover_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, DEAD_TEXT) t.set_color("font_disabled_color", cls, DEAD_TEXT)
# NO OUTLINE on a button label.
#
# An outline exists to separate a glyph from a backdrop the glyph cannot beat
# on its own — over the 3D scene, or across a two-tone progress bar. A button
# label has neither problem: it sits on a solid chip, and `ink_for` has
# already given it a colour that beats that chip.
#
# Worse, the theme can only carry ONE outline colour per class, and half the
# states here use an ink glyph. Ink glyphs inside a 5 px ink outline are not
# outlined, they are five pixels fatter — the selected mode chip rendered as
# an unreadable dark blob on volt. The chips keep their heavy ink BORDER, so
# the drawn look is unaffected.
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, 0)
# The icon has to invert with the label. A paper glyph on a volt chip is the # 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. # 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_normal_color", cls, PAPER)