Skip to content

Liquid-glass UI — Plan 1b: TV follow-ups (perf, button glyphs, upgrade clarity)

Liquid-glass UI — Plan 1b (TV follow-ups) Implementation Plan

Section titled “Liquid-glass UI — Plan 1b (TV follow-ups) Implementation Plan”

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (- [ ]) syntax.

Goal: Fix the three things Chris found on the Apple TV with BUILD 188 (2026-09-24): the level-up screen runs at 24-31 fps, the Ⓐ/Ⓑ/Ⓨ prompt glyphs don’t render, and the upgrade cards don’t explain their value (BEST is too crude; crystals should read as the stronger long-term pick; every card needs one plain sentence saying what it does for you).

Architecture: (1) Frosted glass samples a ONE-TIME blurred snapshot of the frozen frame instead of re-reading + mip-building the live screen every frame. (2) Button prompts are drawn as vector shapes by a ButtonGlyph control, not font characters. (3) upgrade_effects() gains a better long-term score for crystals; the card shows a WHY tag instead of BEST, and a benefit sentence from a new UpgradeCopy table.

Tech Stack: Godot 4.7 typed GDScript, GUT.

Spec: docs/superpowers/specs/2026-09-24-liquid-glass-ui-redesign-design.md (parent). Chris’s approval for this plan, 2026-09-24: “yes also can we add a simple to understand sentence that explains the benefit of the upgrade”.

Evidence (BUILD 188 telemetry, Chris’s session, Living Room Apple TV)

Section titled “Evidence (BUILD 188 telemetry, Chris’s session, Living Room Apple TV)”
Screen fps render_ms
level-up open (sim frozen, sim_ms 0.2) 24-31 32-41
pause menu (frozen, one frosted column) 50 ~20
live play 150-200 enemies 36-43 ~20-25 (normal)
  • Branch/worktree + commit rules as in .superpowers/sdd/<plan>/implementer-rules.md (copied from Plan 1). No push by implementers.
  • VisualLook.glass_ui = false must still render the legacy panels unchanged.
  • /sim stays pure. The only sim change is the score value in upgrade_system.upgrade_effects() (ranking only; rank_upgrades has no sim/AI caller, so determinism baselines must stay unchanged — verify both).
  • Every player-facing sentence must be TRUE to the sim (CLAUDE.md “Every player-facing claim … matches the sim”): read the effect’s implementation before writing its sentence. No em dashes in player-facing copy. Plain words, second person, one sentence, ≤ 70 characters where possible.
  • Card strip stays STRIP_H 200 at the 1280×720 base; the device-scale fit test must keep passing.
  • Device-scale visual checks: render at a 1280×720 base (the ui-sweep’s unstretched 1920×1080 capture hides overflow — see NEXT_TASKS.md).

Files: ui/theme/glass_panel.gd, new ui/theme/glass_snapshot.gdshader, ui/level_up_panel.gd, ui/stats_sheet.gd, ui/pause_menu.gd; tests tests/test_glass_panel.gd, tests/test_level_up_panel.gd, tests/test_pause_menu.gd.

Interfaces produced:

  • static func GlassPanel.snapshot_backdrop(vp: Viewport) -> ImageTexture — grabs vp.get_texture().get_image() (the last rendered frame, which the just-opened panel is not yet in), downsizes it to 1/8 of its size with Image.resize(..., Image.INTERPOLATE_BILINEAR), then back up to 1/4 (a cheap two-step blur), converts to RGBA8 if needed, returns ImageTexture.create_from_image(img). Returns null if the viewport has no image (headless).
  • var backdrop_texture: Texture2D on GlassPanel: when set on a frosted panel, the material switches to SNAPSHOT_SHADER (glass_snapshot.gdshader: same glass_common.gdshaderinc surface, base = texture(backdrop_tex, SCREEN_UV).rgb, same saturate ×1.35 and darken ×0.55 as the frost shader); when null, the existing live FROST_SHADER is used (fallback, unchanged).
  • func StatsSheet.set_backdrop(tex: Texture2D) passes it to the sheet’s glass.

