From daa1a73956cd1be180c51ac5115405a0b2d472d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= <44814898+BruebachL@users.noreply.github.com> Date: Sat, 7 Nov 2026 02:19:01 +0100 Subject: [PATCH] [Icons] Order the emphasis ladder by chroma, not contrast Contrast cannot order the ladder. On a dark surface the highest-contrast tone is the palest one, so a 7:1 primary came out near-white while the secondary kept the family's color; the ladder read inverted, and for the shipped semantic greens and reds the primary level was already satisfied by the secondary, so the two levels resolved to the same hex. Emphasis is chromatic intensity now. Quiet recedes toward the surface at the 3:1 non-text floor, secondary keeps the family color at the 4.5:1 text floor, and primary is the most chromatic tone the same floor allows. Every level stays legible on the window and the button surface, and the primary is a strictly more saturated member of its family on both schemes. The reformatted palettes move the two upper levels onto the shared floor and leave the quiet level alone. --- cockatrice/src/interface/theme_icon_ink.cpp | 66 ++++++++++++++++--- cockatrice/src/interface/theme_icon_ink.h | 13 ++-- .../themes/Fabric/palette-default-dark.toml | 12 ++-- .../themes/Fabric/palette-default-light.toml | 12 ++-- .../themes/Fusion/palette-default-dark.toml | 12 ++-- .../themes/Fusion/palette-default-light.toml | 12 ++-- .../themes/Leather/palette-default-dark.toml | 12 ++-- .../themes/Leather/palette-default-light.toml | 12 ++-- .../themes/Plasma/palette-default-dark.toml | 12 ++-- .../themes/Plasma/palette-default-light.toml | 12 ++-- .../VelvetMarble/palette-default-dark.toml | 12 ++-- .../VelvetMarble/palette-default-light.toml | 12 ++-- .../theme_icon_render_test.cpp | 20 +++--- 13 files changed, 135 insertions(+), 84 deletions(-) diff --git a/cockatrice/src/interface/theme_icon_ink.cpp b/cockatrice/src/interface/theme_icon_ink.cpp index 1b0748091..f74dc2010 100644 --- a/cockatrice/src/interface/theme_icon_ink.cpp +++ b/cockatrice/src/interface/theme_icon_ink.cpp @@ -14,13 +14,13 @@ namespace constexpr double ACCENT_MIX = 0.70; // An icon that labels a control is carrying text; see ui-assets.md. constexpr double LABEL_CONTRAST_FLOOR = 4.5; -// A primary glyph has to out-read the default one on both schemes. The accent is -// a mid-tone: on a light surface it is *lighter* than the neutral ink, and on a -// dark surface it is *darker*, so neither "the accent as-is" nor "a contrast -// floor shared with the default" keeps the primary above the secondary. A 7:1 -// target does: it darkens the accent on light schemes and lightens it on dark -// ones, so the primary level always out-contrasts the default level. -constexpr double PRIMARY_CONTRAST = 7.0; +// A primary glyph is the most chromatic member of its family. Contrast cannot +// order the ladder: on a dark surface the highest-contrast tone is the palest +// one, so a contrast target makes the primary look like a washed-out secondary +// and the ladder reads inverted. Primary instead raises the saturation by this +// many HSV points over the secondary and then solves the value back to the text +// floor, so it is louder in color at the same legibility. +constexpr int PRIMARY_SATURATION_STEP = 64; // A quiet glyph is blended this far into its surface. It has to stay findable // without competing with the actions beside it. constexpr double QUIET_BLEND = 0.55; @@ -105,11 +105,59 @@ double labelContrastFloor() return LABEL_CONTRAST_FLOOR; } +namespace +{ +/** @brief The luminance a color needs to reach @p ratio against @p surface. */ +double targetLuminance(const QColor &surface, double ratio) +{ + const double surfaceLuminance = ThemeIconInk::relativeLuminance(surface); + return surfaceLuminance > 0.5 ? (surfaceLuminance + 0.05) / ratio - 0.05 : (surfaceLuminance + 0.05) * ratio - 0.05; +} + +/** + * @brief Raises a color's saturation, then solves its value for the floor. + * + * Saturation is raised before the value is solved, because reaching a contrast + * floor by adding white is what desaturates a tone in the first place. If the + * hue cannot reach the floor at the raised saturation inside the gamut, the step + * shrinks until it can, so an already-vivid tone keeps what it has. A gray has + * no saturation to raise and falls back to the contrast search. + */ +QColor chromaticStep(const QColor &color, const QColor &surface, double floor, int stepPoints) +{ + if (color.hue() < 0) { + return ThemeIconInk::reachContrast(color, surface, floor); + } + + const double target = targetLuminance(surface, floor); + const int hue = color.hue(); + const int secondary = color.hsvSaturation(); + const int primary = qMin(255, secondary + stepPoints); + for (int saturation = primary; saturation > secondary; --saturation) { + if (ThemeIconInk::relativeLuminance(QColor::fromHsv(hue, saturation, 255)) < target) { + continue; + } + int low = 0; + int high = 255; + for (int search = 0; search < CONTRAST_SEARCH_STEPS; ++search) { + const int middle = (low + high) / 2; + if (ThemeIconInk::relativeLuminance(QColor::fromHsv(hue, saturation, middle)) >= target) { + high = middle; + } else { + low = middle; + } + } + return QColor::fromHsv(hue, saturation, high).toRgb(); + } + return ThemeIconInk::reachContrast(color, surface, floor); +} +} // namespace + QColor forEmphasis(const QColor &color, const QColor &surface, IconEmphasis emphasis) { switch (emphasis) { case IconEmphasis::Primary: - return reachContrast(color, surface, PRIMARY_CONTRAST).toRgb(); + return chromaticStep(color, surface, LABEL_CONTRAST_FLOOR, PRIMARY_SATURATION_STEP).toRgb(); case IconEmphasis::Quiet: return reachContrast(blend(color, surface, QUIET_BLEND), surface, QUIET_CONTRAST_FLOOR).toRgb(); case IconEmphasis::Secondary: @@ -120,7 +168,7 @@ QColor forEmphasis(const QColor &color, const QColor &surface, IconEmphasis emph double primaryContrastFloor() { - return PRIMARY_CONTRAST; + return LABEL_CONTRAST_FLOOR; } double quietBlend() diff --git a/cockatrice/src/interface/theme_icon_ink.h b/cockatrice/src/interface/theme_icon_ink.h index 6ba90a239..f1af04184 100644 --- a/cockatrice/src/interface/theme_icon_ink.h +++ b/cockatrice/src/interface/theme_icon_ink.h @@ -99,11 +99,12 @@ double labelContrastFloor(); /** * @brief Applies an emphasis level to an already-hued glyph color. * - * Secondary keeps the hue untouched, so it is the no-op the default needs. - * Primary pushes the color to a higher floor than the default, because the - * accent is a mid-tone: on a light scheme it is lighter than the neutral ink and - * on a dark one darker, so only a stronger contrast target keeps the primary - * level above the secondary on both. Quiet + * Secondary keeps the color untouched, so it is the no-op the default needs. + * Primary is the most chromatic member of its family: it raises the saturation + * to the most that still clears the text floor. Contrast alone cannot order the + * ladder, because on a dark surface the highest-contrast tone is the palest one; + * a primary that reads as a washed-out version of the secondary is exactly the + * inversion this rule removes. Quiet * blends the color toward the surface it sits on and stops at the non-text * legibility floor, so a labelling glyph recedes without disappearing. * @@ -114,7 +115,7 @@ double labelContrastFloor(); */ QColor forEmphasis(const QColor &color, const QColor &surface, IconEmphasis emphasis); -/** @brief Contrast floor the primary level reaches, so it out-reads the default. */ +/** @brief Contrast floor the primary level reaches, so it stays legible. */ double primaryContrastFloor(); /** @brief How far a quiet glyph is blended into its surface. */ diff --git a/cockatrice/themes/Fabric/palette-default-dark.toml b/cockatrice/themes/Fabric/palette-default-dark.toml index 2412f7c03..916a27ea3 100644 --- a/cockatrice/themes/Fabric/palette-default-dark.toml +++ b/cockatrice/themes/Fabric/palette-default-dark.toml @@ -71,13 +71,13 @@ Accent = #4a82e6 [AppColors] AccentStrong = #3a6fd6 AccentSoft = #8fb8f2 -Icon = #7b9fe4 -IconStrong = #b1c7ef +Icon = #7ca0e5 +IconStrong = #6a9dff IconSoft = #70809d -Positive = #6dd58c -PositiveStrong = #99e2ae +Positive = #5bb174 +PositiveStrong = #30b758 PositiveSoft = #5e8873 -Negative = #ff8a80 -NegativeStrong = #ffb3ac +Negative = #ed8077 +NegativeStrong = #ff7367 NegativeSoft = #9b7678 diff --git a/cockatrice/themes/Fabric/palette-default-light.toml b/cockatrice/themes/Fabric/palette-default-light.toml index f122a5aeb..8a27f4c30 100644 --- a/cockatrice/themes/Fabric/palette-default-light.toml +++ b/cockatrice/themes/Fabric/palette-default-light.toml @@ -71,13 +71,13 @@ Accent = #2f6fd6 [AppColors] AccentStrong = #3a6fd6 AccentSoft = #8fb8f2 -Icon = #2f59ad -IconStrong = #213e79 +Icon = #2f58ac +IconStrong = #054ddf IconSoft = #687899 -Positive = #1b7f3b -PositiveStrong = #104a22 +Positive = #166931 +PositiveStrong = #006b22 PositiveSoft = #5d7f6f -Negative = #b3261e -NegativeStrong = #7c1b15 +Negative = #b0251d +NegativeStrong = #ba0a00 NegativeSoft = #9a6c6f diff --git a/cockatrice/themes/Fusion/palette-default-dark.toml b/cockatrice/themes/Fusion/palette-default-dark.toml index ece7633ac..037eab5d0 100644 --- a/cockatrice/themes/Fusion/palette-default-dark.toml +++ b/cockatrice/themes/Fusion/palette-default-dark.toml @@ -71,13 +71,13 @@ Accent = #ff1e1e1e [AppColors] AccentStrong = #ff139740 AccentSoft = #ffc9fd62 -Icon = #5db87c -IconStrong = #aad9b9 +Icon = #5eb97d +IconStrong = #30be61 IconSoft = #6e8d79 -Positive = #6dd58c -PositiveStrong = #99e2ae +Positive = #5fb97a +PositiveStrong = #32bf5c PositiveSoft = #698e74 -Negative = #ff8a80 -NegativeStrong = #ffbfb9 +Negative = #f8867c +NegativeStrong = #ff8075 NegativeSoft = #a37c78 diff --git a/cockatrice/themes/Fusion/palette-default-light.toml b/cockatrice/themes/Fusion/palette-default-light.toml index 3465168eb..cdf5a6a7f 100644 --- a/cockatrice/themes/Fusion/palette-default-light.toml +++ b/cockatrice/themes/Fusion/palette-default-light.toml @@ -71,13 +71,13 @@ Accent = #fff0f0f0 [AppColors] AccentStrong = #ff139740 AccentSoft = #ffc9fd62 -Icon = #107e36 -IconStrong = #0b5826 +Icon = #107d36 +IconStrong = #007e2c IconSoft = #6c937a -Positive = #1b7f3b -PositiveStrong = #135929 +Positive = #1b7d3a +PositiveStrong = #007e28 PositiveSoft = #70927b -Negative = #b3261e -NegativeStrong = #7d1b15 +Negative = #cf2c23 +NegativeStrong = #da0c00 NegativeSoft = #b37d7b diff --git a/cockatrice/themes/Leather/palette-default-dark.toml b/cockatrice/themes/Leather/palette-default-dark.toml index ab5fe82b7..a613da217 100644 --- a/cockatrice/themes/Leather/palette-default-dark.toml +++ b/cockatrice/themes/Leather/palette-default-dark.toml @@ -71,13 +71,13 @@ Accent = #c9995a [AppColors] AccentStrong = #b7823b AccentSoft = #e4c58f -Icon = #bc8c4a -IconStrong = #d4b68c +Icon = #bd8d4a +IconStrong = #d0851e IconSoft = #86745a -Positive = #6dd58c -PositiveStrong = #99e2ae +Positive = #55a66d +PositiveStrong = #2dab52 PositiveSoft = #588061 -Negative = #ff8a80 -NegativeStrong = #ffada6 +Negative = #df7970 +NegativeStrong = #ff5e50 NegativeSoft = #996b66 diff --git a/cockatrice/themes/Leather/palette-default-light.toml b/cockatrice/themes/Leather/palette-default-light.toml index df1d50978..4469c127b 100644 --- a/cockatrice/themes/Leather/palette-default-light.toml +++ b/cockatrice/themes/Leather/palette-default-light.toml @@ -71,13 +71,13 @@ Accent = #a5712f [AppColors] AccentStrong = #b7823b AccentSoft = #e4c58f -Icon = #735125 -IconStrong = #4e3719 +Icon = #725025 +IconStrong = #7e4b09 IconSoft = #817059 -Positive = #1b7f3b -PositiveStrong = #0f4520 +Positive = #15642e +PositiveStrong = #006621 PositiveSoft = #61795d -Negative = #b3261e -NegativeStrong = #741914 +Negative = #a7231c +NegativeStrong = #b10a00 NegativeSoft = #9a665b diff --git a/cockatrice/themes/Plasma/palette-default-dark.toml b/cockatrice/themes/Plasma/palette-default-dark.toml index 487f0dff4..ee6f9d254 100644 --- a/cockatrice/themes/Plasma/palette-default-dark.toml +++ b/cockatrice/themes/Plasma/palette-default-dark.toml @@ -71,13 +71,13 @@ Accent = #6a4df0 [AppColors] AccentStrong = #5b3ee0 AccentSoft = #a28bf7 -Icon = #927fea -IconStrong = #b7aaf1 +Icon = #9380eb +IconStrong = #927aff IconSoft = #736b97 -Positive = #6dd58c -PositiveStrong = #99e2ae +Positive = #519f69 +PositiveStrong = #2ba44f PositiveSoft = #517a63 -Negative = #ff8a80 -NegativeStrong = #ffada6 +Negative = #d5736b +NegativeStrong = #ff4f40 NegativeSoft = #926567 diff --git a/cockatrice/themes/Plasma/palette-default-light.toml b/cockatrice/themes/Plasma/palette-default-light.toml index 16199858b..b3e96ca75 100644 --- a/cockatrice/themes/Plasma/palette-default-light.toml +++ b/cockatrice/themes/Plasma/palette-default-light.toml @@ -71,13 +71,13 @@ Accent = #5a3ee2 [AppColors] AccentStrong = #5b3ee0 AccentSoft = #a28bf7 -Icon = #5439d0 -IconStrong = #39278e +Icon = #5338ce +IconStrong = #4118fe IconSoft = #7569a4 -Positive = #1b7f3b -PositiveStrong = #0e431f +Positive = #15622e +PositiveStrong = #006420 PositiveSoft = #5c776d -Negative = #b3261e -NegativeStrong = #711913 +Negative = #a4231b +NegativeStrong = #ae0900 NegativeSoft = #95646d diff --git a/cockatrice/themes/VelvetMarble/palette-default-dark.toml b/cockatrice/themes/VelvetMarble/palette-default-dark.toml index 238a7e40c..b5b43df0a 100644 --- a/cockatrice/themes/VelvetMarble/palette-default-dark.toml +++ b/cockatrice/themes/VelvetMarble/palette-default-dark.toml @@ -71,13 +71,13 @@ Accent = #8fa6c0 [AppColors] AccentStrong = #54687e AccentSoft = #9db0c4 -Icon = #8392a1 -IconStrong = #acb6c0 +Icon = #8493a2 +IconStrong = #6b95bf IconSoft = #6d737a -Positive = #6dd58c -PositiveStrong = #99e2ae +Positive = #52a16a +PositiveStrong = #2ba650 PositiveSoft = #517c61 -Negative = #ff8a80 -NegativeStrong = #ffada6 +Negative = #d8756c +NegativeStrong = #ff5344 NegativeSoft = #926865 diff --git a/cockatrice/themes/VelvetMarble/palette-default-light.toml b/cockatrice/themes/VelvetMarble/palette-default-light.toml index a5ba5e959..c61f1e94c 100644 --- a/cockatrice/themes/VelvetMarble/palette-default-light.toml +++ b/cockatrice/themes/VelvetMarble/palette-default-light.toml @@ -71,13 +71,13 @@ Accent = #54687e [AppColors] AccentStrong = #54687e AccentSoft = #9db0c4 -Icon = #4a5b6f -IconStrong = #333e4c +Icon = #495a6e +IconStrong = #375a84 IconSoft = #6e7580 -Positive = #1b7f3b -PositiveStrong = #0f4620 +Positive = #16662f +PositiveStrong = #006721 PositiveSoft = #5b7b6a -Negative = #b3261e -NegativeStrong = #761a14 +Negative = #aa241c +NegativeStrong = #b40a00 NegativeSoft = #96696b diff --git a/tests/theme_migration/theme_icon_render_test.cpp b/tests/theme_migration/theme_icon_render_test.cpp index 672826462..25253a29d 100644 --- a/tests/theme_migration/theme_icon_render_test.cpp +++ b/tests/theme_migration/theme_icon_render_test.cpp @@ -1440,13 +1440,14 @@ TEST_F(ThemeIconRenderTest, EmphasisSelectsTheAuthoredVariant) } /** - * @brief The authored primary level out-reads the default one on both schemes. + * @brief The authored primary level is the most chromatic member of its family. * - * The three levels are authored against the window and button surfaces, so the - * ordering has to be visible on both, not just on the window the palette is - * usually previewed against. + * Contrast cannot order the ladder: on a dark surface the highest-contrast tone + * is the palest one, so a contrast target makes the primary read as a washed-out + * secondary. Chroma orders the levels instead; contrast only has to stay above + * the legibility floor on every surface the icon is drawn on. */ -TEST_F(ThemeIconRenderTest, PrimaryOutReadsSecondaryOnBothSchemes) +TEST_F(ThemeIconRenderTest, PrimaryIsTheMostChromaticLevelOnBothSchemes) { for (const bool dark : {false, true}) { const InkPalette palette = shippedPalette(QStringLiteral("Fabric"), dark); @@ -1455,9 +1456,8 @@ TEST_F(ThemeIconRenderTest, PrimaryOutReadsSecondaryOnBothSchemes) const QColor primary = palette.roles.value(AppColor::IconStrong); ASSERT_TRUE(secondary.isValid() && primary.isValid()) << (dark ? "dark" : "light"); + EXPECT_GT(primary.hsvSaturation(), secondary.hsvSaturation()) << (dark ? "dark" : "light"); for (const QColor &surface : {palette.window, palette.button}) { - EXPECT_GT(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::contrastRatio(secondary, surface)) - << (dark ? "dark" : "light"); EXPECT_GE(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::primaryContrastFloor()) << (dark ? "dark" : "light"); } @@ -1559,7 +1559,9 @@ TEST(IconInk, ShippedPalettesAuthorTheEmphasisLadder) << theme.toStdString() << (dark ? " dark" : " light"); EXPECT_GE(primary, ThemeIconInk::primaryContrastFloor()) << theme.toStdString() << (dark ? " dark" : " light"); - EXPECT_GT(primary, secondary) << theme.toStdString() << (dark ? " dark" : " light"); + EXPECT_GT(palette.roles.value(AppColor::IconStrong).hsvSaturation(), + palette.roles.value(AppColor::Icon).hsvSaturation()) + << theme.toStdString() << (dark ? " dark" : " light"); EXPECT_GE(quiet, ThemeIconInk::quietContrastFloor()) << theme.toStdString() << (dark ? " dark" : " light"); EXPECT_LT(quiet, secondary) << theme.toStdString() << (dark ? " dark" : " light"); @@ -1573,7 +1575,7 @@ TEST(IconInk, ShippedPalettesAuthorTheEmphasisLadder) << theme.toStdString() << (dark ? " dark" : " light"); EXPECT_LT(ThemeIconInk::contrastRatio(palette.roles.value(soft), surface), baseContrast) << theme.toStdString() << (dark ? " dark" : " light"); - EXPECT_GT(ThemeIconInk::contrastRatio(palette.roles.value(strong), surface), baseContrast) + EXPECT_GT(palette.roles.value(strong).hsvSaturation(), palette.roles.value(base).hsvSaturation()) << theme.toStdString() << (dark ? " dark" : " light"); } }