Skip to content

UI: split/classic control panel variants and linked attack/unit layout#3376

Open
hkio120 wants to merge 1 commit intoopenfrontio:mainfrom
hkio120:pr-control-panels-only
Open

UI: split/classic control panel variants and linked attack/unit layout#3376
hkio120 wants to merge 1 commit intoopenfrontio:mainfrom
hkio120:pr-control-panels-only

Conversation

@hkio120
Copy link
Contributor

@hkio120 hkio120 commented Mar 7, 2026

Description:

This PR isolates the in-game control panel work (split/classic panel variants) and related positioning/visual updates for attacks and unit bar integration.

Included

  • Added split/classic in-game layout controls and persistence in index.html
  • Reworked control panel rendering logic for split/classic panel styles
  • Integrated unit display directly with control panel container
  • Adjusted attacks display placement/compaction so it stays properly above the control panel (including classic layout behavior)
  • Fixed control panel event typing/runtime checks introduced by the panel refactor
    -The color of the regeneration bar depends on whether you are in optimal regeneration, ranging from red (not enough troops) to green optimal and then to blue (too many troops).
    -New information tells us how much gold was generated in the last minute
    -The attacks are displayed in such a way that even if you spam them, they don't cover a large part of the screen.

Please complete the following:

  • [ x] 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:

HulKiora

--- Screenshot ---
Layou Split - Panel Split :
image
Layout Split - Panel Classic :
image
Layout Classic - Panel Split
image
Layout Classic - Panel Classic :
image

image

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 7, 2026

Walkthrough

The pull request adds alternate in-game layout options (Classic vs Split) for the HUD with localStorage persistence, reorganizes the attack display into incoming/outgoing columns, enhances the control panel with gold-per-minute income tracking and troop regeneration color optimization, and implements a centralized tooltip system for unit displays.

Changes

Cohort / File(s) Summary
Layout Infrastructure
index.html
Introduces DOM wrappers for layout containers, adds CSS rules for classic left-aligned control panel, and injects localStorage-backed state management for layout switching with two toggle buttons and dynamic class application.
Attack Display Reorganization
src/client/graphics/layers/AttacksDisplay.ts
Reduces padding and icon sizes, adjusts border radius classes, and reorganizes render structure into incoming and outgoing sub-blocks displayed in two responsive columns with independent scrolling.
Control Panel Enhancement
src/client/graphics/layers/ControlPanel.ts
Adds income window tracking for trade/training gold per minute; introduces troop regen optimization with gradient color lerping; expands rendering to support both classic and split layouts with new badges for gold, regen rate, and attack ratio; refactors ratio slider and touch-drag interactions.
Unit Display Tooltip System
src/client/graphics/layers/UnitDisplay.ts
Implements global tooltip that displays on unit hover, tracks hovered structure state, adjusts icon sizing and styling, and removes per-item inline tooltip blocks in favor of centralized tooltip rendering.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Button
    participant localStorage
    participant CSS
    participant DOM

    User->>Button: Click layout-switch or style-button
    Button->>localStorage: Read current layout/style
    localStorage-->>Button: Return stored value
    Button->>localStorage: Write new layout/style preference
    Button->>CSS: Apply layout/style class to document
    CSS->>DOM: Update element positioning and appearance
    DOM-->>User: Render new layout
    Button->>Button: Update button label text
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • openfrontio/OpenFrontIO#3357: Implements the same HUD restructuring with unit-display relocation, alternate layout options, and persistent state controls for layout and style toggling.

Poem

🎨 The layouts split and dance with grace,
New panels find their rightful place,
Gold flows per minute, colors blend,
From classic roots, split paths extend,
Tooltips float where fingers roam. ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: adding UI variants for the control panel (split/classic) and linking attack/unit layout functionality.
Description check ✅ Passed The description is directly related to the changeset, detailing the split/classic panel variants, layout controls, unit display integration, attacks display adjustments, and regeneration bar color changes.
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.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/client/graphics/layers/UnitDisplay.ts (1)

266-318: ⚠️ Potential issue | 🟠 Major

Use a keyboard-focusable control here.

This is still a clickable div with mouse-only handlers, so keyboard and assistive-tech users cannot focus it, trigger it, or get the same highlight/tooltip behavior. Please switch it to a real <button type="button"> or add tabindex, role="button", and matching focus/blur/keydown handlers.