Behaviour: LevelUpPanel.show_for takes one snapshot BEFORE making itself visible and assigns it to every frosted card and the sheet; PauseMenu does the same when it builds/opens. hide_panel clears the references (lets the texture free). If the snapshot is null (headless tests), panels fall back to the live frost shader.

  • Tests first: (a) setting backdrop_texture on a frosted panel switches the shader to SNAPSHOT_SHADER and binds backdrop_tex; clearing it restores FROST_SHADER; clear (unfrosted) panels ignore it. (b) snapshot_backdrop on a SubViewport that has rendered at least one frame returns a texture whose size is 1/4 of the viewport (±1). (c) LevelUpPanel: after show_for inside a rendered SubViewport, every card and the sheet share the same non-null backdrop_texture; after hide_panel all are null.
  • Implement; full gate.
  • Device-scale capture (1280×720 base, level-up + sheet open) — the glass must still look frosted (blurred colours behind), no black panels, no visible seam; Read it and describe.
  • Commit perf(ui): glass samples a one-time snapshot of the frozen frame.

Files: new ui/button_glyph.gd (class_name ButtonGlyph extends Control), new ui/button_prompt.gd (class_name ButtonPrompt, static builder), ui/level_up_panel.gd, ui/pause_menu.gd, ui/start_menu.gd; tests tests/test_button_prompt.gd + existing panel tests.

Interfaces produced:

  • ButtonGlyph: var button: String (“a”,“b”,“x”,“y”), var style: String (“xbox” | “playstation” | “key”), var key_text: String (for style “key”, e.g. “ENTER”, “TAB”, “BACKSPACE”). _draw() draws: xbox → filled circle (A green 0.36,0.8,0.36 / B red 0.9,0.3,0.3 / X blue 0.25,0.55,1.0 / Y yellow 1.0,0.8,0.2) with the letter centred in dark text using NeonTheme.title_font(); playstation → dark circle with a light outline and a vector shape (a=✕ two lines, b=○ circle, x=□ square, y=△ triangle; PS colours cross blue, circle red, square pink, triangle green); key → rounded rect keycap outline with key_text in mono. Size from custom_minimum_size (default 22×22 base; keycap width fits text). No font glyphs outside Basic Latin.
  • static func ButtonPrompt.style_for_device(device: int) -> String — “playstation” if Input.get_joy_name(device).to_lower() contains “playstation”, “dualshock”, “dualsense” or “ps4”/“ps5”; else “xbox”. static func ButtonPrompt.row(parts: Array) -> HBoxContainer where each part is [button, label_text, key_text]; builds glyph + label pairs with spacing; chooses keycaps when there is no connected playable joypad and Platform.has_mouse(); returns an empty, hidden row on touch (Platform.is_touch()).
  • Replace the Ⓐ/Ⓑ/Ⓨ text hints: level-up hint ([["a","CHOOSE","ENTER"],["y","SHIP STATS","TAB"]]), pause back hint ([["b","BACK","BACKSPACE"]], touch keeps its “TAP TO GO BACK” label), start menu (read its current Ⓐ usage and convert the same way).
  • Tests first: style_for_device name matching (use a pure static func style_for_name(name: String) so it’s testable without a pad); row() on touch returns a hidden row; row() builds one ButtonGlyph + one Label per part with the right button/key text; ButtonGlyph draws without error for every button × style (instantiate, add to tree, await a frame).
  • Implement; full gate; /usr/bin/grep -rn 'Ⓐ\|Ⓑ\|Ⓧ\|Ⓨ' ui main.gd must return nothing.
  • Device-scale capture of the level-up hint and pause back hint (force style “xbox” and “key” once each); Read and describe.
  • Commit feat(ui): vector button prompts (xbox/playstation/keycaps) replace font glyphs.

Files: sim/upgrade_system.gd (score only), ui/level_up_panel.gd; tests tests/test_upgrade_effects.gd (or a new tests/test_upgrade_ranking.gd), tests/test_level_up_panel.gd.

Scoring change (in upgrade_effects): a crystal grant that crosses nothing but has progress scores 9.0 + 8.0 * closeness, where closeness = max over its progress rows of have/need (0..1). Crossing rungs keeps 20 per rung / 100 per evolve. Everything else unchanged (stat/mod 8, weapon 15+). So a long-term crystal always outranks a plain stat, and a grant that nearly completes a rung can outrank a new weapon.

