Skip to content

Update control panel UI#3357

Draft
evanpelle wants to merge 1 commit intomainfrom
new-ui
Draft

Update control panel UI#3357
evanpelle wants to merge 1 commit intomainfrom
new-ui

Conversation

@evanpelle
Copy link
Collaborator

@evanpelle evanpelle commented Mar 5, 2026

Relates to #2260

Description:

Inspired by #3359

This PR centers the control panel and combines it with the units display. The reasoning is that the control panel contains the most critical info so it should be in the center of the screen. Combining it with the units display reduces the number of UI components on screen.

Screenshot 2026-03-06 at 2 06 34 PM Screenshot 2026-03-06 at 2 06 55 PM Screenshot 2026-03-06 at 2 07 09 PM Screenshot 2026-03-06 at 2 07 24 PM

Please complete the following:

  • I have added screenshots for all UI updates
  • I process any text displayed to the user through translateText() and I've added it to the en.json file
  • I have added relevant tests to the test directory
  • I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced

Please put your Discord username so you can be contacted if a bug or regression is found:

evan

Co-authored-by: hkio120 111693579+hkio120@users.noreply.github.com

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 5, 2026

Walkthrough

Reorganizes the bottom HUD into a responsive three-column grid, moves unit-display into the HUD (hidden on small viewports), shifts events/chat to the right on large screens, simplifies visual classes across attack/event layers, and refactors ControlPanel to separate mobile and desktop render paths while removing touch-drag attack handling in favor of slider-based inputs.

Changes

Cohort / File(s) Summary
Layout / HUD
index.html
Bottom HUD restructured to a responsive grid (lg:grid-cols-[1fr_460px_1fr]), increased z-index, pointer-events enabled for interactive zones, unit-display moved inside HUD wrapper and hidden on small viewports; inline comments added describing HUD zones.
Unit bar UI
src/client/graphics/layers/UnitDisplay.ts
Unit bar converted to a single centered horizontal grid row, reduced icon/text sizes, adjusted paddings/gaps, hover tooltip z-index increased, positioning changed from bottom-fixed to top-border container.
Control Panel (render split)
src/client/graphics/layers/ControlPanel.ts
Rendering split into renderDesktop() and renderMobile(); touch-drag state and handlers removed (no drag-to-adjust attack ratio); added calculateTroopBar() and separate desktop/mobile troop-bar render helpers and a new top-level render() selector.
Attacks display styling
src/client/graphics/layers/AttacksDisplay.ts
Simplified CSS classes: replaced responsive rounding with uniform rounded-lg, removed some responsive grid breakpoints; no logic changes.
Events / Chat styling
src/client/graphics/layers/EventsDisplay.ts
Simplified wrapper classes for collapsed/expanded states, removed responsive offsets and multiple rounding variants, unified rounding to rounded-t-lg.
Manifest / packaging
manifest_file, package.json
Minor non-functional edits to manifest/package metadata.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

HUD shifts like tide in three neat lanes,
Units tuck inward while chat rearranges,
ControlPanel splits for big and small,
Touch drag is gone — a slider stands tall,
Small tweaks and classes hum new frames.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Update control panel UI' clearly summarizes the main change: refactoring the control panel layout and combining it with the units display for a centered, responsive interface.
Description check ✅ Passed The description is directly related to the changeset, explaining the motivation (centering critical info, reducing UI components), referencing related issues, and including screenshots demonstrating the UI updates across multiple viewport sizes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
src/client/graphics/layers/ControlPanel.ts (2)

430-432: Missing resize listener for mobile/desktop switch.

The isMobile check at window.innerWidth < 1024 runs only at render time. If a user resizes their browser across the 1024px boundary, the component won't automatically switch between mobile and desktop layouts until a state change triggers a re-render.

This matches the lg: breakpoint (1024px) used in index.html for the HUD layout, so the behavior is at least consistent. If dynamic resizing is not a priority for this draft PR, this can be addressed later.

