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
+40 -15
View File
@@ -309,7 +309,7 @@ static void evaluateLiteLifecycleRequestFromPageState(App* app) {
std::string trimmedPath(s_settingsState.lite_wallet_path);
const auto first = trimmedPath.find_first_not_of(" \t\r\n");
if (first == std::string::npos) {
s_settingsState.lite_lifecycle_status = "Enter a wallet path";
s_settingsState.lite_lifecycle_status = TR("lite_enter_wallet_path");
s_settingsState.lite_lifecycle_summary.clear();
return;
}
@@ -320,8 +320,9 @@ static void evaluateLiteLifecycleRequestFromPageState(App* app) {
if (input.request.operation == wallet::LiteWalletLifecycleOperation::RestoreFromSeed) {
const int words = liteSeedWordCount(s_settingsState.lite_restore_seed);
if (!dragonx::util::isCompleteRecoveryPhrase(words)) {
s_settingsState.lite_lifecycle_status =
"Enter all 24 seed words to restore (got " + std::to_string(words) + ")";
char seedBuf[128];
snprintf(seedBuf, sizeof(seedBuf), TR("lite_enter_all_seed_words"), words);
s_settingsState.lite_lifecycle_status = seedBuf;
s_settingsState.lite_lifecycle_summary.clear();
return;
}
@@ -353,7 +354,7 @@ static void evaluateLiteLifecycleRequestFromPageState(App* app) {
// Rejected before any thread launched (wallet already open, an attempt in flight, or no
// usable server). The controller's status carries the reason.
s_settingsState.lite_lifecycle_status = lite->status().message.empty()
? "Could not start the operation"
? TR("lite_could_not_start")
: lite->status().message;
Notifications::instance().warning(s_settingsState.lite_lifecycle_status);
}
@@ -364,7 +365,7 @@ static void evaluateLiteLifecycleRequestFromPageState(App* app) {
// failed to load / rollout-disabled). The live path above returns when a backend is
// present, so reaching here means there is nothing to run.
s_settingsState.lite_lifecycle_summary.clear();
s_settingsState.lite_lifecycle_status = "Lite wallet backend unavailable";
s_settingsState.lite_lifecycle_status = TR("lite_backend_unavailable");
Notifications::instance().warning(s_settingsState.lite_lifecycle_status);
}
@@ -819,7 +820,7 @@ void RenderSettingsPage(App* app) {
ImGui::SameLine(0, Layout::spacingSm());
if (TactileButton(TR("refresh"), ImVec2(refreshBtnW, 0), S.resolveFont("button"))) {
schema::SkinManager::instance().refresh();
Notifications::instance().info("Theme list refreshed");
Notifications::instance().info(TR("settings_theme_refreshed"));
}
if (ImGui::IsItemHovered()) {
material::Tooltip(TR("tt_scan_themes"),
@@ -1064,7 +1065,7 @@ void RenderSettingsPage(App* app) {
ImGui::SameLine();
if (TactileButton(TR("refresh"), ImVec2(refreshBtnW, 0), S.resolveFont("button"))) {
schema::SkinManager::instance().refresh();
Notifications::instance().info("Theme list refreshed");
Notifications::instance().info(TR("settings_theme_refreshed"));
}
if (ImGui::IsItemHovered()) {
material::Tooltip(TR("tt_scan_themes"),
@@ -1386,6 +1387,30 @@ void RenderSettingsPage(App* app) {
float bw = (contentW - btnSpacing * (btnsPerRow - 1)) / btnsPerRow;
float minBtnW = S.drawElement("components.settings-page", "wallet-btn-min-width").sizeOr(100.0f);
bw = std::max(minBtnW, bw);
// Grow the column so the longest translated label (e.g. German) isn't clipped inside
// ImGui::Button. Measure every label with the actual button font (LegacySize is already
// DPI-scaled — don't scale it again) and add the button's own FramePadding on both sides;
// if that exceeds bw, drop to fewer columns rather than overflow the card width.
{
ImFont* toolsFont = S.resolveFont("button");
if (!toolsFont) toolsFont = Type().button();
const char* toolLabels[] = {
TR("settings_address_book"), TR("settings_validate_address"),
TR("settings_request_payment"), TR("settings_shield_mining"),
TR("settings_merge_to_address"), TR("settings_clear_ztx"),
};
float widestLabel = 0.0f;
for (const char* lbl : toolLabels)
widestLabel = std::max(widestLabel,
toolsFont->CalcTextSizeA(toolsFont->LegacySize, FLT_MAX, 0, lbl).x);
const float needW = widestLabel + ImGui::GetStyle().FramePadding.x * 2.0f
+ 8.0f * Layout::dpiScale();
// Shed columns until the widest label fits (or we're down to a single column).
while (btnsPerRow > 1 &&
((contentW - btnSpacing * (btnsPerRow - 1)) / btnsPerRow) < needW)
--btnsPerRow;
bw = std::max({minBtnW, (contentW - btnSpacing * (btnsPerRow - 1)) / btnsPerRow, needW});
}
if (TactileButton(TR("settings_address_book"), ImVec2(bw, 0), S.resolveFont("button")))
app->setCurrentPage(ui::NavPage::Contacts); // now a top-level tab
@@ -2225,7 +2250,7 @@ void RenderSettingsPage(App* app) {
if (s_settingsState.rpc_plaintext_remote) {
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning()));
ImGui::PushTextWrapPos(sectionOrigin.x + contentW);
ImGui::TextWrapped("Remote RPC is using plaintext HTTP. Add rpctls=1 to DRAGONX.conf if your daemon supports TLS.");
ImGui::TextWrapped("%s", TR("rpc_plaintext_remote_warning"));
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
}
@@ -2367,14 +2392,14 @@ void RenderSettingsPage(App* app) {
try {
rpc::RPCClient::TraceScope trace("Settings / Test connection");
rpc->call("getinfo");
return []() { Notifications::instance().success("RPC connection OK"); };
return []() { Notifications::instance().success(TR("settings_rpc_ok")); };
} catch (const std::exception& e) {
std::string err = e.what();
return [err]() { Notifications::instance().error("RPC error: " + err); };
return [err]() { Notifications::instance().error(std::string(TR("settings_rpc_error_prefix")) + err); };
}
});
} else {
Notifications::instance().warning("Not connected to daemon");
Notifications::instance().warning(TR("settings_not_connected"));
}
}
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_test_conn"));
@@ -2587,14 +2612,14 @@ void RenderSettingsPage(App* app) {
if (aboutGrid) ImGui::Dummy(ImVec2(0, Layout::spacingXs())); else ImGui::SameLine(0, Layout::spacingMd());
if (TactileButton(TrId("save_settings", "about_save").c_str(), ImVec2(aboutBtnW, 0), S.resolveFont("button"))) {
saveSettingsPageState(app->settings());
Notifications::instance().success("Settings saved");
Notifications::instance().success(TR("settings_saved"));
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_save_settings"));
ImGui::SameLine(0, Layout::spacingMd());
if (TactileButton(TrId("reset_to_defaults", "about_reset").c_str(), ImVec2(aboutBtnW, 0), S.resolveFont("button"))) {
if (app->settings()) {
loadSettingsPageState(app->settings());
Notifications::instance().info("Settings reloaded from disk");
Notifications::instance().info(TR("settings_reloaded"));
}
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_reset_settings"));
@@ -2913,9 +2938,9 @@ void RenderSettingsPage(App* app) {
if (doConfirm) {
std::string ztx_file = util::Platform::getDragonXDataDir() + "ztx_history.json";
if (util::Platform::deleteFile(ztx_file)) {
Notifications::instance().success("Z-transaction history cleared");
Notifications::instance().success(TR("settings_ztx_cleared"));
} else {
Notifications::instance().info("No history file found");
Notifications::instance().info(TR("settings_ztx_not_found"));
}
s_settingsState.confirm_clear_ztx = false;
}