Tag (display, in LevelUpPanel) — replaces the BEST pill:

Condition (from upgrade_effects) Tag Colour
any change with evolve EVOLVES Tokens.GOLD
crystal with changes (crosses a rung now) UNLOCKS NOW Color(0.4,1,0.6)
crystal, no changes, has progress LONG-TERM the crystal violet lifted toward white
kind weapon NEW WEAPON Tokens.GOLD
stat / mod INSTANT Tokens.ACCENT
The tag replaces the kind label line (CRYSTALS/MOD/…) — one small glass pill at the card’s top-left inside the content, text 13. A separate small “RECOMMENDED” pill (the old BEST pill position) appears ONLY on the top-ranked card and only when its score beats the second card’s by ≥ 4.0 (a clear winner); otherwise no card is marked.
  • Tests first: (a) ranking: a long-term crystal (orbit owned, crystals:cold=3 → 3/5) outranks armor; (b) a crossing grant still outranks a long-term one; (c) tag per condition for each of the 5 rows via real upgrade_effects ids; (d) RECOMMENDED shown only when the score gap ≥ 4 (one test each side); (e) determinism survival + co-op unchanged (run them; report).
  • Implement; full gate; device-scale capture; commit feat(levelup): why-tags replace BEST; long-term crystals rank above plain stats.

Task 4: One-sentence benefit on every card

Section titled “Task 4: One-sentence benefit on every card”

Files: new ui/upgrade_copy.gd (class_name UpgradeCopy, static), ui/level_up_panel.gd; tests tests/test_upgrade_copy.gd, tests/test_level_up_panel.gd.

Interface: static func UpgradeCopy.sentence(sim: Sim, pilot: PlayerState, id: String, eff: Dictionary) -> String — one plain sentence, second person, no em dashes:

  • stat/mod ids: a per-effect template table (read each effect’s implementation in sim/ first; magnitudes from the content entry): e.g. armor “Every hit you take does %d less damage.”, max_hp “Adds %d hull, so you survive more hits.”, fire_rate_mult “All your weapons fire %d%% faster.”, move_speed “Your ship flies %d%% faster.”, pickup_radius “You pull in crystals from %d%% further away.”, crit_chance “Your shots get a %d%% chance to crit for bonus damage.”, projectiles_pierce “Your shots pass through %d more enemies.”, split_on_hit “Shots burst into %d fragments when they hit.”, lifesteal_per_kill “Every kill repairs %s hull.”, thrusters (verify what 0.85 does before writing). Any effect without a verified template returns “” (card shows no sentence) — never guess.
  • wm:<wid>:<kind> weapon mods: “Makes your <weapon_path_label lower-cased>.” or a per-kind table if that reads badly — read the labels.
  • crystal: from eff — evolve change → “Evolves into , a much stronger form.”; crossing change → “ gets
  • weapon: “Adds to your ship.” plus nothing invented about behaviour unless a verified per-weapon one-liner table exists; write the 16-weapon one-liner table ONLY from reading each sim/weapon_*.gd (e.g. “Adds Nova, a ring of fire that bursts around you.”). Layout: the sentence goes under the name in the card (15px, PROGRESS_TEXT colour, autowrap, custom_minimum_size.x 0, max 2 lines — clip beyond); change lines drop to at most 2 when a sentence is present. The device-scale fit test (strip 200) must still pass with the longest sentence; extend it with the sentence.
  • Tests first: sentence per effect for every mod in content (non-empty for each verified effect, and contains the right number); crystal evolve/crossing/progress sentences via real ids; weapon sentence; “” for an unknown effect; no “—” in any output (loop all mods + all 16 weapons).
  • Implement; full gate; device-scale capture with the three longest sentences; Read and describe; commit feat(levelup): one plain sentence says what each upgrade does for you.

Task 5 (controller): merge, BUILD 189 to the Apple TV, telemetry frame check of the level-up screen (target: ≥45 fps with the sheet open; before: 24-31), report.

Section titled “Task 5 (controller): merge, BUILD 189 to the Apple TV, telemetry frame check of the level-up screen (target: ≥45 fps with the sheet open; before: 24-31), report.”