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) |
Global Constraints
Section titled “Global Constraints”- Branch/worktree + commit rules as in
.superpowers/sdd/<plan>/implementer-rules.md(copied from Plan 1). No push by implementers. VisualLook.glass_ui = falsemust still render the legacy panels unchanged./simstays pure. The only sim change is thescorevalue inupgrade_system.upgrade_effects()(ranking only;rank_upgradeshas 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).
Task 1: Frozen-frame glass (performance)
Section titled “Task 1: Frozen-frame glass (performance)”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— grabsvp.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 withImage.resize(..., Image.INTERPOLATE_BILINEAR), then back up to 1/4 (a cheap two-step blur), converts to RGBA8 if needed, returnsImageTexture.create_from_image(img). Returns null if the viewport has no image (headless).var backdrop_texture: Texture2Don GlassPanel: when set on a frosted panel, the material switches toSNAPSHOT_SHADER(glass_snapshot.gdshader: sameglass_common.gdshaderincsurface, 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_textureon a frosted panel switches the shader to SNAPSHOT_SHADER and bindsbackdrop_tex; clearing it restores FROST_SHADER; clear (unfrosted) panels ignore it. (b)snapshot_backdropon 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.
Task 2: Vector button prompts
Section titled “Task 2: Vector button prompts”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 fromcustom_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” ifInput.get_joy_name(device).to_lower()contains “playstation”, “dualshock”, “dualsense” or “ps4”/“ps5”; else “xbox”.static func ButtonPrompt.row(parts: Array) -> HBoxContainerwhere each part is[button, label_text, key_text]; builds glyph + label pairs with spacing; chooses keycaps when there is no connected playable joypad andPlatform.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.gdmust 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.
Task 3: Why-tags and a fairer ranking
Section titled “Task 3: Why-tags and a fairer ranking”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) outranksarmor; (b) a crossing grant still outranks a long-term one; (c) tag per condition for each of the 5 rows via realupgrade_effectsids; (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-
effecttemplate 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.