🤖 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 266 - 318, The
clickable div used for unit tiles is not keyboard-focusable; update the element
in UnitDisplay (the block that uses this.canBuild(unitType), selected,
this.uiState.ghostStructure, this.eventBus and emits
GhostStructureChangedEvent/ToggleStructureEvent) to be an actual <button
type="button> or, if you must keep a div, add tabindex="0", role="button", and
wire up focus/blur handlers plus a keydown handler that triggers the same click
behavior on Enter/Space and the same mouseenter/mouseleave behavior for keyboard
focus; ensure the handlers call the same logic that currently runs in the
`@click`, `@mouseenter` and `@mouseleave` callbacks (including canBuild checks,
updating uiState.ghostStructure and emitting
GhostStructureChangedEvent/ToggleStructureEvent) and call this.requestUpdate()
so keyboard users get identical highlighting/tooltip behavior.
🤖 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 536-543: The range inputs for attack ratio in ControlPanel (the
inputs bound to this.attackRatio and using handleRatioSliderInput) are unlabeled
for screen readers; add an accessible label to each of the three inputs (the
ones using .value=${String(Math.round(this.attackRatio * 100))} and attached to
handleRatioSliderInput) by adding either an aria-label="Attack ratio" (or
similarly descriptive text per context) or an aria-labelledby that points to a
visible label element ID so screen readers can identify the control; ensure you
apply the same change to the other two matching inputs in the file.
- Around line 166-179: handleBarTouch currently measures the popup rect and only
runs on touchstart, causing jumpy behavior; update the code so the touch handler
is attached to the actual bar element (query/select the ".attack-drag-bar"
element) and bind it for both "touchstart" and "touchmove", then inside
handleBarTouch compute the rect from that bar element (not the popup) and use
touch.clientY-relative-to-bar to compute ratio, clamp and set attackRatio,
persist and call onAttackRatioChange, requestUpdate and preventDefault; apply
the same change to the other similar handlers referenced (the other touchstart
handlers) so all touch interactions track finger movement.
- Around line 292-334: The current dedup logic uses only amount strings
(tradeCounts keyed by g.toString()) so train BonusEvent entries with the same
gold as a trade display get incorrectly removed; change the matching to include
the event source/type or only deduplicate captured-ship displays: when building
tradeCounts from displayEvents include the display message (or an identifier
like "captured_ship" vs "trade") in the key (e.g., `${event.message}:${amount}`)
and when iterating bonusEvents use the BonusEventUpdate's source/type field (or
only consider deduping if the display message is
"events_display.received_gold_from_captured_ship") to form the same composite
key before decrementing tradeCounts; update places referencing
tradeDisplayGolds, bonusGolds, tradeCounts, trainGold, and sumBigInts
accordingly so only captured-ship bonus entries are removed rather than any
same-valued train income.

In `@src/client/graphics/layers/UnitDisplay.ts`:
- Around line 142-160: The tooltip is currently positioned with hard-coded
viewport coordinates (CSS classes fixed top-[22rem] left-4) so it drifts; in
UnitDisplay.ts update the tooltip rendering to anchor to the hovered element or
panel container instead: remove the fixed/top/left classes and compute position
using the hovered item's DOM bounds (use the element that corresponds to
this._hoveredUnit or the panel container), set inline style top/left (or use
absolute positioning within a relatively positioned container) when rendering
the tooltip that shows this._hoveredStructureKey / this._hoveredDisplayHotkey /
renderNumber(this.cost(this._hoveredUnit)) with goldCoinIcon; ensure fallback
positioning if bounds are unavailable and recalc on layout changes or window
resize.

---

Outside diff comments:
In `@src/client/graphics/layers/UnitDisplay.ts`:
- Around line 266-318: The clickable div used for unit tiles is not
keyboard-focusable; update the element in UnitDisplay (the block that uses
this.canBuild(unitType), selected, this.uiState.ghostStructure, this.eventBus
and emits GhostStructureChangedEvent/ToggleStructureEvent) to be an actual
<button type="button> or, if you must keep a div, add tabindex="0",
role="button", and wire up focus/blur handlers plus a keydown handler that
triggers the same click behavior on Enter/Space and the same
mouseenter/mouseleave behavior for keyboard focus; ensure the handlers call the
same logic that currently runs in the `@click`, `@mouseenter` and `@mouseleave`
callbacks (including canBuild checks, updating uiState.ghostStructure and
emitting GhostStructureChangedEvent/ToggleStructureEvent) and call
this.requestUpdate() so keyboard users get identical highlighting/tooltip
behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4535cdca-1c10-4734-b666-3b48b1eb8994

📥 Commits

