[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.
This commit is contained in:
Lukas Brübach 2026-11-07 02:19:01 +01:00
parent 2e4731d3a5
commit daa1a73956
13 changed files with 135 additions and 84 deletions

View file

@ -14,13 +14,13 @@ namespace
constexpr double ACCENT_MIX = 0.70; constexpr double ACCENT_MIX = 0.70;
// An icon that labels a control is carrying text; see ui-assets.md. // An icon that labels a control is carrying text; see ui-assets.md.
constexpr double LABEL_CONTRAST_FLOOR = 4.5; constexpr double LABEL_CONTRAST_FLOOR = 4.5;
// A primary glyph has to out-read the default one on both schemes. The accent is // A primary glyph is the most chromatic member of its family. Contrast cannot
// a mid-tone: on a light surface it is *lighter* than the neutral ink, and on a // order the ladder: on a dark surface the highest-contrast tone is the palest
// dark surface it is *darker*, so neither "the accent as-is" nor "a contrast // one, so a contrast target makes the primary look like a washed-out secondary
// floor shared with the default" keeps the primary above the secondary. A 7:1 // and the ladder reads inverted. Primary instead raises the saturation by this
// target does: it darkens the accent on light schemes and lightens it on dark // many HSV points over the secondary and then solves the value back to the text
// ones, so the primary level always out-contrasts the default level. // floor, so it is louder in color at the same legibility.
constexpr double PRIMARY_CONTRAST = 7.0; constexpr int PRIMARY_SATURATION_STEP = 64;
// A quiet glyph is blended this far into its surface. It has to stay findable // A quiet glyph is blended this far into its surface. It has to stay findable
// without competing with the actions beside it. // without competing with the actions beside it.
constexpr double QUIET_BLEND = 0.55; constexpr double QUIET_BLEND = 0.55;
@ -105,11 +105,59 @@ double labelContrastFloor()
return LABEL_CONTRAST_FLOOR; 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) QColor forEmphasis(const QColor &color, const QColor &surface, IconEmphasis emphasis)
{ {
switch (emphasis) { switch (emphasis) {
case IconEmphasis::Primary: case IconEmphasis::Primary:
return reachContrast(color, surface, PRIMARY_CONTRAST).toRgb(); return chromaticStep(color, surface, LABEL_CONTRAST_FLOOR, PRIMARY_SATURATION_STEP).toRgb();
case IconEmphasis::Quiet: case IconEmphasis::Quiet:
return reachContrast(blend(color, surface, QUIET_BLEND), surface, QUIET_CONTRAST_FLOOR).toRgb(); return reachContrast(blend(color, surface, QUIET_BLEND), surface, QUIET_CONTRAST_FLOOR).toRgb();
case IconEmphasis::Secondary: case IconEmphasis::Secondary:
@ -120,7 +168,7 @@ QColor forEmphasis(const QColor &color, const QColor &surface, IconEmphasis emph
double primaryContrastFloor() double primaryContrastFloor()
{ {
return PRIMARY_CONTRAST; return LABEL_CONTRAST_FLOOR;
} }
double quietBlend() double quietBlend()

View file

@ -99,11 +99,12 @@ double labelContrastFloor();
/** /**
* @brief Applies an emphasis level to an already-hued glyph color. * @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. * Secondary keeps the color untouched, so it is the no-op the default needs.
* Primary pushes the color to a higher floor than the default, because the * Primary is the most chromatic member of its family: it raises the saturation
* accent is a mid-tone: on a light scheme it is lighter than the neutral ink and * to the most that still clears the text floor. Contrast alone cannot order the
* on a dark one darker, so only a stronger contrast target keeps the primary * ladder, because on a dark surface the highest-contrast tone is the palest one;
* level above the secondary on both. Quiet * 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 * blends the color toward the surface it sits on and stops at the non-text
* legibility floor, so a labelling glyph recedes without disappearing. * 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); 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(); double primaryContrastFloor();
/** @brief How far a quiet glyph is blended into its surface. */ /** @brief How far a quiet glyph is blended into its surface. */

View file

@ -71,13 +71,13 @@ Accent = #4a82e6
[AppColors] [AppColors]
AccentStrong = #3a6fd6 AccentStrong = #3a6fd6
AccentSoft = #8fb8f2 AccentSoft = #8fb8f2
Icon = #7b9fe4 Icon = #7ca0e5
IconStrong = #b1c7ef IconStrong = #6a9dff
IconSoft = #70809d IconSoft = #70809d
Positive = #6dd58c Positive = #5bb174
PositiveStrong = #99e2ae PositiveStrong = #30b758
PositiveSoft = #5e8873 PositiveSoft = #5e8873
Negative = #ff8a80 Negative = #ed8077
NegativeStrong = #ffb3ac NegativeStrong = #ff7367
NegativeSoft = #9b7678 NegativeSoft = #9b7678

View file

@ -71,13 +71,13 @@ Accent = #2f6fd6
[AppColors] [AppColors]
AccentStrong = #3a6fd6 AccentStrong = #3a6fd6
AccentSoft = #8fb8f2 AccentSoft = #8fb8f2
Icon = #2f59ad Icon = #2f58ac
IconStrong = #213e79 IconStrong = #054ddf
IconSoft = #687899 IconSoft = #687899
Positive = #1b7f3b Positive = #166931
PositiveStrong = #104a22 PositiveStrong = #006b22
PositiveSoft = #5d7f6f PositiveSoft = #5d7f6f
Negative = #b3261e Negative = #b0251d
NegativeStrong = #7c1b15 NegativeStrong = #ba0a00
NegativeSoft = #9a6c6f NegativeSoft = #9a6c6f

View file

@ -71,13 +71,13 @@ Accent = #ff1e1e1e
[AppColors] [AppColors]
AccentStrong = #ff139740 AccentStrong = #ff139740
AccentSoft = #ffc9fd62 AccentSoft = #ffc9fd62
Icon = #5db87c Icon = #5eb97d
IconStrong = #aad9b9 IconStrong = #30be61
IconSoft = #6e8d79 IconSoft = #6e8d79
Positive = #6dd58c Positive = #5fb97a
PositiveStrong = #99e2ae PositiveStrong = #32bf5c
PositiveSoft = #698e74 PositiveSoft = #698e74
Negative = #ff8a80 Negative = #f8867c
NegativeStrong = #ffbfb9 NegativeStrong = #ff8075
NegativeSoft = #a37c78 NegativeSoft = #a37c78

View file

@ -71,13 +71,13 @@ Accent = #fff0f0f0
[AppColors] [AppColors]
AccentStrong = #ff139740 AccentStrong = #ff139740
AccentSoft = #ffc9fd62 AccentSoft = #ffc9fd62
Icon = #107e36 Icon = #107d36
IconStrong = #0b5826 IconStrong = #007e2c
IconSoft = #6c937a IconSoft = #6c937a
Positive = #1b7f3b Positive = #1b7d3a
PositiveStrong = #135929 PositiveStrong = #007e28
PositiveSoft = #70927b PositiveSoft = #70927b
Negative = #b3261e Negative = #cf2c23
NegativeStrong = #7d1b15 NegativeStrong = #da0c00
NegativeSoft = #b37d7b NegativeSoft = #b37d7b

View file

@ -71,13 +71,13 @@ Accent = #c9995a
[AppColors] [AppColors]
AccentStrong = #b7823b AccentStrong = #b7823b
AccentSoft = #e4c58f AccentSoft = #e4c58f
Icon = #bc8c4a Icon = #bd8d4a
IconStrong = #d4b68c IconStrong = #d0851e
IconSoft = #86745a IconSoft = #86745a
Positive = #6dd58c Positive = #55a66d
PositiveStrong = #99e2ae PositiveStrong = #2dab52
PositiveSoft = #588061 PositiveSoft = #588061
Negative = #ff8a80 Negative = #df7970
NegativeStrong = #ffada6 NegativeStrong = #ff5e50
NegativeSoft = #996b66 NegativeSoft = #996b66

View file

@ -71,13 +71,13 @@ Accent = #a5712f
[AppColors] [AppColors]
AccentStrong = #b7823b AccentStrong = #b7823b
AccentSoft = #e4c58f AccentSoft = #e4c58f
Icon = #735125 Icon = #725025
IconStrong = #4e3719 IconStrong = #7e4b09
IconSoft = #817059 IconSoft = #817059
Positive = #1b7f3b Positive = #15642e
PositiveStrong = #0f4520 PositiveStrong = #006621
PositiveSoft = #61795d PositiveSoft = #61795d
Negative = #b3261e Negative = #a7231c
NegativeStrong = #741914 NegativeStrong = #b10a00
NegativeSoft = #9a665b NegativeSoft = #9a665b

View file

@ -71,13 +71,13 @@ Accent = #6a4df0
[AppColors] [AppColors]
AccentStrong = #5b3ee0 AccentStrong = #5b3ee0
AccentSoft = #a28bf7 AccentSoft = #a28bf7
Icon = #927fea Icon = #9380eb
IconStrong = #b7aaf1 IconStrong = #927aff
IconSoft = #736b97 IconSoft = #736b97
Positive = #6dd58c Positive = #519f69
PositiveStrong = #99e2ae PositiveStrong = #2ba44f
PositiveSoft = #517a63 PositiveSoft = #517a63
Negative = #ff8a80 Negative = #d5736b
NegativeStrong = #ffada6 NegativeStrong = #ff4f40
NegativeSoft = #926567 NegativeSoft = #926567

View file

@ -71,13 +71,13 @@ Accent = #5a3ee2
[AppColors] [AppColors]
AccentStrong = #5b3ee0 AccentStrong = #5b3ee0
AccentSoft = #a28bf7 AccentSoft = #a28bf7
Icon = #5439d0 Icon = #5338ce
IconStrong = #39278e IconStrong = #4118fe
IconSoft = #7569a4 IconSoft = #7569a4
Positive = #1b7f3b Positive = #15622e
PositiveStrong = #0e431f PositiveStrong = #006420
PositiveSoft = #5c776d PositiveSoft = #5c776d
Negative = #b3261e Negative = #a4231b
NegativeStrong = #711913 NegativeStrong = #ae0900
NegativeSoft = #95646d NegativeSoft = #95646d

View file

@ -71,13 +71,13 @@ Accent = #8fa6c0
[AppColors] [AppColors]
AccentStrong = #54687e AccentStrong = #54687e
AccentSoft = #9db0c4 AccentSoft = #9db0c4
Icon = #8392a1 Icon = #8493a2
IconStrong = #acb6c0 IconStrong = #6b95bf
IconSoft = #6d737a IconSoft = #6d737a
Positive = #6dd58c Positive = #52a16a
PositiveStrong = #99e2ae PositiveStrong = #2ba650
PositiveSoft = #517c61 PositiveSoft = #517c61
Negative = #ff8a80 Negative = #d8756c
NegativeStrong = #ffada6 NegativeStrong = #ff5344
NegativeSoft = #926865 NegativeSoft = #926865

View file

@ -71,13 +71,13 @@ Accent = #54687e
[AppColors] [AppColors]
AccentStrong = #54687e AccentStrong = #54687e
AccentSoft = #9db0c4 AccentSoft = #9db0c4
Icon = #4a5b6f Icon = #495a6e
IconStrong = #333e4c IconStrong = #375a84
IconSoft = #6e7580 IconSoft = #6e7580
Positive = #1b7f3b Positive = #16662f
PositiveStrong = #0f4620 PositiveStrong = #006721
PositiveSoft = #5b7b6a PositiveSoft = #5b7b6a
Negative = #b3261e Negative = #aa241c
NegativeStrong = #761a14 NegativeStrong = #b40a00
NegativeSoft = #96696b NegativeSoft = #96696b

View file

@ -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 * Contrast cannot order the ladder: on a dark surface the highest-contrast tone
* ordering has to be visible on both, not just on the window the palette is * is the palest one, so a contrast target makes the primary read as a washed-out
* usually previewed against. * 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}) { for (const bool dark : {false, true}) {
const InkPalette palette = shippedPalette(QStringLiteral("Fabric"), dark); const InkPalette palette = shippedPalette(QStringLiteral("Fabric"), dark);
@ -1455,9 +1456,8 @@ TEST_F(ThemeIconRenderTest, PrimaryOutReadsSecondaryOnBothSchemes)
const QColor primary = palette.roles.value(AppColor::IconStrong); const QColor primary = palette.roles.value(AppColor::IconStrong);
ASSERT_TRUE(secondary.isValid() && primary.isValid()) << (dark ? "dark" : "light"); 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}) { 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()) EXPECT_GE(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::primaryContrastFloor())
<< (dark ? "dark" : "light"); << (dark ? "dark" : "light");
} }
@ -1559,7 +1559,9 @@ TEST(IconInk, ShippedPalettesAuthorTheEmphasisLadder)
<< theme.toStdString() << (dark ? " dark" : " light"); << theme.toStdString() << (dark ? " dark" : " light");
EXPECT_GE(primary, ThemeIconInk::primaryContrastFloor()) EXPECT_GE(primary, ThemeIconInk::primaryContrastFloor())
<< theme.toStdString() << (dark ? " dark" : " light"); << 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()) EXPECT_GE(quiet, ThemeIconInk::quietContrastFloor())
<< theme.toStdString() << (dark ? " dark" : " light"); << theme.toStdString() << (dark ? " dark" : " light");
EXPECT_LT(quiet, secondary) << 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"); << theme.toStdString() << (dark ? " dark" : " light");
EXPECT_LT(ThemeIconInk::contrastRatio(palette.roles.value(soft), surface), baseContrast) EXPECT_LT(ThemeIconInk::contrastRatio(palette.roles.value(soft), surface), baseContrast)
<< theme.toStdString() << (dark ? " dark" : " light"); << 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"); << theme.toStdString() << (dark ? " dark" : " light");
} }
} }