feat(ui): implement UI/UX audit — i18n, HiDPI, theme, destructive-action & overflow fixes

Implements all 26 confirmed UI/UX audit findings plus the 4 dashboard/timeline
tile labels. Verified across full-node + Lite + Windows builds (ctest green) and
an adversarial diff review (one market-tab delete regression caught + fixed).

i18n coverage:
- Balance hero/quick-actions/toasts + dashboard & timeline tiles (Total Balance,
  Shielded, Transparent, Quick Send, Quick Receive, Click to open, Market)
- Send: Review Send / Cancel / Paste / view-only tooltip / memo byte counter
- Settings: Lite lifecycle errors, plaintext-RPC security warning, 8 toasts
- Mining pool-payout tooltip
- Resolve daemon_update_title double-assignment collision (new daemon_update_prompt_title)
- 23 new keys translated into all 8 locales; CJK subset font rebuilt

HiDPI: DPI-scale the Send amount bar, mining thread-tile clamp bounds, receive
loading skeleton, and sidebar notification-badge insets.

Light theme: theme-aware material::SurfaceOverlay() for balance bar tracks and
row-hover highlights; contacts active-pill foreground uses OnPrimary().

Destructive actions: arm/confirm for portfolio-group delete, saved pool/worker
remove, and avatar-image delete.