Reviewing files that changed from the base of the PR and between 3fca25f and fa08d15.

📒 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

Comment on lines +166 to +179
private handleBarTouch(e: TouchEvent) {
// original touch-drag handler (keep existing behavior)
const touch = e.touches[0];
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const y = touch.clientY - rect.top;
const ratio = 1 - y / rect.height;
const clamped = Math.min(1, Math.max(0.01, ratio));
this.attackRatio = clamped;
localStorage.setItem("settings.attackRatio", String(this.attackRatio));
this.onAttackRatioChange(this.attackRatio);
this.requestUpdate();
e.preventDefault();
}
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 | 🟠 Major

Make the mobile ratio popup track finger movement.

Right now handleBarTouch only runs on touchstart, and it reads the rect of the whole popup instead of the .attack-drag-bar. That turns the control into a jump-to-value tap and skews the math near the label and padding. Bind the handler to the bar itself on both touchstart and touchmove.

Also applies to: 628-643, 736-750

🤖 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 166 - 179,
handleBarTouch currently measures the popup rect and only runs on touchstart,
causing jumpy behavior; update the code so the touch handler is attached to the
actual bar element (query/select the ".attack-drag-bar" element) and bind it for
both "touchstart" and "touchmove", then inside handleBarTouch compute the rect
from that bar element (not the popup) and use touch.clientY-relative-to-bar to
compute ratio, clamp and set attackRatio, persist and call onAttackRatioChange,
requestUpdate and preventDefault; apply the same change to the other similar
handlers referenced (the other touchstart handlers) so all touch interactions
track finger movement.

Comment on lines +292 to +334
const tradeDisplayGolds: bigint[] = [];
const displayEvents = (updates[GameUpdateType.DisplayEvent] ??
[]) as DisplayMessageUpdate[];
for (const event of displayEvents) {
if (event.playerID !== mySmallId || event.goldAmount === undefined)
continue;
if (
event.message === "events_display.received_gold_from_trade" ||
event.message === "events_display.received_gold_from_captured_ship"
) {
tradeDisplayGolds.push(event.goldAmount);
}
}

const bonusGolds: bigint[] = [];
const bonusEvents = (updates[GameUpdateType.BonusEvent] ??
[]) as BonusEventUpdate[];
for (const event of bonusEvents) {
if (event.player !== myId || event.gold <= 0) continue;
bonusGolds.push(BigInt(Math.max(0, Math.floor(event.gold))));
}

// BonusEvent includes train and some ship gains (destination/captured).
// Remove bonus entries that match ship display messages, keep remaining as train gain.
const tradeCounts = new Map<string, number>();
for (const g of tradeDisplayGolds) {
const key = g.toString();
tradeCounts.set(key, (tradeCounts.get(key) ?? 0) + 1);
}

let trainGold = 0n;
for (const bg of bonusGolds) {
const key = bg.toString();
const remaining = tradeCounts.get(key) ?? 0;
if (remaining > 0) {
tradeCounts.set(key, remaining - 1);
} else {
trainGold += bg;
}
}

const shipGold = this.sumBigInts(tradeDisplayGolds);
const combined = shipGold + trainGold;
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 | 🟠 Major

Do not match train income against trade income by amount alone.

A trade display event and a train bonus can have the same gold value in the same tick. With the current Map<string, number> keyed only by amount, that train entry gets discarded and the +.../m badge undercounts. Only de-duplicate captured-ship events against BonusEvent, or include the source in the match key.

Suggested fix
-    const tradeDisplayGolds: bigint[] = [];
+    const tradeGolds: bigint[] = [];
+    const capturedShipDisplayGolds: bigint[] = [];

-      if (
-        event.message === "events_display.received_gold_from_trade" ||
-        event.message === "events_display.received_gold_from_captured_ship"
-      ) {
-        tradeDisplayGolds.push(event.goldAmount);
-      }
+      if (event.message === "events_display.received_gold_from_trade") {
+        tradeGolds.push(event.goldAmount);
+      }
+      if (event.message === "events_display.received_gold_from_captured_ship") {
+        capturedShipDisplayGolds.push(event.goldAmount);
+      }

