Battle UI contract + 4 sub-issues: SUPPORT/LOG below fold, portrait fallback, snap-pause selection, RMB hint #22

Closed
opened 2026-10-04 03:48:51 +00:00 by Mark · 1 comment
Owner

Context

The in-mission battle UI is described piecemeal across README.md §Controls, the
DOM in web/index.html, and the renderer in web/js/ui.js + web/css/style.css.
The screenshot at docs/screenshot.png is the only end-to-end reference, and it
reveals the surface is real but the layout contract is never written down.

This issue captures the canonical battle-UI contract derived from the docs +
screenshot, then enumerates the gaps the contract surfaces. Each gap lands as a
sub-issue (below) so kilo-gsd can pick them up individually.

The contract is web-first; the Godot port has its own godot/scenes/main.tscn
that ships an empty UI CanvasLayer (separate gap, filed in #23 alongside the
existing Godot-side stubs).

Design contract — battle UI

The in-mission screen has four regions in a fixed layout:

┌───────────────────────────────────────────────────────────────────────────┐
│ TOPBAR  //CYBERTACTICS · T+MM:SS · MAP · beat% · PHASE      ▾ 1× 2× ⏵ ⤴ ⏏ │  height: auto
├──────────────────────────────────────────────────┬────────────────────────┤
│                                                  │ SQUAD                 │
│                                                  │ SELECTED              │
│             TACTICAL MAP (canvas)                │ STANCE                │
│             728×504 px fixed                     │ ORDER QUEUE           │
│             renders grid + units + effects       │ PLAYBOOKS             │
│                                                  │ SUPPORT               │
│                                                  │ LOG                   │
├──────────────────────────────────────────────────┴────────────────────────┤
│ HINT  LMB · F1/F2/F3 · RMB · SPACE · TAB · DEL · objective reminder        │
└──────────────────────────────────────────────────────────────────────────┘

Region definitions

Region Element Source of truth Behavior
Topbar #topbar (html:9) README.md §Controls lines 164–175 Logo · mission clock · beat % · phase pill · pause / 1× / 2× / Command-Phase / Save&Title. Pause button reflects G.speed === 0. Phase pill recolors per phase-exec / phase-plan / phase-event (css:19–21).
Map #game (html:21, 728×504) README.md §Systems lines 183–204 WebGL-less Canvas2D. Selection rings, queue-path lines, snap-focus rings, dmg text, LOS fog. Mouse coords → tile via TILE = 28.
Sidebar #side (html:22, w=330px, max-h=504) README.md + screenshot Eight stacked sections in DOM order: SQUAD → SELECTED → STANCE → ORDER QUEUE → PLAYBOOKS → SUPPORT → LOG. Currently max-height: 504px (css:31) so the bottom two scroll off on a 504-tall canvas.
Hint #hint (html:32) README.md §Controls Static reminder string. Same one is reused as the title-screen footer.

Panel contracts

Each sidebar panel has a hard role derived from web/js/ui.js updateUI()
(lines 595–780):

Panel DOM id Read Write
SQUAD #squad team headers + per-member rows (ui.js:608–634) click row → selectUnit(m); click team header → selectTeam(tid)
SELECTED #sel G.selected portrait + stats (ui.js:636–650); or team stats when G.selTeam set none (display only)
STANCE #stances iterates STANCES keys (ui.js:652–668) click → setStance(k); button active when target/team matches
ORDER QUEUE #queue u.queue (ui.js:670–713) — list with qitem rows; x per-row when paused click row → u.queue.splice(i, 1); click clear → clearQueue(u)
PLAYBOOKS #playbooks iterates PLAYBOOKS (ui.js:715–744); research-locked entries show 🔒 via <node> click → applyPlaybook(k) (only enabled when not locked)
SUPPORT #support air + drop always; smoke only when ghostcell research owned (ui.js:746–760) click → beginTargeting(k); second click anywhere cancels
LOG #log G.log ring of 60 (ui.js:762–763) none

Controls contract

