[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;
// 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()

View file

@ -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. */

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

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
* 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");
}
}