Interaction/overflow: route balance star/eye buttons and the console fold-toggle
through popup-safe guards; clip peer addr/subver; truncate the balance custom
label; measure Settings tool-button widths; mining stepper disabled-state
feedback; +/- stepper buttons restyled to match the thread tiles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-18 17:54:15 -05:00
co-authored by Claude Opus 4.8
parent 0de44569b7
commit e24ca015d1
24 changed files with 599 additions and 148 deletions
+66 -30
View File
@@ -42,6 +42,20 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
bool& s_pool_mode, char (&s_pool_url)[256], char (&s_pool_worker)[256],
bool& s_pool_settings_dirty)
{
// Arm/confirm for the destructive X (delete) on saved pools & workers: a first click on a
// row's X arms it (the X turns red + relabels via the trash glyph) and a second click within
// a short window actually removes it; moving to another row or letting the window lapse disarms.
// Same idiom as contacts_tab.cpp's s_confirm_delete_idx, keyed by row index within each list.
static int s_armed_pool_idx = -1; // armed saved-pool row (-1 = none)
static int s_armed_worker_idx = -1; // armed saved-worker row (-1 = none)
static double s_armed_pool_time = 0.0;
static double s_armed_worker_time = 0.0;
const double kArmWindowSecs = 3.0; // second click must land within this window
const double nowTime = ImGui::GetTime();
// Lapse the arm if the confirm window elapsed.
if (s_armed_pool_idx >= 0 && nowTime - s_armed_pool_time > kArmWindowSecs) s_armed_pool_idx = -1;
if (s_armed_worker_idx >= 0 && nowTime - s_armed_worker_time > kArmWindowSecs) s_armed_worker_idx = -1;
const bool soloMiningAvailable = app->supportsSoloMining();
float toggleW = schema::UI().drawElement("tabs.mining", "mode-toggle-width").size * hs;
float toggleH = schema::UI().drawElement("tabs.mining", "mode-toggle-height").size;
@@ -296,9 +310,11 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
ImFont* rowFont = ImGui::GetFont();
float rowFontSz = ImGui::GetFontSize();
float rowH = ImGui::GetFrameHeight();
int rowIdx = 0;
for (const auto& url : savedUrls) {
ImGui::PushID(url.c_str());
bool isCurrent = (std::string(s_pool_url) == url);
bool armed = (s_armed_pool_idx == rowIdx); // this row's X is arm-confirmed
ImVec2 rowMin = ImGui::GetCursorScreenPos();
ImVec2 rowMax(rowMin.x + popupInnerW, rowMin.y + rowH);
ImGui::InvisibleButton("##row", ImVec2(popupInnerW, rowH));
@@ -319,40 +335,46 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
pdl->AddText(rowFont, rowFontSz,
ImVec2(rowMin.x + textPadX, textY),
isCurrent ? Primary() : OnSurface(), urlDisp.c_str());
// X button — flush with right edge, icon centered
// X (delete) button — flush with right edge, icon centered. First click on the X
// arms it (filled trash glyph + readable-error tint); a second click within the
// arm window actually removes. Armed row stays lit even when not hovered so the
// pending-delete state is visible.
{
ImVec2 xMin(rowMax.x - xZoneW, rowMin.y);
ImVec2 xMax(rowMax.x, rowMax.y);
if (inXZone) {
pdl->AddRectFilled(xMin, xMax, IM_COL32(255, 80, 80, 30));
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
material::Tooltip("%s", TR("mining_remove"));
} else if (rowHov) {
// Show faint X when row is hovered
ImFont* icoF = Type().iconSmall();
const char* xIcon = ICON_MD_CLOSE;
ImVec2 iSz = icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, xIcon);
ImVec2 xCenter((xMin.x + xMax.x) * 0.5f, (xMin.y + xMax.y) * 0.5f);
pdl->AddText(icoF, icoF->LegacySize,
ImVec2(xCenter.x - iSz.x * 0.5f, xCenter.y - iSz.y * 0.5f),
OnSurfaceDisabled(), xIcon);
material::Tooltip("%s", armed ? TR("address_book_confirm_delete")
: TR("mining_remove"));
}
// Always draw icon when hovering X zone
if (inXZone) {
if (armed || inXZone || rowHov) {
ImFont* icoF = Type().iconSmall();
const char* xIcon = ICON_MD_CLOSE;
// Filled trash while armed reinforces the recolor; otherwise a plain X.
const char* xIcon = armed ? ICON_MD_DELETE : ICON_MD_CLOSE;
ImU32 xCol = armed ? ReadableError()
: (inXZone ? Error() : OnSurfaceDisabled());
ImVec2 iSz = icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, xIcon);
ImVec2 xCenter((xMin.x + xMax.x) * 0.5f, (xMin.y + xMax.y) * 0.5f);
pdl->AddText(icoF, icoF->LegacySize,
ImVec2(xCenter.x - iSz.x * 0.5f, xCenter.y - iSz.y * 0.5f),
Error(), xIcon);
xCol, xIcon);
}
}
// Hovering a *different* row's X disarms the previously armed one.
if (inXZone && !armed && s_armed_pool_idx >= 0)
s_armed_pool_idx = -1;
// Click handling
if (rowClk) {
if (inXZone) {
urlToRemove = url;
if (armed) {
urlToRemove = url; // 2nd click on the armed row removes
} else {
s_armed_pool_idx = rowIdx; // 1st click arms this row
s_armed_pool_time = nowTime;
}
} else {
s_armed_pool_idx = -1; // selecting a row disarms
strncpy(s_pool_url, url.c_str(), sizeof(s_pool_url) - 1);
s_pool_url[sizeof(s_pool_url) - 1] = '\0';
s_pool_settings_dirty = true;
@@ -362,10 +384,12 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
if (rowHov && !inXZone)
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::PopID();
rowIdx++;
}
if (!urlToRemove.empty()) {
app->settings()->removeSavedPoolUrl(urlToRemove);
app->settings()->save();
s_armed_pool_idx = -1; // removal shifts indices — drop the arm
}
}
ImGui::EndPopup();
@@ -460,9 +484,11 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
ImFont* wRowFont = ImGui::GetFont();
float wRowFontSz = ImGui::GetFontSize();
float wRowH = ImGui::GetFrameHeight();
int wRowIdx = 0;
for (const auto& addr : savedWorkers) {
ImGui::PushID(addr.c_str());
bool isCurrent = (std::string(s_pool_worker) == addr);
bool armed = (s_armed_worker_idx == wRowIdx); // this row's X is arm-confirmed
ImVec2 rowMin = ImGui::GetCursorScreenPos();
ImVec2 rowMax(rowMin.x + wPopupInnerW, rowMin.y + wRowH);
ImGui::InvisibleButton("##row", ImVec2(wPopupInnerW, wRowH));
@@ -486,38 +512,46 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
// Tooltip for long addresses
if (rowHov && !inXZone)
material::Tooltip("%s", addr.c_str());
// X button — flush with right edge, icon centered
// X (delete) button — flush with right edge, icon centered. First click on the X
// arms it (filled trash glyph + readable-error tint); a second click within the
// arm window actually removes. Armed row stays lit even when not hovered so the
// pending-delete state is visible.
{
ImVec2 xMin(rowMax.x - wXZoneW, rowMin.y);
ImVec2 xMax(rowMax.x, rowMax.y);
if (inXZone) {
pdl->AddRectFilled(xMin, xMax, IM_COL32(255, 80, 80, 30));
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
material::Tooltip("%s", TR("mining_remove"));
} else if (rowHov) {
ImFont* icoF = Type().iconSmall();
const char* xIcon = ICON_MD_CLOSE;
ImVec2 iSz = icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, xIcon);
ImVec2 xCenter((xMin.x + xMax.x) * 0.5f, (xMin.y + xMax.y) * 0.5f);
pdl->AddText(icoF, icoF->LegacySize,
ImVec2(xCenter.x - iSz.x * 0.5f, xCenter.y - iSz.y * 0.5f),
OnSurfaceDisabled(), xIcon);
material::Tooltip("%s", armed ? TR("address_book_confirm_delete")
: TR("mining_remove"));
}
if (inXZone) {
if (armed || inXZone || rowHov) {
ImFont* icoF = Type().iconSmall();
const char* xIcon = ICON_MD_CLOSE;
// Filled trash while armed reinforces the recolor; otherwise a plain X.
const char* xIcon = armed ? ICON_MD_DELETE : ICON_MD_CLOSE;
ImU32 xCol = armed ? ReadableError()
: (inXZone ? Error() : OnSurfaceDisabled());
ImVec2 iSz = icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, xIcon);
ImVec2 xCenter((xMin.x + xMax.x) * 0.5f, (xMin.y + xMax.y) * 0.5f);
pdl->AddText(icoF, icoF->LegacySize,
ImVec2(xCenter.x - iSz.x * 0.5f, xCenter.y - iSz.y * 0.5f),
Error(), xIcon);
xCol, xIcon);
}
}
// Hovering a *different* row's X disarms the previously armed one.
if (inXZone && !armed && s_armed_worker_idx >= 0)
s_armed_worker_idx = -1;
// Click handling
if (rowClk) {
if (inXZone) {
addrToRemove = addr;
if (armed) {
addrToRemove = addr; // 2nd click on the armed row removes
} else {
s_armed_worker_idx = wRowIdx; // 1st click arms this row
s_armed_worker_time = nowTime;
}
} else {
s_armed_worker_idx = -1; // selecting a row disarms
strncpy(s_pool_worker, addr.c_str(), sizeof(s_pool_worker) - 1);
s_pool_worker[sizeof(s_pool_worker) - 1] = '\0';
s_pool_settings_dirty = true;
@@ -527,10 +561,12 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
if (rowHov && !inXZone)
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::PopID();
wRowIdx++;
}
if (!addrToRemove.empty()) {
app->settings()->removeSavedPoolWorker(addrToRemove);
app->settings()->save();
s_armed_worker_idx = -1; // removal shifts indices — drop the arm
}
}
ImGui::EndPopup();