Input Effect Source
LMB on squad unit selectUnit(u) main.js:25
LMB on enemy in LOS log hostile's stats (read-only inspect) main.js:26
LMB empty / scenery deselect (selectUnit(null)) main.js:25
RMB ground issueContext(tx, ty) → MOVE / formation spread main.js:28, game.js:677–709
RMB enemy focus-fire ATTACK queue on target game.js:685–687
RMB scenery (non-barrel) nearest operative INTERACT, rest cover game.js:692–699
SPACE togglePause() main.js:34
1 / 2 setSpeed(1/2) main.js:40–41
TAB cycleUnit() next alive main.js:35
F1 / F2 / F3 selectTeam("alpha"/"bravo"/"all") main.js:36–38
DEL / Backspace clearQueue(G.selected) main.js:39
ESC cancel G.targeting main.js:42

Phase contract

G.phase ∈ {EXECUTION, PLANNING}; G.pausedBy ∈ {null, "manual", "planning", "event"}.
Order queue rows are editable only when G.speed === 0 || G.phase === "PLANNING"
(ui.js:687, 705). Command phase fires every BEAT_LEN = 9s of execution
(game.js:6) via enterPlanning() which resets orders_run for all squad units
and pauses; resumeExec() resumes at prevSpeed.

Identified gaps — concrete fixes the contract surfaces

These are real bugs / missing affordances, ordered by how much the user is
likely to hit them in a 2-minute playthrough. Each becomes a sub-issue.

Sub-issue A — SUPPORT and LOG scroll below the fold (real bug)

web/css/style.css:31 pins #side { max-height: 504px; } to match the canvas.
With 5 visible panels (Squad/Selected/Stance/Queue/Playbooks) the user never
sees Support or Log on a default 1280×800 viewport. Confirmed in screenshot —
docs/screenshot.png shows only the first 5 panels.

Fix: make #side flex-grow within #wrap (or use 100vh - topbar - hint)
and let the LOG section be the only one that scrolls internally. Other
panels stay compact. Acceptance: Support + Log visible on 1280×800 without
scrolling the whole sidebar; make ci passes.

Sub-issue B — selected-panel portrait is missing for non-squad (real bug)

web/js/ui.js updateUI() line 644 selects PORTRAIT[u.sprite] only when the
sprite is in PORTRAIT. For non-squad enemies inspected via LMB, the renderer
falls back to assets/portraits/<vex|ganger>.png — wrong portrait for the
inspected unit (SABLE shows VEX's face). The fallback string is on the same line.

Acceptance: LMB on a visible hostile shows that hostile's portrait (or a
neutral silhouette); no squad portrait leaks into the enemy inspect path.

Sub-issue C — no visible "Phalanx locked" affordance on the Playbook button

(tooltips-OK, button-state stub)

This is the same gap that landed as #17 for Phalanx. The fix is in place
(ui.js:729–741 wires 🔒 via <research-name> tooltip + sub-label), but the
button itself remains enabled-looking — the disabled attribute is set
(ui.js:741) but no visual state tells the user "this will not work, you need
research X." Currently the only signal is opacity reduction via button:disabled
(css:27).

Acceptance: research-locked playbooks render with a distinct visual state
(amber border + sub-label already wired) and a one-line README/wiki note pointing
to the Strategic View where research lives.

Sub-issue D — selected unit speed across modes (UX gap)

Speed buttons are 1× / 2×; the README §Controls line 172 only mentions those.
There is no 0.5× (slow-mo for tactical review) or 3×. README matches the
build, but the topbar cluster renders the buttons at fixed width and looks
empty when nothing is happening in the planning phase.

Acceptance decision: keep current 1× / 2× — README is accurate, no scope
creep. Mark as out of scope for this issue, file separately if desired.

Sub-issue E — keyboard hint bottom-bar is the only command surface

(documentation gap)

#hint (html:32) is a fixed string. There's no in-game tooltip on RMB that says
"MOVE on ground, ATTACK on enemy, INTERACT on object" — the user has to read
the hint bar or experiment. Same for the F1/F2/F3 strike-team keybindings —
they aren't shown anywhere in the panels themselves; only title attribute on
team rows (ui.js:623).

