Skip to content

Liquid-glass UI — Plan 1: core (glass component, level-up, stats sheet, dud filter, pause)

Liquid-glass UI — Plan 1 (core) Implementation Plan

Section titled “Liquid-glass UI — Plan 1 (core) Implementation Plan”

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Ship the shared liquid-glass component, the redesigned level-up (bottom strip of 3 glass cards), the Y ship-stats sheet with live card preview, the dud-crystal-card filter, and the glass pause menu.

Architecture: One GlassPanel container draws a single shader-backed surface behind its content (frosted variant samples the mipmapped screen texture; clear variant doesn’t). StatsSheet is a pure-data-plus-view Control reused by the level-up panel and the pause menu. The old level-up panel is kept intact as LegacyLevelUpPanel behind VisualLook.glass_ui, both deriving from a tiny LevelUpBase so main.gd holds either.

Tech Stack: Godot 4.7 typed GDScript, canvas_item shaders, GUT 9.7.1.

Spec: docs/superpowers/specs/2026-09-24-liquid-glass-ui-redesign-design.md

Plan 2 (separate, written after this lands): start menu, results, dimension choice, shop, ship config, bestiary, dialogue box, HUD docks — each a layout pass on GlassPanel.

  • Old look one flag away: VisualLook.glass_ui (static var, default true); false must render every touched screen exactly as before.
  • /sim stays pure (no Node/render APIs). UI only reads sim state; the only sim change in this plan is the dud-crystal filter in sim/upgrade_system.gd.
  • Frost (screen-texture sampling) only on screens shown while the sim is paused. Never on the HUD.
  • Base viewport is 1280×720 (canvas_items stretch). Sizes below are base units. Card name font 24, card line font 17 (≥ spec’s 28px/20px at 1080p).
  • No DPS delta anywhere. DPS shows effective_dps(sim) only.
  • Co-op: the level-up panel and its sheet obey _gate_device / _gate_p1 exactly as today.
  • Gate for every commit: bash scripts/check-test-count.sh > /tmp/gate.log 2>&1; tail -1 /tmp/gate.log | grep -q 'guard OK' must pass before git commit; then git pull --rebase --autostash && git push.
  • After any windowed godot run: git diff --quiet project.godot || git checkout -- project.godot.
  • ui-sweep: zsh scripts/ui-sweep.sh (never bash). Bless only after eyeballing every PNG in tools/ui_sweep/out/.
  • Run a single test script with -gselect=<script_name_without_.gd>, e.g. godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gselect=test_glass_panel -gexit.
  • Determinism: a survival re-pin is approved by Chris for the dud filter only (spec §2), in its own commit, and only if test_determinism_survival actually fails.

Task 1: GlassPanel component + VisualLook.glass_ui

Section titled “Task 1: GlassPanel component + VisualLook.glass_ui”

Files:

  • Modify: render/visual_look.gd (append the flag)
  • Create: ui/theme/glass_common.gdshaderinc, ui/theme/glass_frost.gdshader, ui/theme/glass_clear.gdshader, ui/theme/glass_panel.gd
  • Test: tests/test_glass_panel.gd

Interfaces:

  • Produces: class_name GlassPanel extends Container with accent: Color, focus: float (0..1), frosted: bool, dim: bool, margin: float (content padding, default 14), consts GLOW_PAD := 18.0, RADIUS := 12.0, FROST_SHADER, CLEAR_SHADER; func backdrop() -> ColorRect (the internal surface, for tests). VisualLook.glass_ui: bool.

  • Step 1: Write the failing test — tests/test_glass_panel.gd

extends GutTest
func _panel(frosted: bool) -> GlassPanel:
var p := GlassPanel.new()
p.frosted = frosted
add_child_autofree(p)
return p
func test_frosted_uses_frost_shader_and_clear_does_not() -> void:
var f := _panel(true)
var c := _panel(false)
await get_tree().process_frame
assert_eq((f.backdrop().material as ShaderMaterial).shader, GlassPanel.FROST_SHADER)
assert_eq((c.backdrop().material as ShaderMaterial).shader, GlassPanel.CLEAR_SHADER)
f.frosted = false
assert_eq((f.backdrop().material as ShaderMaterial).shader, GlassPanel.CLEAR_SHADER, "live switch")
func test_setters_push_shader_params() -> void:
var p := _panel(false)
p.accent = Color(1, 0, 0)
p.focus = 0.5
p.dim = true
var m := p.backdrop().material as ShaderMaterial
assert_eq(m.get_shader_parameter("accent"), Color(1, 0, 0))
assert_almost_eq(float(m.get_shader_parameter("focus")), 0.5, 0.001)
assert_almost_eq(float(m.get_shader_parameter("dim")), 1.0, 0.001)
func test_focus_is_clamped() -> void:
var p := _panel(false)
p.focus = 3.0
assert_almost_eq(p.focus, 1.0, 0.001)
func test_layout_pads_content_and_grows_backdrop_by_glow_pad() -> void:
var p := _panel(false)
var lbl := Label.new()
lbl.text = "hello"
p.add_child(lbl)
p.size = Vector2(300, 120)
await get_tree().process_frame
await get_tree().process_frame
assert_eq(lbl.position, Vector2(p.margin, p.margin), "content inset by margin")
assert_eq(lbl.size, Vector2(300 - 2 * p.margin, 120 - 2 * p.margin))
assert_eq(p.backdrop().position, Vector2(-GlassPanel.GLOW_PAD, -GlassPanel.GLOW_PAD))
assert_eq(p.backdrop().size, Vector2(300, 120) + Vector2.ONE * GlassPanel.GLOW_PAD * 2.0)
var m := p.backdrop().material as ShaderMaterial
assert_eq(m.get_shader_parameter("rect_size"), Vector2(300, 120))
func test_backdrop_is_internal_not_a_child() -> void:
var p := _panel(true)
await get_tree().process_frame
assert_eq(p.get_child_count(), 0, "screens iterating children never see the backdrop")
assert_true(p.backdrop().get_parent() == p)
func test_minimum_size_includes_margins() -> void:
var p := _panel(false)
var box := Control.new()
box.custom_minimum_size = Vector2(100, 40)
p.add_child(box)
assert_eq(p.get_combined_minimum_size(), Vector2(100, 40) + Vector2.ONE * p.margin * 2.0)
  • Step 2: Run it — expect FAIL (“Could not find type GlassPanel”).

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gselect=test_glass_panel -gexit

  • Step 3: Implement

Append to render/visual_look.gd:

# Liquid-glass UI (docs/superpowers/specs/2026-09-24-liquid-glass-ui-redesign-design.md, the 4th
# freeze exception). false = the Nova-plate UI exactly as it was on 2026-09-23. Read when a screen is
# built, so flipping it takes effect on the next open, not mid-screen.
static var glass_ui := true

ui/theme/glass_common.gdshaderinc:

// Shared liquid-glass surface. The backdrop quad is the panel grown by `pad` px on every side so the
// focus glow can bleed outside the panel; UV spans that whole quad.
uniform vec4 accent : source_color = vec4(0.2, 0.9, 1.0, 1.0);
uniform float focus = 0.0;
uniform float dim = 0.0;
uniform vec2 rect_size = vec2(100.0, 100.0);
uniform float pad = 18.0;
uniform float radius = 12.0;
float glass_sd(vec2 p, vec2 half_size, float r) {
vec2 q = abs(p) - half_size + vec2(r);
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
}
// base: what shows through (blurred screen, or a dark plate). body_a: surface alpha inside.
vec4 glass_surface(vec2 uv, vec3 base, float body_a) {
vec2 full = rect_size + vec2(pad * 2.0);
vec2 p = uv * full - full * 0.5;
float d = glass_sd(p, rect_size * 0.5, radius);
float inside = 1.0 - smoothstep(-0.8, 0.8, d);
float t = clamp((p.y + rect_size.y * 0.5) / max(rect_size.y, 1.0), 0.0, 1.0); // 0 top, 1 bottom
vec3 pale = mix(accent.rgb, vec3(1.0), 0.7);
vec3 body = mix(base, vec3(1.0), mix(0.13, 0.035, t)); // top-lit sheen
body = mix(body, pale, 0.10 * focus);
body *= 1.0 - 0.18 * smoothstep(0.65, 1.0, t); // soft bottom shade
body *= mix(1.0, 0.55, dim);
float rim = 1.0 - smoothstep(0.0, 1.3, abs(d + 0.7));
body += pale * rim * (0.22 + 0.4 * focus) * (1.0 - 0.6 * dim);
body += vec3(rim * (1.0 - t) * 0.25); // bright top edge
float g = exp(-max(d, 0.0) / (pad * 0.35)) * (1.0 - inside) * focus * (1.0 - dim);
vec4 glow = vec4(accent.rgb, g * 0.55);
return mix(glow, vec4(body, body_a), inside);
}

ui/theme/glass_frost.gdshader:

shader_type canvas_item;
#include "res://ui/theme/glass_common.gdshaderinc"
// Frosted: the game frame behind, read from the screen texture's mip chain (one copy + mip build per
// frame, no multi-pass blur). Paused screens only — see GlassPanel.
uniform sampler2D screen_tex : hint_screen_texture, filter_linear_mipmap;
uniform float blur_lod = 3.5;
void fragment() {
vec3 s = textureLod(screen_tex, SCREEN_UV, blur_lod).rgb;
float l = dot(s, vec3(0.299, 0.587, 0.114));
s = mix(vec3(l), s, 1.35) * 0.55; // a little more saturated, darkened so text reads
COLOR = glass_surface(UV, s, 1.0);
}

ui/theme/glass_clear.gdshader:

shader_type canvas_item;
#include "res://ui/theme/glass_common.gdshaderinc"
// Unfrosted glass for the live HUD: never touches the screen texture (no copy, no mips).
void fragment() {
COLOR = glass_surface(UV, vec3(0.03, 0.04, 0.07), 0.72);
}

ui/theme/glass_panel.gd:

class_name GlassPanel
extends Container
# Liquid-glass surface (spec: docs/superpowers/specs/2026-09-24-liquid-glass-ui-redesign-design.md §1).
# Every glass box in the UI is one of these — screens set accent/focus/frosted/dim and never build
# their own StyleBoxFlat, so the look can't drift screen to screen.
# The surface is an INTERNAL ColorRect (not returned by get_children()) grown by GLOW_PAD on every side
# so the focus glow bleeds outside the panel. Content children are laid out inset by `margin`.
const FROST_SHADER := preload("res://ui/theme/glass_frost.gdshader")
const CLEAR_SHADER := preload("res://ui/theme/glass_clear.gdshader")
const GLOW_PAD := 18.0
const RADIUS := 12.0
var accent: Color = Tokens.ACCENT:
set(v):
accent = v
_push()
var focus: float = 0.0:
set(v):
focus = clampf(v, 0.0, 1.0)
_push()
var frosted: bool = false:
set(v):
frosted = v
if _mat != null:
_mat.shader = FROST_SHADER if v else CLEAR_SHADER
var dim: bool = false:
set(v):
dim = v
_push()
var margin: float = 14.0:
set(v):
margin = v
update_minimum_size()
queue_sort()
var _bg: ColorRect
var _mat: ShaderMaterial
func _init() -> void:
_mat = ShaderMaterial.new()
_mat.shader = FROST_SHADER if frosted else CLEAR_SHADER
_bg = ColorRect.new()
_bg.color = Color.WHITE
_bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
_bg.material = _mat
add_child(_bg, false, Node.INTERNAL_MODE_FRONT)
_push()
func backdrop() -> ColorRect:
return _bg
func _push() -> void:
if _mat == null:
return
_mat.set_shader_parameter("accent", accent)
_mat.set_shader_parameter("focus", focus)
_mat.set_shader_parameter("dim", 1.0 if dim else 0.0)
_mat.set_shader_parameter("pad", GLOW_PAD)
_mat.set_shader_parameter("radius", RADIUS)
_mat.set_shader_parameter("rect_size", size)
func _get_minimum_size() -> Vector2:
var m := Vector2.ZERO
for c in get_children():
if c is Control and (c as Control).visible:
m = m.max((c as Control).get_combined_minimum_size())
return m + Vector2.ONE * margin * 2.0
func _notification(what: int) -> void:
if what == NOTIFICATION_SORT_CHILDREN:
var inner := Rect2(Vector2.ONE * margin, size - Vector2.ONE * margin * 2.0)
for c in get_children():
if c is Control:
fit_child_in_rect(c as Control, inner)
_bg.position = -Vector2.ONE * GLOW_PAD
_bg.size = size + Vector2.ONE * GLOW_PAD * 2.0
_push()
  • Step 4: Run the test — expect PASS. If test_layout_pads_content… fails on sizing, check that the panel is not inside another container overriding size (it isn’t in the test).
  • Step 5: Visual check. Write a throwaway scene (not committed) at /tmp is not loadable by Godot, so put it at tools/glass_preview/glass_preview.gd + .tscn (committed, dev tool): a Node2D scene that draws a few coloured circles, then a CanvasLayer with three GlassPanels (frosted, focus 0 / 0.5 / 1, accents cyan/violet/gold) each holding a Label, and after 20 frames saves get_viewport().get_texture().get_image() to OS.get_environment("GLASS_OUT") (default res://tools/glass_preview/out.png), then quits:
extends Node2D
# Dev tool: renders GlassPanel variants over a busy background for eyeballing (2026-09-24).
# GLASS_OUT=/abs/out.png godot --path . res://tools/glass_preview/glass_preview.tscn
func _ready() -> void:
var layer := CanvasLayer.new()
add_child(layer)
var accents := [Tokens.ACCENT, Color(0.6, 0.35, 1.0), Tokens.GOLD]
for i in 3:
var p := GlassPanel.new()
p.frosted = true
p.accent = accents[i]
p.focus = i * 0.5
p.position = Vector2(140 + i * 360, 420)
p.size = Vector2(320, 150)
var l := Label.new()
l.text = "Penetrator\nPierce 1 → 3"
l.add_theme_font_override("font", NeonTheme.title_font())
l.add_theme_font_size_override("font_size", 24)
p.add_child(l)
layer.add_child(p)
for f in 20:
await get_tree().process_frame
await RenderingServer.frame_post_draw
var out := OS.get_environment("GLASS_OUT")
if out == "":
out = ProjectSettings.globalize_path("res://tools/glass_preview/out.png")
get_viewport().get_texture().get_image().save_png(out)
get_tree().quit()
func _draw() -> void:
for i in 40:
var c := Color.from_hsv(fmod(i * 0.137, 1.0), 0.8, 1.0)
draw_circle(Vector2(fmod(i * 197.0, 1280.0), fmod(i * 131.0, 720.0)), 30.0 + (i % 5) * 12.0, c)

.tscn: a single root Node2D with the script attached ([gd_scene format=3] / [ext_resource type="Script" path="res://tools/glass_preview/glass_preview.gd" id="1"] / [node name="GlassPreview" type="Node2D"] / script = ExtResource("1")). Add tools/glass_preview/out.png to .gitignore.

Run windowed: GLASS_OUT=$PWD/scratch_glass.png godot --path . res://tools/glass_preview/glass_preview.tscn --rendering-method mobile, restore project.godot, open the PNG. Expected: blurred circles through each panel, a bright top edge, no glow on the left panel, a visible accent glow on the right one. If the blur is missing (sharp circles), the renderer isn’t generating screen mips; raise nothing, report it — the fallback is still a legible tinted panel.

  • Step 6: Gate + commit — feat(ui): GlassPanel liquid-glass component + VisualLook.glass_ui

Files:

  • Modify: sim/upgrade_system.gd (survival branch of roll_upgrade_choices, lines ~111-131; add two funcs after roll_crystal_grant)
  • Test: tests/test_dud_crystal_filter.gd

Interfaces:

  • Produces: func is_dud_crystal(sim: Sim, pilot: PlayerState, id: String) -> bool, func roll_live_crystal_grant(sim: Sim, pilot: PlayerState) -> String (returns "" when every retry was a dud), const CRYSTAL_REROLLS := 4.

  • Step 1: Failing test — tests/test_dud_crystal_filter.gd

extends GutTest
# A crystal grant that crosses no weapon rung AND builds towards none is a dud ("no effect") —
# the one dud card still reaching players (RELEASE-0.1 §4). Everything goes through the real
# upgrade_effects()/roll path; nothing is hand-set.
func _sim() -> Sim:
var sim := Sim.new(1, SimContentFixture.db())
sim.enable_survival()
return sim
func test_grant_touching_no_owned_weapon_is_a_dud() -> void:
var sim := _sim()
assert_false(sim.player.arsenal.active_weapon_ids.has("orbit"), "precondition: no cold weapon owned")
assert_true(sim.upgrade_system.is_dud_crystal(sim, sim.player, "crystals:cold=1"))
func test_grant_building_towards_a_rung_is_not_a_dud() -> void:
var sim := _sim()
sim.upgrade_system.grant_weapon(sim, sim.player, "orbit") # cold shard @5 -> 1 cold = progress 1/5
assert_false(sim.upgrade_system.is_dud_crystal(sim, sim.player, "crystals:cold=1"))
func test_non_crystal_ids_are_never_duds() -> void:
var sim := _sim()
assert_false(sim.upgrade_system.is_dud_crystal(sim, sim.player, "weapon:nova"))
func test_live_grant_is_never_a_dud() -> void:
var sim := _sim()
for i in 200:
var id := sim.upgrade_system.roll_live_crystal_grant(sim, sim.player)
if id != "":
assert_false(sim.upgrade_system.is_dud_crystal(sim, sim.player, id), "rolled %s" % id)
func test_level_up_offers_never_contain_a_dud() -> void:
var sim := _sim()
for i in 150:
var ids := sim.upgrade_system.roll_upgrade_choices(sim, sim.player, 3)
assert_between(ids.size(), 1, 3)
for id in ids:
assert_false(sim.upgrade_system.is_dud_crystal(sim, sim.player, id), "offered dud %s" % id)
  • Step 2: Run — expect FAIL (is_dud_crystal not found).

  • Step 3: Implement. After roll_crystal_grant in sim/upgrade_system.gd add:

# Retries before giving up on a live crystal grant (each retry is one more roll_crystal_grant draw
# from upgrade_rng, so a dud-free roll consumes exactly the draws it always did — determinism holds
# for every offer that never hit a dud).
const CRYSTAL_REROLLS := 4
# A crystal grant that crosses no weapon rung AND builds towards none: the "no effect" card
# (liquid-glass spec §2, RELEASE-0.1 §4). A grant that is one rung short still shows progress, so
# it is NOT a dud.
func is_dud_crystal(sim: Sim, pilot: PlayerState, id: String) -> bool:
if not id.begins_with("crystals:"):
return false
var eff := upgrade_effects(sim, pilot, id)
return bool(eff.get("dead", false)) and (eff.get("progress", []) as Array).is_empty()
# roll_crystal_grant, re-rolled past duds. "" when every retry was a dud (caller drops the slot).
func roll_live_crystal_grant(sim: Sim, pilot: PlayerState) -> String:
for _i in CRYSTAL_REROLLS:
var id := roll_crystal_grant(sim)
if not is_dud_crystal(sim, pilot, id):
return id
return ""

In the survival branch of roll_upgrade_choices, replace:

if sim.upgrade_rng.randf() < CRYSTAL_GRANT_CHANCE:
out.append(roll_crystal_grant(sim))
if sim.upgrade_rng.randf() < CRYSTAL_SECOND_GRANT_CHANCE:
out.append(roll_crystal_grant(sim))

with:

if sim.upgrade_rng.randf() < CRYSTAL_GRANT_CHANCE:
var g1 := roll_live_crystal_grant(sim, pilot)
if g1 != "":
out.append(g1)
if sim.upgrade_rng.randf() < CRYSTAL_SECOND_GRANT_CHANCE:
var g2 := roll_live_crystal_grant(sim, pilot)
if g2 != "":
out.append(g2)

and the pad loop:

while out.size() < n:
out.append(roll_crystal_grant(sim))

with:

while out.size() < n:
var g3 := roll_live_crystal_grant(sim, pilot)
if g3 == "":
break # fewer cards beats a dud card (spec §2)
out.append(g3)
  • Step 4: Run the new test — PASS. Then run -gselect=test_determinism_survival and -gselect=test_determinism_coop.
    • Both pass → no re-pin; note “no re-pin needed” in the commit body.
    • One fails → re-pin ONLY the failing file’s asserted hash/checksum to the new printed values, in a separate commit test(determinism): re-pin survival baseline for the dud-crystal filter (approved, spec §2).
  • Step 5: Soak — scripts/soak.sh --runs=6 --ticks=18000 --ships=cobalt,aurum --coop=both must report no invariant failures.
  • Step 6: Tick RELEASE-0.1.md — in §4 find the dud “no effect” level-up card item and append ✅ DONE (<commit>).
  • Step 7: Gate + commit — fix(upgrades): never offer a dud crystal card

Pure row builders first (unit-testable without a scene), view in Task 4.

Files:

  • Create: ui/stats_sheet.gd (data half only in this task)
  • Test: tests/test_stats_sheet_rows.gd

Interfaces:

  • Consumes: upgrade_system.ship_stat_preview(sim, pilot, id) -> {field, now, after} (or {}), upgrade_system.parse_crystal_spec(sim, spec) -> Dictionary (element→amount), upgrade_system.effective_dps(sim) -> float, WeaponThresholds.rules_for(wid), sim.content.weapon(wid), sim.max_weapon_slots, sim.crystals.counts.

  • Produces (all static):

    • StatsSheet.ship_rows(sim, pilot, preview_id: String) -> Array[Dictionary] — each {field, name, value: String, after: String ("" = unchanged), frac: float (-1 = no bar)}, fields in order max_hp, armor, speed, fire_rate_mult, pickup_radius, dps.
    • StatsSheet.crystal_rows(sim, pilot, preview_id) -> Array[Dictionary] — each {element, have: int, after: int, next: int (0 = no rung above)}, one per element held OR granted by the previewed card, sorted by element name.
    • StatsSheet.weapon_rows(sim, pilot, preview_id) -> Array[Dictionary] — each {wid, name, evolved: bool, ghost: bool, empty: bool, flag: String}; owned weapons first (in active_weapon_ids order), then a ghost row for a weapon:X preview, then empty slots up to sim.max_weapon_slots.
  • Step 1: Failing test — tests/test_stats_sheet_rows.gd

extends GutTest
func _sim() -> Sim:
var sim := Sim.new(1, SimContentFixture.db())
sim.enable_survival()
return sim
func _row(rows: Array, key: String, val: String) -> Dictionary:
for r: Dictionary in rows:
if String(r.get(key, "")) == val:
return r
return {}
func test_ship_rows_show_current_values_and_no_after_without_preview() -> void:
var sim := _sim()
var rows := StatsSheet.ship_rows(sim, sim.player, "")
assert_eq(rows.size(), 6)
assert_eq(String(rows[0]["field"]), "max_hp")
for r: Dictionary in rows:
assert_eq(String(r["after"]), "", "%s unchanged" % r["field"])
func test_stat_card_preview_sets_after_on_its_row_only() -> void:
var sim := _sim()
var id := ""
for u in sim.content.upgrades(): # first real stat upgrade that ship_stat_preview understands
if not sim.upgrade_system.ship_stat_preview(sim, sim.player, String(u["id"])).is_empty():
id = String(u["id"])
break
assert_ne(id, "", "fixture has a previewable stat upgrade")
var pv := sim.upgrade_system.ship_stat_preview(sim, sim.player, id)
var rows := StatsSheet.ship_rows(sim, sim.player, id)
for r: Dictionary in rows:
if String(r["field"]) == String(pv["field"]):
assert_ne(String(r["after"]), "", "previewed row shows an after value")
else:
assert_eq(String(r["after"]), "", "%s untouched" % r["field"])
func test_crystal_preview_adds_the_granted_element_with_after_total() -> void:
var sim := _sim()
sim.upgrade_system.grant_weapon(sim, sim.player, "orbit") # cold rung at 5
var rows := StatsSheet.crystal_rows(sim, sim.player, "crystals:cold=1")
var cold := _row(rows, "element", "cold")
assert_false(cold.is_empty(), "granted element appears even at 0 held")
assert_eq(int(cold["have"]), 0)
assert_eq(int(cold["after"]), 1)
assert_eq(int(cold["next"]), 5, "next rung = orbit's cold 5")
func test_crystal_rows_without_preview_have_after_equal_have() -> void:
var sim := _sim()
sim.crystals.counts["void"] = 7
var rows := StatsSheet.crystal_rows(sim, sim.player, "")
var v := _row(rows, "element", "void")
assert_eq(int(v["have"]), 7)
assert_eq(int(v["after"]), 7)
func test_weapon_rows_list_owned_then_ghost_then_empty_slots() -> void:
var sim := _sim()
var owned := sim.player.arsenal.active_weapon_ids.size()
var rows := StatsSheet.weapon_rows(sim, sim.player, "weapon:nova")
assert_eq(rows.size(), sim.max_weapon_slots, "one row per slot")
assert_true(bool(rows[owned]["ghost"]), "the previewed weapon ghosts into the first free slot")
assert_eq(String(rows[owned]["wid"]), "nova")
for i in range(owned + 1, rows.size()):
assert_true(bool(rows[i]["empty"]))
func test_crystal_card_flags_the_weapon_whose_rung_it_crosses() -> void:
var sim := _sim()
sim.upgrade_system.grant_weapon(sim, sim.player, "orbit")
var rows := StatsSheet.weapon_rows(sim, sim.player, "crystals:cold=5")
var orbit := _row(rows, "wid", "orbit")
assert_ne(String(orbit["flag"]), "", "orbit's cold rung is crossed -> flagged")
  • Step 2: Run — FAIL (StatsSheet not found).

  • Step 3: Implement ui/stats_sheet.gd (data half):

class_name StatsSheet
extends Control
# Ship-stats sheet (spec §3): opened with Y over the level-up cards (live preview of the focused card)
# and from the pause menu (read-only). Row builders are static + pure so they're tested without a
# scene; the view (Task 4) only renders what they return. Reads sim state only.
const SHIP_FIELDS: Array[Array] = [
["max_hp", "Hull"], ["armor", "Armor"], ["speed", "Speed"],
["fire_rate_mult", "Fire rate"], ["pickup_radius", "Pickup"], ["dps", "DPS"],
]
static func _fmt(field: String, pilot: PlayerState, v: float) -> String:
match field:
"max_hp": return "%.0f / %.0f" % [pilot.hp, v]
"fire_rate_mult": return "%.2f×" % v
_: return "%.0f" % v
static func ship_rows(sim: Sim, pilot: PlayerState, preview_id: String) -> Array[Dictionary]:
var pv: Dictionary = {} if preview_id == "" else sim.upgrade_system.ship_stat_preview(sim, pilot, preview_id)
var out: Array[Dictionary] = []
for f: Array in SHIP_FIELDS:
var field: String = f[0]
var row := {"field": field, "name": f[1], "after": "", "frac": -1.0}
if field == "dps":
row["value"] = "%.0f" % sim.upgrade_system.effective_dps(sim) # current only, no delta (spec)
else:
row["value"] = _fmt(field, pilot, float(pilot.get(field)))
if String(pv.get("field", "")) == field:
row["after"] = _fmt(field, pilot, float(pv["after"]))
if field == "max_hp":
row["frac"] = clampf(pilot.hp / maxf(pilot.max_hp, 1.0), 0.0, 1.0)
out.append(row)
return out
static func _bundle(sim: Sim, preview_id: String) -> Dictionary:
if not preview_id.begins_with("crystals:"):
return {}
return sim.upgrade_system.parse_crystal_spec(sim, preview_id.substr("crystals:".length()))
static func _owned_plus_aim(pilot: PlayerState) -> Array:
return pilot.arsenal.active_weapon_ids + ["aim"] # "aim" is always on but not in active ids
static func crystal_rows(sim: Sim, pilot: PlayerState, preview_id: String) -> Array[Dictionary]:
var bundle := _bundle(sim, preview_id)
var els: Array[String] = []
for el: Variant in sim.crystals.counts:
if int(sim.crystals.counts[el]) > 0:
els.append(String(el))
for el: Variant in bundle:
if not els.has(String(el)):
els.append(String(el))
els.sort()
var out: Array[Dictionary] = []
for el in els:
var have := int(sim.crystals.counts.get(el, 0))
var after := have + int(bundle.get(el, 0))
var nxt := 0
for wid: Variant in _owned_plus_aim(pilot):
for rule: Dictionary in WeaponThresholds.rules_for(String(wid)):
var amt := int(rule["amount"])
if String(rule["element"]) == el and amt > have and (nxt == 0 or amt < nxt):
nxt = amt
out.append({"element": el, "have": have, "after": after, "next": nxt})
return out
static func weapon_rows(sim: Sim, pilot: PlayerState, preview_id: String) -> Array[Dictionary]:
var bundle := _bundle(sim, preview_id)
var after: Dictionary = sim.crystals.counts.duplicate()
for el: Variant in bundle:
after[el] = int(after.get(el, 0)) + int(bundle[el])
var out: Array[Dictionary] = []
for wid: Variant in pilot.arsenal.active_weapon_ids:
var w: Variant = pilot.arsenal.weapon_by_id.get(wid)
var flag := ""
if not bundle.is_empty():
var before_met := WeaponThresholds.rules_met(String(wid), sim.crystals.counts)
var after_met := WeaponThresholds.rules_met(String(wid), after)
var rules := WeaponThresholds.rules_for(String(wid))
for i in after_met:
if not before_met.has(i):
flag = "★ EVOLVE" if String(rules[i]["kind"]) == "evolve" else "▲ " + String(rules[i]["kind"]).to_upper()
out.append({"wid": String(wid), "name": String(sim.content.weapon(String(wid)).get("name", wid)),
"evolved": w != null and bool(w.evolved), "ghost": false, "empty": false, "flag": flag})
if preview_id.begins_with("weapon:"):
var nw := preview_id.substr("weapon:".length())
out.append({"wid": nw, "name": String(sim.content.weapon(nw).get("name", nw)),
"evolved": false, "ghost": true, "empty": false, "flag": "NEW"})
while out.size() < sim.max_weapon_slots:
out.append({"wid": "", "name": "Empty slot", "evolved": false, "ghost": false, "empty": true, "flag": ""})
return out
  • Step 4: Run — PASS. If test_weapon_rows… fails because the fixture sim already fills every slot, the precondition is wrong for this fixture: assert owned < sim.max_weapon_slots first and pick the ghost index from the data.
  • Step 5: Gate + commit — feat(ui): StatsSheet row model (ship, crystals, weapons, live preview)

Files:

  • Modify: ui/stats_sheet.gd (add the view half)
  • Test: tests/test_stats_sheet_view.gd

Interfaces:

  • Consumes: Task 3 row builders, GlassPanel, WeaponDetailView.build(dict) -> Control, upgrade_system.weapon_detail(sim, pilot, wid) -> Dictionary.

  • Produces: func bind(sim: Sim, pilot: PlayerState) -> void, func set_preview(id: String) -> void, func open() -> void, func close() -> void, func is_open() -> bool, func move_weapon(step: int) -> void, var weapon_sel: int, var read_only: bool (pause-menu mode: no preview), signal closed.

  • Step 1: Failing test — tests/test_stats_sheet_view.gd

extends GutTest
func _sheet() -> Array:
var sim := Sim.new(1, SimContentFixture.db())
sim.enable_survival()
sim.upgrade_system.grant_weapon(sim, sim.player, "orbit")
var sheet := StatsSheet.new()
add_child_autofree(sheet)
sheet.bind(sim, sim.player)
return [sim, sheet]
func test_open_close_toggles_visibility() -> void:
var r := _sheet()
var sheet: StatsSheet = r[1]
assert_false(sheet.is_open())
sheet.open()
assert_true(sheet.is_open())
assert_true(sheet.visible)
sheet.close()
assert_false(sheet.is_open())
func test_preview_marks_changed_labels() -> void:
var r := _sheet()
var sheet: StatsSheet = r[1]
sheet.open()
sheet.set_preview("crystals:cold=5")
await get_tree().process_frame
assert_true(sheet.text_dump().contains("0 → 5") or sheet.text_dump().contains("0→5") or sheet.text_dump().contains("→ 5"),
"cold bar shows the after total: %s" % sheet.text_dump())
func test_read_only_ignores_preview() -> void:
var r := _sheet()
var sheet: StatsSheet = r[1]
sheet.read_only = true
sheet.set_preview("crystals:cold=5")
assert_false(sheet.text_dump().contains("→ 5"))
func test_move_weapon_wraps_over_owned_weapons() -> void:
var r := _sheet()
var sim: Sim = r[0]
var sheet: StatsSheet = r[1]
var n := sim.player.arsenal.active_weapon_ids.size()
sheet.open()
sheet.move_weapon(-1)
assert_eq(sheet.weapon_sel, n - 1, "wraps backwards")
sheet.move_weapon(1)
assert_eq(sheet.weapon_sel, 0)
  • Step 2: Run — FAIL.

  • Step 3: Implement — add to ui/stats_sheet.gd:

signal closed
const WIDTH_FRAC := 0.42
const HEIGHT_FRAC := 0.58
const SLIDE_S := 0.18
const GOOD := Color(0.4, 1.0, 0.6)
const EVOLVE := Color(1.0, 0.75, 0.25)
const ELEMENT_COLORS := {
"fire": Color(1.0, 0.55, 0.3), "cold": Color(0.5, 0.88, 1.0), "lightning": Color(1.0, 0.92, 0.35),
"void": Color(0.68, 0.45, 1.0), "blood": Color(1.0, 0.3, 0.4), "light": Color(1.0, 0.97, 0.8),
"kinetic": Color(0.75, 0.82, 0.9), "aether": Color(0.55, 1.0, 0.85),
}
var read_only := false
var weapon_sel := 0
var _sim: Sim
var _pilot: PlayerState
var _preview := ""
var _open := false
var _glass: GlassPanel
var _body: HBoxContainer
var _tween: Tween
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
set_anchors_preset(Control.PRESET_FULL_RECT)
_glass = GlassPanel.new()
_glass.frosted = true
_glass.focus = 0.35
_glass.margin = 18.0
add_child(_glass)
_body = HBoxContainer.new()
_body.add_theme_constant_override("separation", 22)
_glass.add_child(_body)
visible = false
func bind(sim: Sim, pilot: PlayerState) -> void:
_sim = sim
_pilot = pilot
weapon_sel = 0
_preview = ""
_rebuild()
func set_preview(id: String) -> void:
if read_only:
return
_preview = id
_rebuild()
func is_open() -> bool:
return _open
func open() -> void:
_open = true
visible = true
_layout()
if _tween != null:
_tween.kill()
_tween = create_tween()
_glass.position.x = -_glass.size.x
_tween.tween_property(_glass, "position:x", 24.0, SLIDE_S).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
func close() -> void:
if not _open:
return
_open = false
visible = false
closed.emit()
func move_weapon(step: int) -> void:
if _pilot == null:
return
var n := _pilot.arsenal.active_weapon_ids.size()
if n == 0:
return
weapon_sel = wrapi(weapon_sel + step, 0, n)
_rebuild()
# Plain-text dump of every Label, for tests.
func text_dump() -> String:
var s := ""
for l in _all_labels(_body):
s += l.text + "\n"
return s
func _all_labels(n: Node) -> Array[Label]:
var out: Array[Label] = []
for c in n.get_children():
if c is Label:
out.append(c)
out.append_array(_all_labels(c))
return out
func _layout() -> void:
var vp := get_viewport_rect().size if is_inside_tree() else Vector2(1280, 720)
_glass.size = Vector2(vp.x * WIDTH_FRAC, vp.y * HEIGHT_FRAC)
_glass.position = Vector2(24, 24)
func _label(text: String, size: int, col: Color, title := false) -> Label:
var l := Label.new()
l.text = text
l.add_theme_font_override("font", NeonTheme.title_font() if title else NeonTheme.mono_font())
l.add_theme_font_size_override("font_size", size)
l.add_theme_color_override("font_color", col)
return l
func _bar(frac: float, ghost: float, col: Color) -> Control:
var bar := Control.new()
bar.custom_minimum_size = Vector2(0, 5)
bar.draw.connect(func() -> void:
var w := bar.size.x
bar.draw_rect(Rect2(0, 0, w, 5), Color(1, 1, 1, 0.10))
if ghost > frac:
bar.draw_rect(Rect2(0, 0, w * clampf(ghost, 0, 1), 5), Color(GOOD.r, GOOD.g, GOOD.b, 0.45))
bar.draw_rect(Rect2(0, 0, w * clampf(frac, 0, 1), 5), col))
return bar
func _rebuild() -> void:
if _body == null or _sim == null:
return
for c in _body.get_children():
c.queue_free()
var pv := "" if read_only else _preview
var left := VBoxContainer.new()
left.size_flags_horizontal = Control.SIZE_EXPAND_FILL
left.add_theme_constant_override("separation", 3)
_body.add_child(left)
left.add_child(_label("SHIP", 13, Tokens.ACCENT, true))
for r: Dictionary in ship_rows(_sim, _pilot, pv):
var row := HBoxContainer.new()
var name_l := _label(String(r["name"]), 15, Tokens.TEXT_DIM)
name_l.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(name_l)
var changed := String(r["after"]) != ""
row.add_child(_label(String(r["value"]) + ((" → " + String(r["after"])) if changed else ""), 15,
GOOD if changed else (Tokens.GOLD if String(r["field"]) == "dps" else Tokens.TEXT)))
left.add_child(row)
if float(r["frac"]) >= 0.0:
left.add_child(_bar(float(r["frac"]), -1.0, Tokens.HP))
var crows := crystal_rows(_sim, _pilot, pv)
if not crows.is_empty():
left.add_child(_label("CRYSTALS", 13, Tokens.ACCENT, true))
for c: Dictionary in crows:
var el := String(c["element"])
var col: Color = ELEMENT_COLORS.get(el, Tokens.TEXT)
var nxt := int(c["next"])
var have := int(c["have"])
var aft := int(c["after"])
var txt := "%s %d" % [el.to_upper(), have]
if aft != have:
txt += " → %d" % aft
if nxt > 0:
txt += " / %d" % nxt
left.add_child(_label(txt, 14, GOOD if aft != have else col))
if nxt > 0:
left.add_child(_bar(float(have) / nxt, float(aft) / nxt, col))
var right := VBoxContainer.new()
right.size_flags_horizontal = Control.SIZE_EXPAND_FILL
right.add_theme_constant_override("separation", 4)
_body.add_child(right)
right.add_child(_label("WEAPONS", 13, Tokens.ACCENT, true))
var rows := weapon_rows(_sim, _pilot, pv)
for i in rows.size():
var w: Dictionary = rows[i]
var col := Tokens.TEXT_DIM if bool(w["empty"]) else (EVOLVE if bool(w["evolved"]) else Tokens.TEXT)
if bool(w["ghost"]) or String(w["flag"]) != "":
col = GOOD
var mark := "▸ " if (i == weapon_sel and not bool(w["empty"]) and not bool(w["ghost"])) else " "
var txt := mark + String(w["name"])
if String(w["flag"]) != "":
txt += " " + String(w["flag"])
right.add_child(_label(txt, 15, col))
var owned := _pilot.arsenal.active_weapon_ids
if weapon_sel < owned.size():
var detail := WeaponDetailView.build(_sim.upgrade_system.weapon_detail(_sim, _pilot, String(owned[weapon_sel])))
right.add_child(detail)
_layout()
  • Step 4: Run — PASS. The preview test matches the crystal line format COLD 0 → 5 / 5; if it fails, print text_dump() and fix the format string, not the assertion’s intent (the after total must be visible).
  • Step 5: Gate + commit — feat(ui): StatsSheet glass view with live preview + weapon path

Task 5: LevelUpBase + keep the old panel as LegacyLevelUpPanel

Section titled “Task 5: LevelUpBase + keep the old panel as LegacyLevelUpPanel”

Files:

  • Create: ui/level_up_base.gd
  • Create: ui/level_up_panel_legacy.gd (verbatim copy of today’s ui/level_up_panel.gd, class renamed)
  • Move: tests/test_level_up_panel.gd → tests/test_level_up_panel_legacy.gd (every LevelUpPanel.new() → LegacyLevelUpPanel.new())
  • Modify: main.gd:44 and main.gd:1053

Interfaces:

  • Produces: class_name LevelUpBase extends CanvasLayer with signal chosen(id: String), var audio: AudioManager, func show_for(sim: Sim, pilot: PlayerState, ids: Array, allowed_device: int = -1, pilot_idx: int = 0) -> void and func hide_panel() -> void (both push_error + no-op in the base). LegacyLevelUpPanel extends LevelUpBase. main.level_up: LevelUpBase.

  • Step 1: ui/level_up_base.gd:

class_name LevelUpBase
extends CanvasLayer
# Shared contract for the two level-up panels (liquid-glass LevelUpPanel and the pre-2026-09-24
# LegacyLevelUpPanel, picked by VisualLook.glass_ui in main.gd) so main holds either one.
signal chosen(id: String)
var audio: AudioManager
func show_for(_sim: Sim, _pilot: PlayerState, _ids: Array, _allowed_device: int = -1, _pilot_idx: int = 0) -> void:
push_error("LevelUpBase.show_for must be overridden")
func hide_panel() -> void:
push_error("LevelUpBase.hide_panel must be overridden")
  • Step 2: git mv ui/level_up_panel.gd ui/level_up_panel_legacy.gd then in it: class_name LevelUpPanel → class_name LegacyLevelUpPanel, extends CanvasLayer → extends LevelUpBase, delete its own signal chosen(id: String) line and its var audio: AudioManager line (both now inherited). Remove the stale .uid rename issue by running godot --headless --path . --import once.
  • Step 3: git mv tests/test_level_up_panel.gd tests/test_level_up_panel_legacy.gd; sed -i '' 's/LevelUpPanel.new()/LegacyLevelUpPanel.new()/' tests/test_level_up_panel_legacy.gd. Add at the top of every test body nothing else — the legacy panel doesn’t read VisualLook.glass_ui.
  • Step 4: Create a temporary ui/level_up_panel.gd so the project still parses until Task 6 replaces it:
class_name LevelUpPanel
extends LegacyLevelUpPanel
# Temporary shim (Task 5 of the liquid-glass core plan); replaced by the glass panel in Task 6.
  • Step 5: main.gd:44 var level_up: LevelUpPanel → var level_up: LevelUpBase; main.gd:1053 level_up = LevelUpPanel.new() → level_up = LevelUpPanel.new() if VisualLook.glass_ui else LegacyLevelUpPanel.new().
  • Step 6: Gate (suite must be fully green: the legacy tests now run against LegacyLevelUpPanel) + commit — refactor(ui): LevelUpBase; keep the pre-glass panel as LegacyLevelUpPanel

Task 6: Glass level-up panel (layout C) with the stats sheet

Section titled “Task 6: Glass level-up panel (layout C) with the stats sheet”

Files:

  • Replace: ui/level_up_panel.gd (overwrite the Task 5 shim)
  • Test: tests/test_level_up_panel.gd (new)

Interfaces:

  • Consumes: LevelUpBase, GlassPanel, StatsSheet (Tasks 1, 3, 4), upgrade_system.rank_upgrades, upgrade_system.upgrade_effects, Platform.is_touch(), MenuNav.is_left/right/up/down.
  • Produces: class_name LevelUpPanel extends LevelUpBase; test-visible fields _cards: Array[GlassPanel], _sel: int, _sheet: StatsSheet, _header: Label, _gate_device: int, _gate_p1: bool; methods _event_allowed(event) -> bool, _move_sel(step), toggle_sheet(), card_text(i: int) -> String.

Layout (1280×720 base): a full-rect ColorRect gradient dim (shader-free: a TextureRect with a GradientTexture2D, transparent at y=0.40 → Color(0,0.01,0.03,0.88) at y=0.72 and below); header Label centred at y≈ 0.47·H (LEVEL %d · CHOOSE ONE, co-op: PLAYER %d · LEVEL %d); an HBoxContainer anchored bottom with 5% side margins, bottom offset 64, height 170, separation 2% of width, holding up to 3 GlassPanel cards (frosted, SIZE_EXPAND_FILL, first card stretch ratio 1.1); a hint Label centred at bottom offset 26; the StatsSheet added last (above the cards). Card accents: crystal Color(0.6,0.35,1.0), weapon Tokens.GOLD, stat/mod Tokens.ACCENT. A card holds a VBoxContainer: kind label (13, accent, title font, letter-spaced via uppercase), name (24, title font, white), up to 3 change lines (17, mono): each target: detail in Color(0.4,1,0.6) (EVOLVE lines Color(1,0.75,0.25)), or for a dead crystal card its first two progress lines %s %d/%d → %s in Tokens.TEXT_DIM. The top-ranked card (index 0) gets a BEST pill: a small GlassPanel (clear, accent cyan, focus 1, margin 4) with a 11px label, anchored top-right of the card with offset (-10, -10).

Focus visuals: the focused card focus = 1.0, scale tween to 1.03 (pivot = card centre), others focus = 0.15, scale 1.0.

Input (in _input, after the device gate, same debounce NAV_DEBOUNCE_MS := 200):

  • left/right AND up/down → _move_sel(±1) (left/up = -1).

  • confirm (ui_accept / JOY_BUTTON_A) → chosen.emit(id).

  • Y (JOY_BUTTON_Y) / Tab (KEY_TAB) → toggle_sheet(); B (JOY_BUTTON_B) / Esc-less: KEY_BACKSPACE → close the sheet if open (never closes the panel).

  • shoulders (JOY_BUTTON_LEFT_SHOULDER/RIGHT_SHOULDER) or KEY_PAGEUP/KEY_PAGEDOWN → _sheet.move_weapon(∓1/±1) when the sheet is open.

  • every handled event → get_viewport().set_input_as_handled().

  • touch: a STATS Button (only when Platform.is_touch()), bottom-right, calls toggle_sheet().

  • On focus change: _sheet.set_preview(id) (the sheet re-renders even while closed so opening is instant).

  • Hint text: "Ⓐ CHOOSE Ⓨ SHIP STATS" joypad, "ENTER CHOOSE TAB SHIP STATS" when Platform.has_mouse() and no joypads (Input.get_connected_joypads().is_empty()), empty string on touch.

  • Entry animation on show_for: each card starts at modulate.a = 0, position.y += 40 relative, tweens in over 0.22s with 0.06s stagger.

  • Mouse: card gui_input click → select + confirm; P2-gated panels set every card’s mouse_filter = MOUSE_FILTER_IGNORE (same rule as legacy).

  • Step 1: Failing test — tests/test_level_up_panel.gd

extends GutTest
func _open(ids: Array, device := -1, pilot_idx := 0, coop := false) -> Array:
var sim := Sim.new(1, SimContentFixture.db())
sim.enable_survival()
sim.upgrade_system.grant_weapon(sim, sim.player, "orbit")
if coop:
sim.add_pilot()
var panel := LevelUpPanel.new()
add_child_autofree(panel)
await get_tree().process_frame
var pilot: PlayerState = sim.player2 if pilot_idx == 1 else sim.player
panel.show_for(sim, pilot, ids, device, pilot_idx)
await get_tree().process_frame
return [sim, panel]
func test_one_glass_card_per_choice_best_first() -> void:
var r: Array = await _open(["crystals:cold=1", "crystals:void=18", "weapon:nova"])
var panel: LevelUpPanel = r[1]
assert_eq(panel._cards.size(), 3)
assert_true(panel._cards[0] is GlassPanel)
assert_eq(String(panel._cards[0].get_meta("id", "")), "crystals:void=18", "ranked best first")
assert_true(panel.card_text(0).contains("BEST"), "only the top card carries the BEST pill")
assert_false(panel.card_text(1).contains("BEST"))
func test_card_carries_its_own_changes_or_progress() -> void:
var r: Array = await _open(["crystals:cold=1"])
var panel: LevelUpPanel = r[1]
assert_true(panel.card_text(0).contains("1/5"), "dead-but-building crystal shows progress: %s" % panel.card_text(0))
func test_moving_focus_updates_the_sheet_preview() -> void:
var r: Array = await _open(["weapon:nova", "crystals:cold=5"])
var panel: LevelUpPanel = r[1]
panel.toggle_sheet()
assert_true(panel._sheet.is_open())
var first := panel._sheet.text_dump()
panel._move_sel(1)
await get_tree().process_frame
assert_ne(panel._sheet.text_dump(), first, "focusing another card re-renders the sheet")
panel.toggle_sheet()
assert_false(panel._sheet.is_open())
func test_left_right_and_up_down_both_move_focus() -> void:
var r: Array = await _open(["armor", "weapon:nova", "crystals:cold=5"])
var panel: LevelUpPanel = r[1]
panel._move_sel(1)
assert_eq(panel._sel, 1)
panel._move_sel(-1)
panel._move_sel(-1)
assert_eq(panel._sel, 2, "wraps")
func test_single_player_header_has_no_pilot_prefix() -> void:
var r: Array = await _open(["armor"])
var panel: LevelUpPanel = r[1]
assert_false(panel._header.text.contains("PLAYER"))
assert_eq(panel._gate_device, -1)
func test_coop_p2_header_and_device_gate() -> void:
var r: Array = await _open(["armor"], 7, 1, true)
var panel: LevelUpPanel = r[1]
assert_true(panel._header.text.begins_with("PLAYER 2"))
var own := InputEventJoypadButton.new(); own.device = 7; own.button_index = JOY_BUTTON_A
var other := InputEventJoypadButton.new(); other.device = 0; other.button_index = JOY_BUTTON_A
var kbd := InputEventKey.new(); kbd.keycode = KEY_ENTER
assert_true(panel._event_allowed(own))
assert_false(panel._event_allowed(other))
assert_false(panel._event_allowed(kbd), "keyboard never drives a P2 panel")
for c in panel._cards:
assert_eq(c.mouse_filter, Control.MOUSE_FILTER_IGNORE, "P2 cards ignore mouse")
func test_p1_coop_panel_allows_keyboard_and_mouse() -> void:
var r: Array = await _open(["armor"], 3, 0, true)
var panel: LevelUpPanel = r[1]
var kbd := InputEventKey.new(); kbd.keycode = KEY_ENTER
assert_true(panel._event_allowed(kbd))
for c in panel._cards:
assert_ne(c.mouse_filter, Control.MOUSE_FILTER_IGNORE)
func test_confirm_emits_chosen_for_focused_card() -> void:
var r: Array = await _open(["armor", "weapon:nova"])
var panel: LevelUpPanel = r[1]
watch_signals(panel)
panel._move_sel(1)
var a := InputEventJoypadButton.new(); a.button_index = JOY_BUTTON_A; a.pressed = true
panel._input(a)
assert_signal_emitted_with_parameters(panel, "chosen", [String(panel._cards[1].get_meta("id"))])
func test_y_toggles_sheet_and_b_only_closes_it() -> void:
var r: Array = await _open(["armor"])
var panel: LevelUpPanel = r[1]
var y := InputEventJoypadButton.new(); y.button_index = JOY_BUTTON_Y; y.pressed = true
panel._input(y)
assert_true(panel._sheet.is_open())
var b := InputEventJoypadButton.new(); b.button_index = JOY_BUTTON_B; b.pressed = true
panel._input(b)
assert_false(panel._sheet.is_open())
assert_true(panel.visible, "B never closes the level-up panel itself")
  • Step 2: Run — FAIL (shim has no _sheet/card_text).

  • Step 3: Implement ui/level_up_panel.gd:

class_name LevelUpPanel
extends LevelUpBase
# Liquid-glass level-up (spec §2, layout C — Chris, 2026-09-24): three glass cards in a strip along
# the bottom, the frozen arena readable above, each card carrying its own changes. Y opens the
# StatsSheet, which previews the focused card live. The pre-glass panel is LegacyLevelUpPanel.
const NAV_DEBOUNCE_MS := 200
const ACCENT_CRYSTAL := Color(0.6, 0.35, 1.0)
const GOOD := Color(0.4, 1.0, 0.6)
const EVOLVE := Color(1.0, 0.75, 0.25)
const STRIP_H := 170.0
const STRIP_BOTTOM := 64.0
var _cards: Array[GlassPanel] = []
var _sel := 0
var _last_nav_ms := 0
var _sheet: StatsSheet
var _header: Label
var _hint: Label
var _strip: HBoxContainer
var _stats_btn: Button
var _sim: Sim
var _pilot: PlayerState
var _effects: Dictionary = {}
var _gate_device := -1
var _gate_p1 := true
func _ready() -> void:
layer = 10
var dim := TextureRect.new()
var grad := Gradient.new()
grad.set_offset(0, 0.40); grad.set_color(0, Color(0, 0.01, 0.03, 0.0))
grad.set_offset(1, 0.72); grad.set_color(1, Color(0, 0.01, 0.03, 0.88))
var gt := GradientTexture2D.new()
gt.gradient = grad
gt.fill_from = Vector2(0, 0)
gt.fill_to = Vector2(0, 1)
dim.texture = gt
dim.stretch_mode = TextureRect.STRETCH_SCALE
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
dim.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(dim)
_header = Label.new()
_header.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
_header.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_header.grow_horizontal = Control.GROW_DIRECTION_BOTH
_header.offset_top = -(STRIP_BOTTOM + STRIP_H + 64)
_header.offset_bottom = -(STRIP_BOTTOM + STRIP_H + 24)
_header.add_theme_font_override("font", NeonTheme.title_font())
_header.add_theme_font_size_override("font_size", 26)
_header.add_theme_color_override("font_color", Color(0.88, 0.97, 1.0))
_header.add_theme_color_override("font_outline_color", Color(0.2, 0.9, 1.0, 0.35))
_header.add_theme_constant_override("outline_size", 6)
add_child(_header)
_strip = HBoxContainer.new()
_strip.anchor_left = 0.05
_strip.anchor_right = 0.95
_strip.anchor_top = 1.0
_strip.anchor_bottom = 1.0
_strip.offset_top = -(STRIP_BOTTOM + STRIP_H)
_strip.offset_bottom = -STRIP_BOTTOM
_strip.add_theme_constant_override("separation", 26)
add_child(_strip)
_hint = Label.new()
_hint.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
_hint.grow_horizontal = Control.GROW_DIRECTION_BOTH
_hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_hint.offset_top = -40
_hint.offset_bottom = -18
_hint.add_theme_font_override("font", NeonTheme.mono_font())
_hint.add_theme_font_size_override("font_size", 14)
_hint.add_theme_color_override("font_color", Color(0.72, 0.8, 0.9))
add_child(_hint)
if Platform.is_touch():
_stats_btn = Button.new()
_stats_btn.text = "STATS"
_stats_btn.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
_stats_btn.offset_left = -130
_stats_btn.offset_top = -52
_stats_btn.offset_right = -24
_stats_btn.offset_bottom = -14
_stats_btn.theme = NeonTheme.get_theme()
_stats_btn.pressed.connect(toggle_sheet)
add_child(_stats_btn)
_sheet = StatsSheet.new()
add_child(_sheet)
hide_panel()
func show_for(sim: Sim, pilot: PlayerState, ids: Array, allowed_device: int = -1, pilot_idx: int = 0) -> void:
_sim = sim
_pilot = pilot
_gate_device = allowed_device
_gate_p1 = pilot_idx == 0
for c in _strip.get_children():
c.queue_free()
_cards.clear()
_effects.clear()
var ranked: Array[String] = sim.upgrade_system.rank_upgrades(sim, pilot, ids)
for id in ranked:
_effects[id] = sim.upgrade_system.upgrade_effects(sim, pilot, id)
var coop := sim.pilots.size() > 1
_header.text = ("PLAYER %d · LEVEL %d" % [pilot_idx + 1, pilot.level]) if coop \
else "LEVEL %d · CHOOSE ONE" % pilot.level
_header.add_theme_color_override("font_color",
(Color(1.0, 0.75, 0.2) if _gate_p1 else Color(0.55, 0.85, 1.0)) if coop else Color(0.88, 0.97, 1.0))
for i in ranked.size():
var card := _make_card(ranked[i], i)
_strip.add_child(card)
_cards.append(card)
_hint.text = _hint_text()
_sheet.close()
_sheet.read_only = false
_sheet.bind(sim, pilot)
visible = true
_last_nav_ms = 0
_sel = 0
_focus(0)
_animate_in()
func hide_panel() -> void:
visible = false
if _sheet != null:
_sheet.close()
_cards.clear()
_sim = null
_pilot = null
_gate_device = -1
_gate_p1 = true
func toggle_sheet() -> void:
if _sheet.is_open():
_sheet.close()
else:
_sheet.open()
_ui_nav()
func card_text(i: int) -> String:
var s := ""
var stack: Array[Node] = [_cards[i]]
while not stack.is_empty():
var n: Node = stack.pop_back()
if n is Label:
s += (n as Label).text + "\n"
stack.append_array(n.get_children(true)) # include INTERNAL children (the BEST pill is one)
return s
func _hint_text() -> String:
if Platform.is_touch():
return ""
if Input.get_connected_joypads().is_empty() and Platform.has_mouse():
return "ENTER CHOOSE TAB SHIP STATS"
return "Ⓐ CHOOSE Ⓨ SHIP STATS"
func _accent(kind: String) -> Color:
match kind:
"crystal": return ACCENT_CRYSTAL
"weapon": return Tokens.GOLD
_: return Tokens.ACCENT
func _kind_label(id: String, kind: String) -> String:
match kind:
"crystal": return "CRYSTALS"
"weapon": return "NEW WEAPON"
"mod": return "MOD"
if id.begins_with("wm:"):
return id.split(":")[1].to_upper() + " MOD"
return "SHIP"
func _lbl(text: String, size: int, col: Color, title := false) -> Label:
var l := Label.new()
l.text = text
l.mouse_filter = Control.MOUSE_FILTER_IGNORE
l.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
l.add_theme_font_override("font", NeonTheme.title_font() if title else NeonTheme.mono_font())
l.add_theme_font_size_override("font_size", size)
l.add_theme_color_override("font_color", col)
return l
func _make_card(id: String, rank: int) -> GlassPanel:
var eff: Dictionary = _effects.get(id, {})
var kind := String(eff.get("kind", "stat"))
var accent := _accent(kind)
var card := GlassPanel.new()
card.frosted = true
card.accent = accent
card.margin = 16.0
card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
card.size_flags_stretch_ratio = 1.1 if rank == 0 else 1.0
card.set_meta("id", id)
card.mouse_filter = Control.MOUSE_FILTER_IGNORE if (_gate_device != -1 and not _gate_p1) else Control.MOUSE_FILTER_STOP
card.gui_input.connect(func(e: InputEvent) -> void:
if e is InputEventMouseButton and e.pressed and e.button_index == MOUSE_BUTTON_LEFT:
_focus(_cards.find(card))
_confirm())
var box := VBoxContainer.new()
box.mouse_filter = Control.MOUSE_FILTER_IGNORE
box.add_theme_constant_override("separation", 4)
card.add_child(box)
box.add_child(_lbl(_kind_label(id, kind), 13, accent, true))
box.add_child(_lbl(String(eff.get("headline", id)), 24, Color.WHITE, true))
var changes: Array = eff.get("changes", [])
if bool(eff.get("dead", false)) or changes.is_empty():
var progress: Array = eff.get("progress", [])
for p: Dictionary in progress.slice(0, 2):
box.add_child(_lbl("%s %d/%d → %s" % [String(p.get("target", "")).capitalize(),
int(p.get("have", 0)), int(p.get("need", 0)), String(p.get("effect", ""))], 17, Tokens.TEXT_DIM))
else:
for ch: Dictionary in changes.slice(0, 3):
var ev := bool(ch.get("evolve", false))
box.add_child(_lbl("%s: %s" % [String(ch.get("target", "")).capitalize(), String(ch.get("detail", ""))],
17, EVOLVE if ev else GOOD))
if rank == 0 and _cards.is_empty() and _effects.size() > 1:
var pill := GlassPanel.new()
pill.accent = Tokens.ACCENT
pill.focus = 1.0
pill.margin = 4.0
pill.add_child(_lbl("BEST", 11, Color(0.01, 0.08, 0.12), true))
pill.set_anchors_preset(Control.PRESET_TOP_RIGHT)
pill.position = Vector2(-10, -10)
card.add_child(pill, false, Node.INTERNAL_MODE_BACK) # internal: not laid out by the card
return card
func _focus(i: int) -> void:
if _cards.is_empty():
return
_sel = clampi(i, 0, _cards.size() - 1)
for k in _cards.size():
var c := _cards[k]
c.focus = 1.0 if k == _sel else 0.15
c.pivot_offset = c.size * 0.5
c.scale = Vector2.ONE * (1.03 if k == _sel else 1.0)
_sheet.set_preview(String(_cards[_sel].get_meta("id", "")))
func _move_sel(step: int) -> void:
if _cards.is_empty():
return
_focus(wrapi(_sel + step, 0, _cards.size()))
_ui_nav()
func _confirm() -> void:
if _cards.is_empty():
return
_ui_nav()
chosen.emit(String(_cards[_sel].get_meta("id", "")))
func _animate_in() -> void:
for k in _cards.size():
var c := _cards[k]
c.modulate.a = 0.0
var tw := create_tween()
tw.tween_interval(0.06 * k)
tw.tween_property(c, "modulate:a", 1.0, 0.22)
func _event_allowed(event: InputEvent) -> bool:
if event is InputEventJoypadButton or event is InputEventJoypadMotion:
return event.device == _gate_device
return _gate_p1
func _input(event: InputEvent) -> void:
if not visible or _cards.is_empty():
return
if _gate_device != -1 and not _event_allowed(event):
return
var jb := event as InputEventJoypadButton
var key := event as InputEventKey
var jpress := jb != null and jb.pressed
var kpress := key != null and key.pressed and not key.echo
if (jpress and jb.button_index == JOY_BUTTON_Y) or (kpress and key.keycode == KEY_TAB):
toggle_sheet()
get_viewport().set_input_as_handled()
return
if (jpress and jb.button_index == JOY_BUTTON_B) or (kpress and key.keycode == KEY_BACKSPACE):
if _sheet.is_open():
_sheet.close()
get_viewport().set_input_as_handled()
return
if _sheet.is_open() and ((jpress and jb.button_index in [JOY_BUTTON_LEFT_SHOULDER, JOY_BUTTON_RIGHT_SHOULDER]) \
or (kpress and key.keycode in [KEY_PAGEUP, KEY_PAGEDOWN])):
var fwd := (jpress and jb.button_index == JOY_BUTTON_RIGHT_SHOULDER) or (kpress and key.keycode == KEY_PAGEDOWN)
_sheet.move_weapon(1 if fwd else -1)
get_viewport().set_input_as_handled()
return
var confirm := event.is_action_pressed("ui_accept") or (jpress and jb.button_index == JOY_BUTTON_A)
if confirm:
_confirm()
get_viewport().set_input_as_handled()
return
var fwd_nav := MenuNav.is_right(event) or MenuNav.is_down(event)
var back_nav := MenuNav.is_left(event) or MenuNav.is_up(event)
if not (fwd_nav or back_nav):
return
var now := Time.get_ticks_msec()
if now - _last_nav_ms >= NAV_DEBOUNCE_MS:
_last_nav_ms = now
_move_sel(1 if fwd_nav else -1)
get_viewport().set_input_as_handled()
func _ui_nav() -> void:
if audio != null:
audio.ui_nav()

Notes for the implementer:

  • _confirm calls _ui_nav() because the legacy _ui_select() also called audio.ui_nav() — same sound.

  • The BEST pill check _cards.is_empty() is true while building card 0 (it’s appended after _make_card returns). _effects.size() > 1 hides the pill when only one card is offered.

  • The BEST pill is an INTERNAL child so GlassPanel’s sort doesn’t stretch it; set its size = pill.get_combined_minimum_size() after adding, and anchor by position = Vector2(card.size.x - pill.size.x + 10, -10) inside the card’s resized signal.

  • Step 4: Run — PASS, then the full gate (legacy tests must still pass).

  • Step 5: ui-sweep. zsh scripts/ui-sweep.sh; the level_up capture will differ (expected). Open tools/ui_sweep/out/level_up.png and check: three cards bottom-third, arena visible above, card text not clipped, BEST pill on card 1, hint line readable. Only then copy it to tools/ui_sweep/baseline/level_up.png. Add a new sweep screen level_up_stats in tools/ui_sweep/sweep.gd’s screens list right after level_up:

["level_up_stats", func() -> void: await _stage_level_up(); main.level_up.toggle_sheet()],

and in _reset_between treat "level_up_stats" exactly like "level_up" (add it to that match arm). Eyeball out/level_up_stats.png (sheet top-left, cards live below, preview lines green) and bless it.

  • Step 6: Restore project.godot, gate + commit — feat(ui): liquid-glass level-up (bottom strip) with Y ship-stats sheet

Task 7: Glass pause menu + read-only stats sheet

Section titled “Task 7: Glass pause menu + read-only stats sheet”

Files:

  • Modify: ui/pause_menu.gd
  • Modify: main.gd (connect the new signal)
  • Test: tests/test_pause_menu.gd (append)

Interfaces:

  • Consumes: GlassPanel, StatsSheet.

  • Produces: signal stats_requested on PauseMenu is NOT needed — the sheet lives inside the pause menu. New: var _sheet: StatsSheet, func bind_stats(sim: Sim, pilot: PlayerState) -> void (main calls it when opening the pause menu), a Ship stats button second in the list.

  • Step 1: Failing tests — append to tests/test_pause_menu.gd:

func test_glass_pause_has_ship_stats_second() -> void:
var menu := PauseMenu.new()
add_child_autofree(menu)
await get_tree().process_frame
assert_eq(menu._buttons[1].text, "Ship stats")
func test_ship_stats_opens_read_only_sheet_and_b_closes_it() -> void:
var sim := Sim.new(1, SimContentFixture.db())
sim.enable_survival()
var menu := PauseMenu.new()
add_child_autofree(menu)
await get_tree().process_frame
menu.bind_stats(sim, sim.player)
menu._buttons[1].emit_signal("pressed")
assert_true(menu._sheet.is_open())
assert_true(menu._sheet.read_only)
var b := InputEventJoypadButton.new(); b.button_index = JOY_BUTTON_B; b.pressed = true
menu._input(b)
assert_false(menu._sheet.is_open())
assert_true(menu.visible, "B closes the sheet, not the pause menu")

(If PauseMenu’s class name differs, read the top of ui/pause_menu.gd and use it.)

  • Step 2: Run — FAIL.
  • Step 3: Implement in ui/pause_menu.gd:
    • Add var _sheet: StatsSheet.
    • In _ready(), when VisualLook.glass_ui: replace the CenterContainer’s direct box parent with a GlassPanel (frosted, accent Tokens.ACCENT, focus 0.2, margin 28) holding box; set the dim ColorRect colour to Color(0.0, 0.01, 0.03, 0.55) (the frost does the separating now); title font size 44; buttons custom_minimum_size = Vector2(380, 48).
    • Insert after Resume: box.add_child(_make_btn("Ship stats", func() -> void: _open_stats(), Color(0.55, 0.85, 1.0))).
    • Rename the Back to Start Menu button text to Quit to menu.
    • Dev-only buttons (Arm Remote Control, Debug Settings) stay behind BuildConfig.dev_tools() as now, but get modulate.a = 0.6; the _code_label moves below the buttons (above the build label) so the header is title + time only.
    • After the box: _sheet = StatsSheet.new(); _sheet.read_only = true; add_child(_sheet).
    • func bind_stats(sim: Sim, pilot: PlayerState) -> void: _sheet.bind(sim, pilot).
    • func _open_stats() -> void: _sheet.open().
    • In _btn_box, when VisualLook.glass_ui, return a StyleBoxEmpty for every state and instead wrap each button: simplest compliant approach — keep the Button but set its styleboxes to StyleBoxEmpty and add a GlassPanel (clear, not frosted, accent = the button’s accent, margin 0) as an INTERNAL front child of the Button sized to it on resized; on focus_entered/focus_exited set that panel’s focus to 1.0 / 0.0. Put this in a helper _glass_skin(b: Button, accent: Color) called from _make_btn when VisualLook.glass_ui.
    • In _input, first thing after the visibility check: if _sheet != null and _sheet.is_open(): B / Y / Backspace / Tab close the sheet; shoulders / PageUp / PageDown call _sheet.move_weapon; everything else (joypad/key) is consumed; return.
    • The Remote-control set_code text stays as is.
  • Step 4: In main.gd, find where the pause menu is shown (_toggle_pause) and, right after it sets the run time (pause_menu.set_run_time(...)), add pause_menu.bind_stats(sim, sim.player).
  • Step 5: Run — PASS; full gate.
  • Step 6: ui-sweep — eyeball out/pause.png (glass column, title + time only in the header, Ship stats second) and bless.
  • Step 7: Commit — feat(ui): glass pause menu with read-only ship stats

Task 8: Debug toggle, docs, on-device check

Section titled “Task 8: Debug toggle, docs, on-device check”

Files:

  • Modify: ui/debug_settings_panel.gd (a glass_ui row beside nova_look)

  • Modify: docs/RELEASE-0.1.md, docs/architecture/rendering-and-performance.md, CLAUDE.md (Current status, one condensed bullet, update in place)

  • Step 1: In debug_settings_panel.gd, find the "nova_look" toggle row definition (search for nova_look — it appears in the rows list, in apply_graphics_toggle, and in the _graphics_state seeding). Add a "glass_ui" row next to each: label Liquid-glass UI, apply_graphics_toggle case "glass_ui": VisualLook.glass_ui = enabled # takes effect next time a screen opens, seeding _graphics_state["glass_ui"] = VisualLook.glass_ui. Main’s level-up panel is built once at boot, so note in the row label comment that the level-up swap needs an app restart.

  • Step 2: docs/architecture/rendering-and-performance.md: a short “Liquid-glass UI” section — GlassPanel, frost vs clear, the paused-only rule, the screen-copy-once behaviour, where the shaders live.

  • Step 3: On-device frame check. Build to the Living Room Apple TV via the bh-deploy skill (BUILD bump), open a level-up with the sheet open, leave it 30s, then query telemetry for that build’s samples while paused (bh-telemetry skill). Record frame time in RELEASE-0.1.md’s 4th-exception entry. Paused frames carry no swarm cost, so anything under the 45 fps target on this screen is a real finding to report to Chris, not to tune silently.

  • Step 4: Update the 4th-exception entry in RELEASE-0.1.md with the commits, and CLAUDE.md’s Current status (one bullet, in place).

  • Step 5: Gate + commit + push — docs: liquid-glass UI core shipped (Plan 1)


  • Spec coverage: §1 → Task 1; §2 layout + dud filter → Tasks 2, 5, 6; §3 → Tasks 3, 4, 6, 7; §4 pause → Task 7 (other screens → Plan 2, stated in the header); §5 perf → Task 1 (clear variant), Task 8 (device check); §6 testing → every task; §7 → Task 8.
  • “Multiple frosted panels share one screen copy”: Godot copies the screen once for the first screen-reading item drawn and reuses it for later ones in the same frame, so no BackBufferCopy is added. If Task 1’s preview shows a later panel NOT blurring earlier panels’ content, that’s this behaviour and is intended.
  • Interface names cross-checked: StatsSheet.ship_rows/crystal_rows/weapon_rows/bind/set_preview/open/close/is_open/move_weapon/text_dump/read_only/weapon_sel, GlassPanel.accent/focus/frosted/dim/margin/backdrop/GLOW_PAD/FROST_SHADER/CLEAR_SHADER, LevelUpPanel._cards/_sel/_sheet/_header/toggle_sheet/card_text/_move_sel/_event_allowed, upgrade_system.is_dud_crystal/roll_live_crystal_grant.