-    const tradeCounts = new Map<string, number>();
-    for (const g of tradeDisplayGolds) {
+    const capturedShipCounts = new Map<string, number>();
+    for (const g of capturedShipDisplayGolds) {
       const key = g.toString();
-      tradeCounts.set(key, (tradeCounts.get(key) ?? 0) + 1);
+      capturedShipCounts.set(key, (capturedShipCounts.get(key) ?? 0) + 1);
     }

-      const remaining = tradeCounts.get(key) ?? 0;
+      const remaining = capturedShipCounts.get(key) ?? 0;
       if (remaining > 0) {
-        tradeCounts.set(key, remaining - 1);
+        capturedShipCounts.set(key, remaining - 1);
       } else {
         trainGold += bg;
       }

-    const shipGold = this.sumBigInts(tradeDisplayGolds);
-    const combined = shipGold + trainGold;
+    const combined =
+      this.sumBigInts(tradeGolds) +
+      this.sumBigInts(capturedShipDisplayGolds) +
+      trainGold;
🤖 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 292 - 334, The
current dedup logic uses only amount strings (tradeCounts keyed by g.toString())
so train BonusEvent entries with the same gold as a trade display get
incorrectly removed; change the matching to include the event source/type or
only deduplicate captured-ship displays: when building tradeCounts from
displayEvents include the display message (or an identifier like "captured_ship"
vs "trade") in the key (e.g., `${event.message}:${amount}`) and when iterating
bonusEvents use the BonusEventUpdate's source/type field (or only consider
deduping if the display message is
"events_display.received_gold_from_captured_ship") to form the same composite
key before decrementing tradeCounts; update places referencing
tradeDisplayGolds, bonusGolds, tradeCounts, trainGold, and sumBigInts
accordingly so only captured-ship bonus entries are removed rather than any
same-valued train income.

Comment on lines +536 to +543
<input
type="range"
min="1"
max="100"
.value=${String(Math.round(this.attackRatio * 100))}
@input=${(e: Event) => this.handleRatioSliderInput(e)}
class="mt-1 w-full min-w-0 h-1.5 accent-blue-400 cursor-pointer"
/>
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

Label the new attack-ratio sliders for assistive tech.

These input[type="range"] controls only have nearby text, so screen readers will hit an unlabeled slider in each layout branch. Please add aria-label or aria-labelledby to all three inputs.

Also applies to: 579-586, 761-768

🤖 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 536 - 543, The range
inputs for attack ratio in ControlPanel (the inputs bound to this.attackRatio
and using handleRatioSliderInput) are unlabeled for screen readers; add an
accessible label to each of the three inputs (the ones using
.value=${String(Math.round(this.attackRatio * 100))} and attached to
handleRatioSliderInput) by adding either an aria-label="Attack ratio" (or
similarly descriptive text per context) or an aria-labelledby that points to a
visible label element ID so screen readers can identify the control; ensure you
apply the same change to the other two matching inputs in the file.

Comment on lines +142 to +160
<div
class="fixed top-[22rem] left-4 text-gray-200 text-center w-max text-xs bg-gray-800/95 backdrop-blur-xs rounded-sm p-1 z-[5000] shadow-lg pointer-events-none border border-white/10"
>
<div class="font-bold text-sm mb-1">
${translateText("unit_type." + this._hoveredStructureKey)}${` [${this
._hoveredDisplayHotkey}]`}
</div>
<div class="p-2">
${translateText(
"build_menu.desc." + this._hoveredStructureKey,
)}
</div>
<div class="flex items-center justify-center gap-1">
<img src=${goldCoinIcon} width="13" height="13" />
<span class="text-yellow-300"
>${renderNumber(this.cost(this._hoveredUnit))}</span
>
</div>
</div>
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

Anchor the tooltip to the hovered unit.

This tooltip is pinned with fixed top-[22rem] left-4, so it will drift away from the unit bar when the control panel moves between classic/split layouts or when the viewport height changes. Please position it from the hovered item or the panel container instead of hard-coding viewport coordinates.

🤖 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 142 - 160, The
tooltip is currently positioned with hard-coded viewport coordinates (CSS
classes fixed top-[22rem] left-4) so it drifts; in UnitDisplay.ts update the
tooltip rendering to anchor to the hovered element or panel container instead:
remove the fixed/top/left classes and compute position using the hovered item's
DOM bounds (use the element that corresponds to this._hoveredUnit or the panel
container), set inline style top/left (or use absolute positioning within a
relatively positioned container) when rendering the tooltip that shows
this._hoveredStructureKey / this._hoveredDisplayHotkey /
renderNumber(this.cost(this._hoveredUnit)) with goldCoinIcon; ensure fallback
positioning if bounds are unavailable and recalc on layout changes or window
resize.

@github-project-automation github-project-automation bot moved this from Triage to Development in OpenFront Release Management Mar 7, 2026
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