Acceptance: RMB hover on map shows a small contextual hint at the cursor
("MOVE", "ATTACK ", "INTERACT ") matching the queued order label.

Sub-issue F — focus-snap event doesn't show which unit the event is about

(UX gap)

snapPause() (game.js:545) sets G.focus and writes a log line but doesn't
select the involved unit. After the snap-pause the player sees a circle pulse
but the SELECTED panel still shows whoever was selected before the event.
For "squad wounded" / "unit down" / "order failed" events, the involved unit
should become G.selected automatically so the SELECTED panel updates.

Acceptance: any snap-pause caused by a squad unit's state changes
selects that unit; hostile-contact snaps select the spotted enemy.

Sub-issue decomposition

ID Title File(s) Depends on
22-1 Fix sidebar height so SUPPORT + LOG fit without scrolling web/css/style.css, possibly web/js/ui.js —
22-2 In-mission SELECTED portrait: pick by unit sprite, not by side fallback web/js/ui.js line 644 —
22-3 Auto-select involved unit on snap-pause events web/js/game.js snapPause() callers —
22-4 RMB cursor hint: show MOVE / ATTACK / INTERACT label under cursor web/js/ui.js render() + web/js/main.js mousedown —

Sub-issue 22-1 should be its own quickfix lane (make ci is the gate).
Sub-issues 22-2 and 22-3 are surgical code changes. Sub-issue 22-4 is a
small renderer addition.

Acceptance

  • Each sub-issue above is filed with the triage-approved label.
  • make ci green on each landed sub-issue.
  • Screenshot at docs/screenshot.png reflects the new layout (Support +
    Log visible without scrolling on 1280×800).
  • README §Controls still matches the running game (no drift).

Out of scope

  • 3× speed, 0.5× slow-mo, accessibility-keyboard layer (no request).
  • Touch-screen affordances beyond the existing RMB→LMB emulation
    (godot/project.godot line 21 sets pointing/emulate_touch_from_mouse=true
    for Godot; the web build has no touch handler).
  • Tooltips on hover (separate enhancement, future issue).
  • Replacing web/ with a bundler — AGENTS.md line 53 rules it out.

Refs

  • README.md lines 160–204 (Controls + Systems)
  • web/index.html lines 9–35 (DOM contract)
  • web/css/style.css lines 12–53 (region styling)
  • web/js/ui.js updateUI() lines 595–780 (panel contracts)
  • web/js/main.js lines 12–43 (input handler)
  • web/js/game.js snapPause() line 545 + enterPlanning() line 555
  • Related closed: #9 (portrait parent), #17 (Phalanx affordance),
    #15 (mission unlock on loss)
