[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

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