fix(console): readable theme-colored log text on light themes

The console log text washed out on Marble/Light (~1.2:1 contrast): refreshColors
chose text colors from the stored IsDarkTheme() flag (default true, and only
re-checked on a dark/light toggle) while the terminal surface used IsLightTheme(),
so dark-theme pale text landed on the light surface. Tie both to the same live
background-luminance predicate and refresh on any theme (schema generation) change.
Light themes now derive each channel from that theme's own palette (Primary,
OnSurface, Error, Secondary, ...) nudged to a WCAG contrast floor — on-theme AND
high-contrast (measured 4.6-17.8:1 across the five light skins).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 12:32:49 -05:00
parent aa60188665
commit 57147fd06b

View File

@@ -90,12 +90,78 @@ bool isResultBodyChannel(ConsoleChannel ch)
} // namespace } // namespace
namespace {
// ── WCAG contrast helpers — keep console text high-contrast while following each theme's palette. ──
inline float srgbToLinear(float c8) {
float c = c8 / 255.0f;
return c <= 0.04045f ? c / 12.92f : std::pow((c + 0.055f) / 1.055f, 2.4f);
}
inline float relLuminance(float r, float g, float b) {
return 0.2126f * srgbToLinear(r) + 0.7152f * srgbToLinear(g) + 0.0722f * srgbToLinear(b);
}
inline float contrastRatio(float lumA, float lumB) {
float hi = std::max(lumA, lumB), lo = std::min(lumA, lumB);
return (hi + 0.05f) / (lo + 0.05f);
}
// Nudge `fg` toward black (light surface) or white (dark surface), preserving hue + alpha, until its
// WCAG contrast against `bg` reaches `minRatio`. Colors already passing come back essentially unchanged.
inline ImU32 EnsureContrast(ImU32 fg, ImU32 bg, float minRatio) {
const float bgL = relLuminance((float)((bg >> IM_COL32_R_SHIFT) & 0xFF),
(float)((bg >> IM_COL32_G_SHIFT) & 0xFF),
(float)((bg >> IM_COL32_B_SHIFT) & 0xFF));
float r = (float)((fg >> IM_COL32_R_SHIFT) & 0xFF);
float g = (float)((fg >> IM_COL32_G_SHIFT) & 0xFF);
float b = (float)((fg >> IM_COL32_B_SHIFT) & 0xFF);
const int a = (int)((fg >> IM_COL32_A_SHIFT) & 0xFF);
const bool darken = bgL > 0.5f; // light surface -> darken the text; dark surface -> lighten it
for (int i = 0; i < 40 && contrastRatio(relLuminance(r, g, b), bgL) < minRatio; ++i) {
if (darken) { r *= 0.90f; g *= 0.90f; b *= 0.90f; }
else { r += (255.0f - r) * 0.12f; g += (255.0f - g) * 0.12f; b += (255.0f - b) * 0.12f; }
}
return IM_COL32((int)(r + 0.5f), (int)(g + 0.5f), (int)(b + 0.5f), a);
}
// Floor a dynamically-derived channel color to a readable contrast on light terminals; no-op on dark.
inline ImU32 FloorLight(ImU32 c) {
return material::IsLightTheme() ? EnsureContrast(c, IM_COL32(255, 255, 255, 255), 4.5f) : c;
}
} // namespace
void ConsoleTab::refreshColors() void ConsoleTab::refreshColors()
{ {
using namespace material;
auto& S = schema::UI(); auto& S = schema::UI();
bool dark = material::IsDarkTheme(); // Derive light/dark from the SAME live background-luminance predicate the terminal surface uses
// (the overlay at line ~274 is chosen by IsLightTheme()). Using the stored IsDarkTheme() flag here
// let the two disagree — baking dark-theme (pale) text onto the light near-white console surface.
bool dark = !IsLightTheme();
// Try schema overrides first, then use sensible per-theme defaults // Per-theme channel defaults.
ImU32 defCmd, defRes, defErr, defDmn, defInf, defRpc;
if (dark) {
// Dark terminal: keep the authored light-on-dark palette (schema overrides honored below).
defCmd = IM_COL32(191, 209, 229, 255);
defRes = IM_COL32(200, 200, 200, 255);
defErr = IM_COL32(246, 71, 64, 255);
defDmn = IM_COL32(160, 160, 160, 180);
defInf = IM_COL32(191, 209, 229, 255);
defRpc = IM_COL32(120, 180, 255, 210);
} else {
// Light terminal: follow THIS theme's own palette (Marble slate/taupe, Dune sand, Light blue, …)
// so the console reads on-theme — each color nudged toward black until it clears a WCAG contrast
// floor on the near-white console surface. High-contrast AND theme-colored.
const ImU32 W = IM_COL32(255, 255, 255, 255); // overlay is white(205); target pure white = safe floor
defCmd = EnsureContrast(Primary(), W, 4.5f); // command echo — theme primary
defRes = EnsureContrast(OnSurface(), W, 7.0f); // result body — theme text (already dark)
defErr = EnsureContrast(Error(), W, 4.5f); // errors — theme error
defDmn = WithAlpha(EnsureContrast(OnSurfaceMedium(), W, 4.5f), 235); // node log — dimmer, still legible
defInf = EnsureContrast(Primary(), W, 4.5f); // info / app log — theme primary
defRpc = EnsureContrast(Secondary(), W, 4.5f); // rpc trace — theme secondary
}
// Schema console colors are authored for the dark terminal (light-on-dark); honor them only in dark
// themes. Light themes always use the palette-derived, contrast-floored defaults above.
if (S.isLoaded()) { if (S.isLoaded()) {
auto cmd = S.drawElement("console", "color-command"); auto cmd = S.drawElement("console", "color-command");
auto res = S.drawElement("console", "color-result"); auto res = S.drawElement("console", "color-result");
@@ -103,17 +169,6 @@ void ConsoleTab::refreshColors()
auto dmn = S.drawElement("console", "color-daemon"); auto dmn = S.drawElement("console", "color-daemon");
auto inf = S.drawElement("console", "color-info"); auto inf = S.drawElement("console", "color-info");
auto rpc = S.drawElement("console", "color-rpc"); auto rpc = S.drawElement("console", "color-rpc");
ImU32 defCmd = dark ? IM_COL32(191, 209, 229, 255) : IM_COL32(21, 101, 192, 255);
ImU32 defRes = dark ? IM_COL32(200, 200, 200, 255) : IM_COL32(50, 50, 50, 255);
ImU32 defErr = dark ? IM_COL32(246, 71, 64, 255) : IM_COL32(198, 40, 40, 255);
ImU32 defDmn = dark ? IM_COL32(160, 160, 160, 180) : IM_COL32(90, 90, 90, 200);
ImU32 defInf = dark ? IM_COL32(191, 209, 229, 255) : IM_COL32(21, 101, 192, 255);
ImU32 defRpc = dark ? IM_COL32(120, 180, 255, 210) : IM_COL32(25, 118, 210, 220);
// The schema console colors are authored for the dark terminal (light-on-dark); honor them only
// in dark themes. Light themes always use the dark-text defaults so log text stays legible on the
// now near-white console surface (otherwise the schema's light-blue text sat pale-on-white ~1.2:1).
COLOR_COMMAND = (dark && !cmd.color.empty()) ? S.resolveColor(cmd.color, defCmd) : defCmd; COLOR_COMMAND = (dark && !cmd.color.empty()) ? S.resolveColor(cmd.color, defCmd) : defCmd;
COLOR_RESULT = (dark && !res.color.empty()) ? S.resolveColor(res.color, defRes) : defRes; COLOR_RESULT = (dark && !res.color.empty()) ? S.resolveColor(res.color, defRes) : defRes;
COLOR_ERROR = (dark && !err.color.empty()) ? S.resolveColor(err.color, defErr) : defErr; COLOR_ERROR = (dark && !err.color.empty()) ? S.resolveColor(err.color, defErr) : defErr;
@@ -121,13 +176,12 @@ void ConsoleTab::refreshColors()
COLOR_INFO = (dark && !inf.color.empty()) ? S.resolveColor(inf.color, defInf) : defInf; COLOR_INFO = (dark && !inf.color.empty()) ? S.resolveColor(inf.color, defInf) : defInf;
COLOR_RPC = (dark && !rpc.color.empty()) ? S.resolveColor(rpc.color, defRpc) : defRpc; COLOR_RPC = (dark && !rpc.color.empty()) ? S.resolveColor(rpc.color, defRpc) : defRpc;
} else { } else {
// No schema — use hardcoded defaults per theme COLOR_COMMAND = defCmd;
COLOR_COMMAND = dark ? IM_COL32(191, 209, 229, 255) : IM_COL32(21, 101, 192, 255); COLOR_RESULT = defRes;
COLOR_RESULT = dark ? IM_COL32(200, 200, 200, 255) : IM_COL32(50, 50, 50, 255); COLOR_ERROR = defErr;
COLOR_ERROR = dark ? IM_COL32(246, 71, 64, 255) : IM_COL32(198, 40, 40, 255); COLOR_DAEMON = defDmn;
COLOR_DAEMON = dark ? IM_COL32(160, 160, 160, 180) : IM_COL32(90, 90, 90, 200); COLOR_INFO = defInf;
COLOR_INFO = dark ? IM_COL32(191, 209, 229, 255) : IM_COL32(21, 101, 192, 255); COLOR_RPC = defRpc;
COLOR_RPC = dark ? IM_COL32(120, 180, 255, 210) : IM_COL32(25, 118, 210, 220);
} }
} }
@@ -135,19 +189,22 @@ ImU32 ConsoleTab::channelTextColor(ConsoleChannel channel) const
{ {
using namespace material; using namespace material;
switch (channel) { switch (channel) {
// COLOR_* channels are already palette-derived + contrast-floored in refreshColors(). The
// roles below are computed live from the theme palette, so floor them to a readable contrast on
// light terminals here (FloorLight is a no-op on dark themes, preserving the authored look).
case ConsoleChannel::Command: return COLOR_COMMAND; case ConsoleChannel::Command: return COLOR_COMMAND;
case ConsoleChannel::Info: return COLOR_INFO; case ConsoleChannel::Info: return COLOR_INFO;
case ConsoleChannel::Success: return WithAlpha(Success(), 255); case ConsoleChannel::Success: return FloorLight(WithAlpha(Success(), 255));
case ConsoleChannel::Warning: return Warning(); case ConsoleChannel::Warning: return FloorLight(Warning());
case ConsoleChannel::Error: return COLOR_ERROR; case ConsoleChannel::Error: return COLOR_ERROR;
case ConsoleChannel::Rpc: return COLOR_RPC; case ConsoleChannel::Rpc: return COLOR_RPC;
case ConsoleChannel::Daemon: return COLOR_DAEMON; case ConsoleChannel::Daemon: return COLOR_DAEMON;
case ConsoleChannel::Xmrig: return COLOR_DAEMON; case ConsoleChannel::Xmrig: return COLOR_DAEMON;
case ConsoleChannel::App: return COLOR_INFO; case ConsoleChannel::App: return COLOR_INFO;
// JSON syntax roles — highlight against the plain-result body. // JSON syntax roles — highlight against the plain-result body.
case ConsoleChannel::JsonKey: return WithAlpha(Secondary(), 255); case ConsoleChannel::JsonKey: return FloorLight(WithAlpha(Secondary(), 255));
case ConsoleChannel::JsonString: return WithAlpha(Success(), 255); case ConsoleChannel::JsonString: return FloorLight(WithAlpha(Success(), 255));
case ConsoleChannel::JsonNumber: return WithAlpha(Warning(), 255); case ConsoleChannel::JsonNumber: return FloorLight(WithAlpha(Warning(), 255));
case ConsoleChannel::JsonBrace: return IsLightTheme() ? IM_COL32(90, 90, 90, 180) : IM_COL32(200, 200, 200, 150); case ConsoleChannel::JsonBrace: return IsLightTheme() ? IM_COL32(90, 90, 90, 180) : IM_COL32(200, 200, 200, 150);
case ConsoleChannel::None: case ConsoleChannel::None:
default: return COLOR_RESULT; default: return COLOR_RESULT;
@@ -208,14 +265,16 @@ void ConsoleTab::render(ConsoleCommandExecutor& exec)
{ {
using namespace material; using namespace material;
// Refresh the console theme colors on a dark/light switch. Line colors are derived // Refresh the console theme colors whenever the theme changes. Keyed on the schema generation
// from each line's channel at draw time, so no per-line remap is needed. // (bumped on every theme/skin load) rather than a dark/light toggle: the constructor's refreshColors()
// can run before the theme is applied (s_isDarkTheme still defaults true), and a toggle-only trigger
// would never correct that on startup. Line colors are derived from each line's channel at draw time.
{ {
static bool s_lastDark = IsDarkTheme(); static uint32_t s_lastGen = ~0u;
bool nowDark = IsDarkTheme(); uint32_t gen = schema::UI().generation();
if (nowDark != s_lastDark) { if (gen != s_lastGen) {
refreshColors(); refreshColors();
s_lastDark = nowDark; s_lastGen = gen;
} }
} }