## Context The in-mission battle UI is described piecemeal across `README.md` §Controls, the DOM in `web/index.html`, and the renderer in `web/js/ui.js` + `web/css/style.css`. The screenshot at `docs/screenshot.png` is the only end-to-end reference, and it reveals the surface is real but the **layout contract is never written down**. This issue captures the canonical battle-UI contract derived from the docs + screenshot, then enumerates the gaps the contract surfaces. Each gap lands as a sub-issue (below) so kilo-gsd can pick them up individually. The contract is web-first; the Godot port has its own `godot/scenes/main.tscn` that ships an empty `UI` CanvasLayer (separate gap, filed in #23 alongside the existing Godot-side stubs). ## Design contract — battle UI The in-mission screen has **four regions** in a fixed layout: ``` ┌───────────────────────────────────────────────────────────────────────────┐ │ TOPBAR //CYBERTACTICS · T+MM:SS · MAP · beat% · PHASE ▾ 1× 2× ⏵ ⤴ ⏏ │ height: auto ├──────────────────────────────────────────────────┬────────────────────────┤ │ │ SQUAD │ │ │ SELECTED │ │ TACTICAL MAP (canvas) │ STANCE │ │ 728×504 px fixed │ ORDER QUEUE │ │ renders grid + units + effects │ PLAYBOOKS │ │ │ SUPPORT │ │ │ LOG │ ├──────────────────────────────────────────────────┴────────────────────────┤ │ HINT LMB · F1/F2/F3 · RMB · SPACE · TAB · DEL · objective reminder │ └──────────────────────────────────────────────────────────────────────────┘ ``` ### Region definitions | Region | Element | Source of truth | Behavior | |---|---|---|---| | Topbar | `#topbar` (html:9) | `README.md` §Controls lines 164–175 | Logo · mission clock · beat % · phase pill · pause / 1× / 2× / Command-Phase / Save&Title. Pause button reflects `G.speed === 0`. Phase pill recolors per `phase-exec` / `phase-plan` / `phase-event` (css:19–21). | | Map | `#game` (html:21, 728×504) | `README.md` §Systems lines 183–204 | WebGL-less Canvas2D. Selection rings, queue-path lines, snap-focus rings, dmg text, LOS fog. Mouse coords → tile via `TILE = 28`. | | Sidebar | `#side` (html:22, w=330px, max-h=504) | `README.md` + screenshot | Eight stacked sections in DOM order: SQUAD → SELECTED → STANCE → ORDER QUEUE → PLAYBOOKS → SUPPORT → LOG. Currently `max-height: 504px` (css:31) so the bottom two scroll off on a 504-tall canvas. | | Hint | `#hint` (html:32) | `README.md` §Controls | Static reminder string. Same one is reused as the title-screen footer. | ### Panel contracts Each sidebar panel has a hard role derived from `web/js/ui.js` `updateUI()` (lines 595–780): | Panel | DOM id | Read | Write | |---|---|---|---| | **SQUAD** | `#squad` | team headers + per-member rows (ui.js:608–634) | click row → `selectUnit(m)`; click team header → `selectTeam(tid)` | | **SELECTED** | `#sel` | `G.selected` portrait + stats (ui.js:636–650); or team stats when `G.selTeam` set | none (display only) | | **STANCE** | `#stances` | iterates `STANCES` keys (ui.js:652–668) | click → `setStance(k)`; button `active` when target/team matches | | **ORDER QUEUE** | `#queue` | `u.queue` (ui.js:670–713) — list with `qitem` rows; `x` per-row when paused | click row → `u.queue.splice(i, 1)`; click clear → `clearQueue(u)` | | **PLAYBOOKS** | `#playbooks` | iterates `PLAYBOOKS` (ui.js:715–744); research-locked entries show `🔒 via <node>` | click → `applyPlaybook(k)` (only enabled when not locked) | | **SUPPORT** | `#support` | `air` + `drop` always; `smoke` only when `ghostcell` research owned (ui.js:746–760) | click → `beginTargeting(k)`; second click anywhere cancels | | **LOG** | `#log` | `G.log` ring of 60 (ui.js:762–763) | none | ### Controls contract | Input | Effect | Source | |---|---|---| | LMB on squad unit | `selectUnit(u)` | main.js:25 | | LMB on enemy in LOS | log hostile's stats (read-only inspect) | main.js:26 | | LMB empty / scenery | deselect (`selectUnit(null)`) | main.js:25 | | RMB ground | `issueContext(tx, ty)` → MOVE / formation spread | main.js:28, game.js:677–709 | | RMB enemy | focus-fire ATTACK queue on target | game.js:685–687 | | RMB scenery (non-barrel) | nearest operative INTERACT, rest cover | game.js:692–699 | | SPACE | `togglePause()` | main.js:34 | | 1 / 2 | `setSpeed(1/2)` | main.js:40–41 | | TAB | `cycleUnit()` next alive | main.js:35 | | F1 / F2 / F3 | `selectTeam("alpha"/"bravo"/"all")` | main.js:36–38 | | DEL / Backspace | `clearQueue(G.selected)` | main.js:39 | | ESC | cancel `G.targeting` | main.js:42 | ### Phase contract `G.phase ∈ {EXECUTION, PLANNING}`; `G.pausedBy ∈ {null, "manual", "planning", "event"}`. Order queue rows are editable only when `G.speed === 0 || G.phase === "PLANNING"` (ui.js:687, 705). Command phase fires every `BEAT_LEN = 9s` of execution (game.js:6) via `enterPlanning()` which resets `orders_run` for all squad units and pauses; `resumeExec()` resumes at `prevSpeed`. ## Identified gaps — concrete fixes the contract surfaces These are real bugs / missing affordances, ordered by how much the user is likely to hit them in a 2-minute playthrough. Each becomes a sub-issue. ### Sub-issue A — SUPPORT and LOG scroll below the fold (real bug) `web/css/style.css:31` pins `#side { max-height: 504px; }` to match the canvas. With 5 visible panels (Squad/Selected/Stance/Queue/Playbooks) the user never sees Support or Log on a default 1280×800 viewport. Confirmed in screenshot — `docs/screenshot.png` shows only the first 5 panels. Fix: make `#side` flex-grow within `#wrap` (or use `100vh - topbar - hint`) and let the **LOG** section be the only one that scrolls internally. Other panels stay compact. **Acceptance:** Support + Log visible on 1280×800 without scrolling the whole sidebar; `make ci` passes. ### Sub-issue B — selected-panel portrait is missing for non-squad (real bug) `web/js/ui.js` `updateUI()` line 644 selects `PORTRAIT[u.sprite]` only when the sprite is in `PORTRAIT`. For non-squad enemies inspected via LMB, the renderer falls back to `assets/portraits/<vex|ganger>.png` — wrong portrait for the inspected unit (SABLE shows VEX's face). The fallback string is on the same line. **Acceptance:** LMB on a visible hostile shows that hostile's portrait (or a neutral silhouette); no squad portrait leaks into the enemy inspect path. ### Sub-issue C — no visible "Phalanx locked" affordance on the Playbook button (tooltips-OK, button-state stub) This is the same gap that landed as #17 for Phalanx. The fix is in place (ui.js:729–741 wires `🔒 via <research-name>` tooltip + sub-label), but the button itself remains enabled-looking — the `disabled` attribute is set (ui.js:741) but no visual state tells the user "this will not work, you need research X." Currently the only signal is opacity reduction via `button:disabled` (css:27). **Acceptance:** research-locked playbooks render with a distinct visual state (amber border + sub-label already wired) and a one-line README/wiki note pointing to the Strategic View where research lives. ### Sub-issue D — selected unit speed across modes (UX gap) Speed buttons are 1× / 2×; the README §Controls line 172 only mentions those. There is no `0.5×` (slow-mo for tactical review) or `3×`. README matches the build, but the topbar cluster renders the buttons at fixed width and looks empty when nothing is happening in the planning phase. **Acceptance decision:** keep current 1× / 2× — README is accurate, no scope creep. Mark as **out of scope** for this issue, file separately if desired. ### Sub-issue E — keyboard hint bottom-bar is the only command surface (documentation gap) `#hint` (html:32) is a fixed string. There's no in-game tooltip on RMB that says "MOVE on ground, ATTACK on enemy, INTERACT on object" — the user has to read the hint bar or experiment. Same for the F1/F2/F3 strike-team keybindings — they aren't shown anywhere in the panels themselves; only `title` attribute on team rows (ui.js:623). **Acceptance:** RMB hover on map shows a small contextual hint at the cursor ("MOVE", "ATTACK <name>", "INTERACT <type>") matching the queued order label. ### Sub-issue F — focus-snap event doesn't show which unit the event is about (UX gap) `snapPause()` (game.js:545) sets `G.focus` and writes a log line but doesn't select the involved unit. After the snap-pause the player sees a circle pulse but the **SELECTED** panel still shows whoever was selected before the event. For "squad wounded" / "unit down" / "order failed" events, the involved unit should become `G.selected` automatically so the SELECTED panel updates. **Acceptance:** any snap-pause caused by a squad unit's state changes selects that unit; hostile-contact snaps select the spotted enemy. ## Sub-issue decomposition | ID | Title | File(s) | Depends on | |---|---|---|---| | 22-1 | Fix sidebar height so SUPPORT + LOG fit without scrolling | `web/css/style.css`, possibly `web/js/ui.js` | — | | 22-2 | In-mission SELECTED portrait: pick by unit sprite, not by side fallback | `web/js/ui.js` line 644 | — | | 22-3 | Auto-select involved unit on snap-pause events | `web/js/game.js` `snapPause()` callers | — | | 22-4 | RMB cursor hint: show MOVE / ATTACK / INTERACT label under cursor | `web/js/ui.js` `render()` + `web/js/main.js` mousedown | — | Sub-issue 22-1 should be its own quickfix lane (`make ci` is the gate). Sub-issues 22-2 and 22-3 are surgical code changes. Sub-issue 22-4 is a small renderer addition. ## Acceptance - [ ] Each sub-issue above is filed with the `triage-approved` label. - [ ] `make ci` green on each landed sub-issue. - [ ] Screenshot at `docs/screenshot.png` reflects the new layout (Support + Log visible without scrolling on 1280×800). - [ ] README §Controls still matches the running game (no drift). ## Out of scope - 3× speed, 0.5× slow-mo, accessibility-keyboard layer (no request). - Touch-screen affordances beyond the existing RMB→LMB emulation (`godot/project.godot` line 21 sets `pointing/emulate_touch_from_mouse=true` for Godot; the web build has no touch handler). - Tooltips on hover (separate enhancement, future issue). - Replacing `web/` with a bundler — `AGENTS.md` line 53 rules it out. ## Refs - `README.md` lines 160–204 (Controls + Systems) - `web/index.html` lines 9–35 (DOM contract) - `web/css/style.css` lines 12–53 (region styling) - `web/js/ui.js` `updateUI()` lines 595–780 (panel contracts) - `web/js/main.js` lines 12–43 (input handler) - `web/js/game.js` `snapPause()` line 545 + `enterPlanning()` line 555 - Related closed: #9 (portrait parent), #17 (Phalanx affordance), #15 (mission unlock on loss) <!-- forgejo-id: Mark/cybertactics#22 -->
Author
Owner