♻️ Optional: Add resize listener for responsive switching
+  private _isMobile = false;
+  private _resizeHandler: (() => void) | null = null;
+
+  connectedCallback() {
+    super.connectedCallback();
+    this._resizeHandler = () => {
+      const isMobile = window.innerWidth < 1024;
+      if (this._isMobile !== isMobile) {
+        this._isMobile = isMobile;
+        this.requestUpdate();
+      }
+    };
+    window.addEventListener("resize", this._resizeHandler);
+    this._resizeHandler();
+  }
+
+  disconnectedCallback() {
+    super.disconnectedCallback();
+    if (this._resizeHandler) {
+      window.removeEventListener("resize", this._resizeHandler);
+    }
+  }

   render() {
-    const isMobile = window.innerWidth < 1024;
     return html`
       <div
         class="relative pointer-events-auto ${this._isVisible
           ? "relative w-full text-sm bg-gray-800/70 px-2 py-1.5 shadow-lg rounded-lg backdrop-blur-xs"
           : "hidden"}"
         `@contextmenu`=${(e: MouseEvent) => e.preventDefault()}
       >
-        ${isMobile ? this.renderMobile() : this.renderDesktop()}
+        ${this._isMobile ? this.renderMobile() : this.renderDesktop()}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/graphics/layers/ControlPanel.ts` around lines 430 - 432, The
render() method in ControlPanel uses a one-time isMobile = window.innerWidth <
1024 check, so add a resize observer to update component state when crossing the
1024px breakpoint: in the component (ControlPanel) add a reactive property
(e.g., isMobile) and initialize it from window.innerWidth in connectedCallback,
attach a window.resize listener or window.matchMedia('(min-width:1024px)')
listener that updates the isMobile property (thus forcing re-render), and remove
the listener in disconnectedCallback; ensure the render() method reads
this.isMobile instead of a local variable so the component switches layouts on
resize.

406-426: Duplicate touch overlay code with color variations.

The touch dragging overlay appears in both renderMobile() (lines 406-426, blue) and render() for desktop (lines 440-460, red). The structure is identical except for color classes.

Consider extracting to a helper method with a color parameter to reduce duplication:

♻️ Optional: Extract overlay to helper method
+  private renderTouchOverlay(color: "blue" | "red") {
+    const textColor = color === "blue" ? "text-blue-400" : "text-red-400";
+    const bgColor = color === "blue" ? "bg-blue-500" : "bg-red-500";
+    return html`
+      <div
+        class="absolute bottom-full right-0 flex flex-col items-center pointer-events-auto z-[10000] bg-gray-800/70 backdrop-blur-xs rounded-tl-lg sm:rounded-lg p-2 w-12"
+        style="height: 50vh;"
+        `@touchstart`=${(e: TouchEvent) => this.handleBarTouch(e)}
+      >
+        <span class="${textColor} text-sm font-bold mb-1" translate="no"
+          >${(this.attackRatio * 100).toFixed(0)}%</span
+        >
+        <div
+          class="attack-drag-bar flex-1 w-3 bg-white/20 rounded-full relative overflow-hidden"
+        >
+          <div
+            class="absolute bottom-0 w-full ${bgColor} rounded-full"
+            style="height: ${this.attackRatio * 100}%"
+          ></div>
+        </div>
+      </div>
+    `;
+  }

Then use ${this._touchDragging ? this.renderTouchOverlay("blue") : ""} in renderMobile() and ${!isMobile && this._touchDragging ? this.renderTouchOverlay("red") : ""} in render().

Also applies to: 440-460

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/graphics/layers/ControlPanel.ts` around lines 406 - 426, The
touch-dragging overlay HTML is duplicated between renderMobile() and render()
(guarded by this._touchDragging) with only color-class differences; extract this
into a helper method (e.g., renderTouchOverlay(color: string) or
renderTouchOverlay(colorClass: string)) that returns the shared template and
accepts a color modifier for the differing classes, then replace the duplicated
blocks in renderMobile() and render() with calls like this._touchDragging ?
this.renderTouchOverlay("blue") : "" and !isMobile && this._touchDragging ?
this.renderTouchOverlay("red") : "" respectively, ensuring you reuse existing
symbols (_touchDragging, handleBarTouch, attackRatio) inside the new helper.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/client/graphics/layers/ControlPanel.ts`:
- Around line 430-432: The render() method in ControlPanel uses a one-time
isMobile = window.innerWidth < 1024 check, so add a resize observer to update
component state when crossing the 1024px breakpoint: in the component
(ControlPanel) add a reactive property (e.g., isMobile) and initialize it from
window.innerWidth in connectedCallback, attach a window.resize listener or
window.matchMedia('(min-width:1024px)') listener that updates the isMobile
property (thus forcing re-render), and remove the listener in
disconnectedCallback; ensure the render() method reads this.isMobile instead of
a local variable so the component switches layouts on resize.
- Around line 406-426: The touch-dragging overlay HTML is duplicated between
renderMobile() and render() (guarded by this._touchDragging) with only
color-class differences; extract this into a helper method (e.g.,
renderTouchOverlay(color: string) or renderTouchOverlay(colorClass: string))
that returns the shared template and accepts a color modifier for the differing
classes, then replace the duplicated blocks in renderMobile() and render() with
calls like this._touchDragging ? this.renderTouchOverlay("blue") : "" and
!isMobile && this._touchDragging ? this.renderTouchOverlay("red") : ""
respectively, ensuring you reuse existing symbols (_touchDragging,
handleBarTouch, attackRatio) inside the new helper.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: af53af0d-5806-4fad-b461-b7b74d60165e

📥 Commits

Reviewing files that changed from the base of the PR and between b3c01d4 and 900d10c.

📒 Files selected for processing (4)
  • index.html
  • src/client/graphics/layers/AttacksDisplay.ts
  • src/client/graphics/layers/ControlPanel.ts
  • src/client/graphics/layers/UnitDisplay.ts

coderabbitai[bot]
coderabbitai bot previously approved these changes Mar 5, 2026
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/client/graphics/layers/ControlPanel.ts`:
- Around line 408-423: The touchstart handler is on the outer wrapper, causing
touches on the percent label/padding to call handleBarTouch which then uses
setRatioFromTouch (which measures .attack-drag-bar) and snaps ratio incorrectly;
move the `@touchstart` handler from the outer div to the element with class
"attack-drag-bar" (and replicate the same change in the desktop popover), or
alternatively have handleBarTouch early-return if the touch is outside the
bounding rect of .attack-drag-bar by checking event.target or hit-testing
against the .attack-drag-bar DOMRect before calling setRatioFromTouch.
- Around line 368-370: render() switches to renderMobile() by width but the
attack control only listens for touchstart (handleAttackTouchStart), leaving
resized desktops without a way to change uiState.attackRatio and causing
PlayerActionHandler to keep the old ratio; add a non-touch fallback by wiring a
pointer/click event (e.g., `@click` or `@pointerdown`) to the same logic or a new
handler (handleAttackClick/handleAttackPointer) that reuses
handleAttackTouchStart's behavior to open the mobile attack UI and update
uiState.attackRatio, and apply the same change to the analogous mobile controls
around the 430-440 block so both touch and mouse/pointer input paths update the
attack slider/state.

In `@src/client/graphics/layers/UnitDisplay.ts`:
- Around line 196-201: The hover toggle logic is missing a MIRV-specific branch
so MIRV currently highlights itself instead of its real dependency
(MissileSilo); update the switch that dispatches ToggleStructureEvent to add a
case for UnitType.MIRV that calls ToggleStructureEvent([UnitType.MissileSilo])
(the same dependency checked by canBuild()), keeping existing behavior for other
unit types and using the same event path used for other structure highlight
cases; locate this logic near renderUnitItem usages and the hover switch
handling ToggleStructureEvent to add the MIRV branch.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ff6376b5-ed5a-4b4c-81fe-4eb7fac30ad1

📥 Commits

Reviewing files that changed from the base of the PR and between 900d10c and a4c8980.

📒 Files selected for processing (5)
  • index.html
  • src/client/graphics/layers/AttacksDisplay.ts
  • src/client/graphics/layers/ControlPanel.ts
  • src/client/graphics/layers/EventsDisplay.ts
  • src/client/graphics/layers/UnitDisplay.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/client/graphics/layers/AttacksDisplay.ts

Comment on lines +196 to +201
${this.renderUnitItem(
mirvIcon,
null,
UnitType.MIRV,
"mirv",
this.keybinds["buildMIRV"]?.key ?? "0",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

MIRV still needs the launcher highlight path.

Adding MIRV here is incomplete unless the hover ToggleStructureEvent switch below also gets a MIRV branch. Right now MIRV falls back to ToggleStructureEvent([UnitType.MIRV]), even though canBuild() says its real dependency is UnitType.MissileSilo, so the hover hint points at the wrong thing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/graphics/layers/UnitDisplay.ts` around lines 196 - 201, The hover
toggle logic is missing a MIRV-specific branch so MIRV currently highlights
itself instead of its real dependency (MissileSilo); update the switch that
dispatches ToggleStructureEvent to add a case for UnitType.MIRV that calls
ToggleStructureEvent([UnitType.MissileSilo]) (the same dependency checked by
canBuild()), keeping existing behavior for other unit types and using the same
event path used for other structure highlight cases; locate this logic near
renderUnitItem usages and the hover switch handling ToggleStructureEvent to add
the MIRV branch.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
src/client/graphics/layers/UnitDisplay.ts (1)

276-291: ⚠️ Potential issue | 🟡 Minor

MIRV still needs a hover highlight case for MissileSilo.

The switch statement handles AtomBomb and HydrogenBomb together (highlighting MissileSilo + SAMLauncher), but MIRV falls through to the default case. Since MIRV also requires a MissileSilo to build (see canBuild() at lines 82-87), it should highlight the same structures as the other nukes.

Proposed fix
 switch (unitType) {
   case UnitType.AtomBomb:
   case UnitType.HydrogenBomb:
+  case UnitType.MIRV:
     this.eventBus?.emit(
       new ToggleStructureEvent([
         UnitType.MissileSilo,
         UnitType.SAMLauncher,
       ]),
     );
     break;
   case UnitType.Warship:
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/graphics/layers/UnitDisplay.ts` around lines 276 - 291, The switch
in UnitDisplay handling hover highlights omits MIRV so it falls to default; add
a case for UnitType.MIRV alongside UnitType.AtomBomb and UnitType.HydrogenBomb
so the handler emits the same ToggleStructureEvent([UnitType.MissileSilo,
UnitType.SAMLauncher]) as the other nukes (modify the switch that checks
unitType in UnitDisplay.ts to include UnitType.MIRV in the same branch that
calls this.eventBus?.emit(new ToggleStructureEvent([UnitType.MissileSilo,
UnitType.SAMLauncher]))).
src/client/graphics/layers/ControlPanel.ts (1)

453-462: ⚠️ Potential issue | 🟠 Major

Desktop path still needs a mouse/click fallback for the attack ratio control.

Line 454 switches to renderMobile() purely based on width (window.innerWidth < 1024), but the desktop attack ratio control (lines 346-364) only has @touchstart. On a resized desktop browser below 1024px, users with a mouse cannot change the attack ratio.

Consider using window.matchMedia("(pointer: coarse)").matches to detect actual touch capability, or add a @click handler alongside @touchstart.

Proposed approach
- const isMobile = window.innerWidth < 1024;
+ const isCompactLayout = window.innerWidth < 1024;
+ const useTouchUi = isCompactLayout && window.matchMedia("(pointer: coarse)").matches;
  return html`
    ...
-   ${isMobile ? this.renderMobile() : this.renderDesktop()}
+   ${useTouchUi ? this.renderMobile() : this.renderDesktop()}

Alternatively, wire a @click handler to open the attack bar for mouse users.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/graphics/layers/ControlPanel.ts` around lines 453 - 462, The
desktop branch uses renderDesktop() selected by window.innerWidth but the
attack-ratio control only listens for `@touchstart` (so mouse users on
small/resized screens cannot open it); update the logic in
render()/renderDesktop() to detect input capability (e.g.,
window.matchMedia("(pointer: coarse)").matches) or add a mouse fallback by
attaching a `@click` handler alongside the existing `@touchstart` on the
attack-ratio control; ensure the same handler/function that opens/manages the
attack bar (the callback used by the `@touchstart`) is reused for `@click` so
behavior is identical and no duplicate logic is introduced.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/client/graphics/layers/ControlPanel.ts`:
- Around line 453-462: The desktop branch uses renderDesktop() selected by
window.innerWidth but the attack-ratio control only listens for `@touchstart` (so
mouse users on small/resized screens cannot open it); update the logic in
render()/renderDesktop() to detect input capability (e.g.,
window.matchMedia("(pointer: coarse)").matches) or add a mouse fallback by
attaching a `@click` handler alongside the existing `@touchstart` on the
attack-ratio control; ensure the same handler/function that opens/manages the
attack bar (the callback used by the `@touchstart`) is reused for `@click` so
behavior is identical and no duplicate logic is introduced.

In `@src/client/graphics/layers/UnitDisplay.ts`:
- Around line 276-291: The switch in UnitDisplay handling hover highlights omits
MIRV so it falls to default; add a case for UnitType.MIRV alongside
UnitType.AtomBomb and UnitType.HydrogenBomb so the handler emits the same
ToggleStructureEvent([UnitType.MissileSilo, UnitType.SAMLauncher]) as the other
nukes (modify the switch that checks unitType in UnitDisplay.ts to include
UnitType.MIRV in the same branch that calls this.eventBus?.emit(new
ToggleStructureEvent([UnitType.MissileSilo, UnitType.SAMLauncher]))).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 24a06b08-ae9f-4670-950d-20037831cc44

📥 Commits

Reviewing files that changed from the base of the PR and between a4c8980 and 840f74d.

📒 Files selected for processing (5)
  • index.html
  • src/client/graphics/layers/AttacksDisplay.ts
  • src/client/graphics/layers/ControlPanel.ts
  • src/client/graphics/layers/EventsDisplay.ts
  • src/client/graphics/layers/UnitDisplay.ts

@evanpelle evanpelle changed the title new ui Update control panel UI Mar 6, 2026
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
src/client/graphics/layers/ControlPanel.ts (1)

127-131: Consider persisting attackRatio changes to localStorage.

init() reads from localStorage.getItem("settings.attackRatio"), but handleRatioSliderInput() never writes the updated value back. User-adjusted attack ratios won't persist across sessions.

Optional fix to persist changes
 private handleRatioSliderInput(e: Event) {
   const value = Number((e.target as HTMLInputElement).value);
   this.attackRatio = value / 100;
   this.onAttackRatioChange(this.attackRatio);
+  localStorage.setItem("settings.attackRatio", String(this.attackRatio));
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/graphics/layers/ControlPanel.ts` around lines 127 - 131, The
slider handler handleRatioSliderInput updates this.attackRatio and calls
onAttackRatioChange but doesn't persist the new value; modify
handleRatioSliderInput to also write the updated value into localStorage (use
the same key read in init(), e.g. "settings.attackRatio")—store a string (either
the ratio or percentage consistently with init's parsing) so the value persists
across sessions and will be picked up by init() on next load.
index.html (1)

269-269: Minor: Tailwind v4 supports direct z-index syntax.

z-[200] works, but v4 allows z-200 without brackets. Not urgent - bracket notation remains valid.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@index.html` at line 269, Replace the bracketed z-index token in the element's
class attribute to use Tailwind v4's direct syntax: change the token "z-[200]"
inside the class string "fixed bottom-0 left-0 w-full z-[200] flex flex-col
pointer-events-none sm:flex-row sm:items-end lg:grid
lg:grid-cols-[1fr_460px_1fr] lg:items-end min-[1200px]:bottom-4
min-[1200px]:px-4" to "z-200" so the component uses the newer shorthand while
preserving the rest of the class list.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@index.html`:
- Line 269: Replace the bracketed z-index token in the element's class attribute
to use Tailwind v4's direct syntax: change the token "z-[200]" inside the class
string "fixed bottom-0 left-0 w-full z-[200] flex flex-col pointer-events-none
sm:flex-row sm:items-end lg:grid lg:grid-cols-[1fr_460px_1fr] lg:items-end
min-[1200px]:bottom-4 min-[1200px]:px-4" to "z-200" so the component uses the
newer shorthand while preserving the rest of the class list.

In `@src/client/graphics/layers/ControlPanel.ts`:
- Around line 127-131: The slider handler handleRatioSliderInput updates
this.attackRatio and calls onAttackRatioChange but doesn't persist the new
value; modify handleRatioSliderInput to also write the updated value into
localStorage (use the same key read in init(), e.g.
"settings.attackRatio")—store a string (either the ratio or percentage
consistently with init's parsing) so the value persists across sessions and will
be picked up by init() on next load.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 05e65fb3-097a-4353-8fa0-ca14bab02b9a

📥 Commits

Reviewing files that changed from the base of the PR and between 840f74d and c1afff8.

📒 Files selected for processing (5)
  • index.html
  • src/client/graphics/layers/AttacksDisplay.ts
  • src/client/graphics/layers/ControlPanel.ts
  • src/client/graphics/layers/EventsDisplay.ts
  • src/client/graphics/layers/UnitDisplay.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/client/graphics/layers/AttacksDisplay.ts
  • src/client/graphics/layers/EventsDisplay.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Development

Development

Successfully merging this pull request may close these issues.

1 participant