Decomposed into 4 sub-issues, each with triage-approved so kilo-gsd can pick them up:

  • #24 — Fix sidebar height so SUPPORT + LOG fit without scrolling (sub-issue 22-1, quickfix)
  • #25 — In-mission SELECTED portrait: pick by unit sprite, not by side fallback (sub-issue 22-2, bug)
  • #26 — Auto-select involved unit on snap-pause events (sub-issue 22-3, enhancement)
  • #27 — RMB cursor hint: show MOVE / ATTACK / INTERACT label under cursor (sub-issue 22-4, enhancement)

Each child's body opens with **Parent-Issue:** #22 and carries the full acceptance criteria + file pointers from the table in this issue's body. Sub-issues C (Phalanx affordance visual state), D (speed 0.5×/3×), and E (F1/F2/F3 in-panel keybinding hints) are not in scope for this decomposition per the issue body; they remain open for follow-up if desired.

Closing this parent — the contract and the gap enumeration are captured; the work itself lives on the children.

Decomposed into 4 sub-issues, each with `triage-approved` so kilo-gsd can pick them up: - #24 — Fix sidebar height so SUPPORT + LOG fit without scrolling (sub-issue 22-1, `quickfix`) - #25 — In-mission SELECTED portrait: pick by unit sprite, not by side fallback (sub-issue 22-2, `bug`) - #26 — Auto-select involved unit on snap-pause events (sub-issue 22-3, `enhancement`) - #27 — RMB cursor hint: show MOVE / ATTACK / INTERACT label under cursor (sub-issue 22-4, `enhancement`) Each child's body opens with `**Parent-Issue:** #22` and carries the full acceptance criteria + file pointers from the table in this issue's body. Sub-issues C (Phalanx affordance visual state), D (speed 0.5×/3×), and E (F1/F2/F3 in-panel keybinding hints) are not in scope for this decomposition per the issue body; they remain open for follow-up if desired. Closing this parent — the contract and the gap enumeration are captured; the work itself lives on the children.
Mark closed this issue 2026-10-04 21:31:45 +00:00
Sign in to join this conversation.
No description provided.