[Icons] Keep the System semantic ladder on the text floor

System authors no palette of its own, so its semantic family is fixed data
measured against whatever surfaces the platform provides. The ladder that
landed with the authored roles reused the old contrast-ordered values:
PositiveSoft and NegativeSoft sat at 2.76–3.90:1 on common light and dark
platform surfaces, and the strong level was less saturated than the
secondary, which is the inversion the chroma rule removes.

Re-author the six default semantic colors per scheme at the same 4.60
target the shipped themes use, against representative platform surfaces,
and mirror them in System's palette files. The new
DefaultSemanticFamilyMeetsTheLadderOnPlatformSurfaces test holds the floor
and the saturation order there, and shippedPalette's early return now says
why System is outside the authored-palette assertions.
This commit is contained in:
Lukas Brübach 2026-11-07 09:39:50 +01:00
parent 3474a7c744
commit ed8e4de3ae
6 changed files with 78 additions and 28 deletions

View file

@ -465,18 +465,22 @@ QColor defaultAppColor(AppColor::Role role, bool dark)
case AppColor::IconStrong: case AppColor::IconStrong:
case AppColor::IconSoft: case AppColor::IconSoft:
return {}; return {};
// The semantic family follows the same chroma ladder as the authored
// themes. A theme without stored values (System) has no per-surface
// palette to measure against, so these are authored against the
// representative platform surfaces the System theme runs on.
case AppColor::Positive: case AppColor::Positive:
return QColor(dark ? QStringLiteral("#6dd58c") : QStringLiteral("#1b7f3b")); return QColor(dark ? QStringLiteral("#6dd58c") : QStringLiteral("#1a7d3a"));
case AppColor::PositiveStrong: case AppColor::PositiveStrong:
return QColor(dark ? QStringLiteral("#99e2ae") : QStringLiteral("#104a22")); return QColor(dark ? QStringLiteral("#32c05c") : QStringLiteral("#007e28"));
case AppColor::PositiveSoft: case AppColor::PositiveSoft:
return QColor(dark ? QStringLiteral("#5e8873") : QStringLiteral("#5d7f6f")); return QColor(dark ? QStringLiteral("#8ab196") : QStringLiteral("#4c7659"));
case AppColor::Negative: case AppColor::Negative:
return QColor(dark ? QStringLiteral("#ff8a80") : QStringLiteral("#b3261e")); return QColor(dark ? QStringLiteral("#ff8a80") : QStringLiteral("#b3261e"));
case AppColor::NegativeStrong: case AppColor::NegativeStrong:
return QColor(dark ? QStringLiteral("#ffb3ac") : QStringLiteral("#7c1b15")); return QColor(dark ? QStringLiteral("#ff8378") : QStringLiteral("#da0b00"));
case AppColor::NegativeSoft: case AppColor::NegativeSoft:
return QColor(dark ? QStringLiteral("#9b7678") : QStringLiteral("#9a6c6f")); return QColor(dark ? QStringLiteral("#c79e9b") : QStringLiteral("#935f5c"));
} }
return {}; return {};

View file

@ -84,7 +84,7 @@ public:
IconTint(QPalette::ColorRole role, IconEmphasis emphasis = IconEmphasis::Secondary); // implicit for role callers IconTint(QPalette::ColorRole role, IconEmphasis emphasis = IconEmphasis::Secondary); // implicit for role callers
IconTint(AppColor::Role role, IconEmphasis emphasis = IconEmphasis::Secondary); // implicit for semantic callers IconTint(AppColor::Role role, IconEmphasis emphasis = IconEmphasis::Secondary); // implicit for semantic callers
/** @brief A neutral glyph in the accent tone, for the surface's primary action. */ /** @brief A neutral glyph at the family's primary level, for the surface's primary action. */
static IconTint primary(); static IconTint primary();
/** @brief A dimmed neutral glyph, for a control that labels rather than acts. */ /** @brief A dimmed neutral glyph, for a control that labels rather than acts. */
@ -93,9 +93,10 @@ public:
/** /**
* @brief Resolves the concrete color for an icon mode. * @brief Resolves the concrete color for an icon mode.
* *
* A disabled application color resolves to the disabled foreground, so an * A disabled application color resolves to the derived disabled ink, the
* unavailable control does not keep its semantic color. A selected palette * quiet level receded toward the surface, so an unavailable control does not
* color keeps the WindowText -> HighlightedText swap. * keep its semantic color. A selected palette color keeps the
* WindowText -> HighlightedText swap.
* *
* Application colors are returned verbatim: the theme author stores one * Application colors are returned verbatim: the theme author stores one
* value per emphasis level, so the paint path does not rewrite it. The * value per emphasis level, so the paint path does not rewrite it. The

View file

@ -9,8 +9,8 @@
AccentStrong = #ff148c3c AccentStrong = #ff148c3c
AccentSoft = #ff78c850 AccentSoft = #ff78c850
Positive = #ff6dd58c Positive = #ff6dd58c
PositiveStrong = #ff99e2ae PositiveStrong = #ff32c05c
PositiveSoft = #ff5e8873 PositiveSoft = #ff8ab196
Negative = #ffff8a80 Negative = #ffff8a80
NegativeStrong = #ffffb3ac NegativeStrong = #ffff8378
NegativeSoft = #ff9b7678 NegativeSoft = #ffc79e9b

View file

@ -8,9 +8,9 @@
[AppColors] [AppColors]
AccentStrong = #ff148c3c AccentStrong = #ff148c3c
AccentSoft = #ff78c850 AccentSoft = #ff78c850
Positive = #ff1b7f3b Positive = #ff1a7d3a
PositiveStrong = #ff104a22 PositiveStrong = #ff007e28
PositiveSoft = #ff5d7f6f PositiveSoft = #ff4c7659
Negative = #ffb3261e Negative = #ffb3261e
NegativeStrong = #ff7c1b15 NegativeStrong = #ffda0b00
NegativeSoft = #ff9a6c6f NegativeSoft = #ff935f5c

View file

@ -1196,7 +1196,10 @@ InkPalette shippedPalette(const QString &theme, bool dark)
.arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light")));
const PaletteConfig config = PaletteConfig::fromFile(file); const PaletteConfig config = PaletteConfig::fromFile(file);
// A theme may author only [AppColors] (System does), so appColors alone do // A theme may author only [AppColors] (System does), so appColors alone do
// not mean it ships a palette: the keys have to be present. // not mean it ships a palette: the keys have to be present. The authored
// neutral-family assertions therefore cover only themes with a palette;
// System's fixed semantic family is checked against representative
// platform surfaces by DefaultSemanticFamilyMeetsTheLadderOnPlatformSurfaces.
if (config.colors.isEmpty()) { if (config.colors.isEmpty()) {
return {}; return {};
} }
@ -1760,6 +1763,48 @@ TEST(IconInk, DisabledStaysBelowQuietOnEveryShippedTheme)
EXPECT_GT(checked, 0) << "no shipped palette was loadable"; EXPECT_GT(checked, 0) << "no shipped palette was loadable";
} }
/**
* @brief The System fallback family keeps the ladder on a platform palette.
*
* System authors no neutral roles and follows the OS surfaces, so its semantic
* family is the one shipped ladder that cannot be measured against its own
* window and button. These are the representative extremes of a platform
* palette, and the fixed values have to clear the same floor there that the
* five authored themes clear on their own surfaces.
*/
TEST(IconInk, DefaultSemanticFamilyMeetsTheLadderOnPlatformSurfaces)
{
struct Scheme
{
bool dark;
QColor window;
QColor button;
};
const QList<Scheme> schemes = {
{false, QColor(QStringLiteral("#f0f0f0")), QColor(QStringLiteral("#f5f5f5"))},
{true, QColor(QStringLiteral("#2b2b2b")), QColor(QStringLiteral("#3c3c3c"))},
};
for (const Scheme &scheme : schemes) {
for (const auto &[base, strong, soft] : {
std::tuple{AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft},
std::tuple{AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}}) {
const QColor secondary = defaultAppColor(base, scheme.dark);
const QColor primary = defaultAppColor(strong, scheme.dark);
const QColor quiet = defaultAppColor(soft, scheme.dark);
ASSERT_TRUE(secondary.isValid() && primary.isValid() && quiet.isValid());
for (const QColor &surface : {scheme.window, scheme.button}) {
EXPECT_GE(ThemeIconInk::contrastRatio(secondary, surface), ThemeIconInk::labelContrastFloor());
EXPECT_GE(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::labelContrastFloor());
EXPECT_GE(ThemeIconInk::contrastRatio(quiet, surface), ThemeIconInk::labelContrastFloor());
}
EXPECT_LT(quiet.hsvSaturation(), secondary.hsvSaturation());
EXPECT_GT(primary.hsvSaturation(), secondary.hsvSaturation());
}
}
}
/** /**
* @brief Every emphasis keeps the selected-row swap to HighlightedText. * @brief Every emphasis keeps the selected-row swap to HighlightedText.
*/ */

View file

@ -151,18 +151,18 @@ TEST_F(ThemeMigrationTest, IsIdempotent)
TEST(ThemeAppColorTest, SemanticDefaultsFollowTheScheme) TEST(ThemeAppColorTest, SemanticDefaultsFollowTheScheme)
{ {
EXPECT_EQ(defaultAppColor(AppColor::Positive, false), QColor(QStringLiteral("#1b7f3b"))); EXPECT_EQ(defaultAppColor(AppColor::Positive, false), QColor(QStringLiteral("#1a7d3a")));
EXPECT_EQ(defaultAppColor(AppColor::Positive, true), QColor(QStringLiteral("#6dd58c"))); EXPECT_EQ(defaultAppColor(AppColor::Positive, true), QColor(QStringLiteral("#6dd58c")));
EXPECT_EQ(defaultAppColor(AppColor::PositiveStrong, false), QColor(QStringLiteral("#104a22"))); EXPECT_EQ(defaultAppColor(AppColor::PositiveStrong, false), QColor(QStringLiteral("#007e28")));
EXPECT_EQ(defaultAppColor(AppColor::PositiveStrong, true), QColor(QStringLiteral("#99e2ae"))); EXPECT_EQ(defaultAppColor(AppColor::PositiveStrong, true), QColor(QStringLiteral("#32c05c")));
EXPECT_EQ(defaultAppColor(AppColor::PositiveSoft, false), QColor(QStringLiteral("#5d7f6f"))); EXPECT_EQ(defaultAppColor(AppColor::PositiveSoft, false), QColor(QStringLiteral("#4c7659")));
EXPECT_EQ(defaultAppColor(AppColor::PositiveSoft, true), QColor(QStringLiteral("#5e8873"))); EXPECT_EQ(defaultAppColor(AppColor::PositiveSoft, true), QColor(QStringLiteral("#8ab196")));
EXPECT_EQ(defaultAppColor(AppColor::Negative, false), QColor(QStringLiteral("#b3261e"))); EXPECT_EQ(defaultAppColor(AppColor::Negative, false), QColor(QStringLiteral("#b3261e")));
EXPECT_EQ(defaultAppColor(AppColor::Negative, true), QColor(QStringLiteral("#ff8a80"))); EXPECT_EQ(defaultAppColor(AppColor::Negative, true), QColor(QStringLiteral("#ff8a80")));
EXPECT_EQ(defaultAppColor(AppColor::NegativeStrong, false), QColor(QStringLiteral("#7c1b15"))); EXPECT_EQ(defaultAppColor(AppColor::NegativeStrong, false), QColor(QStringLiteral("#da0b00")));
EXPECT_EQ(defaultAppColor(AppColor::NegativeStrong, true), QColor(QStringLiteral("#ffb3ac"))); EXPECT_EQ(defaultAppColor(AppColor::NegativeStrong, true), QColor(QStringLiteral("#ff8378")));
EXPECT_EQ(defaultAppColor(AppColor::NegativeSoft, false), QColor(QStringLiteral("#9a6c6f"))); EXPECT_EQ(defaultAppColor(AppColor::NegativeSoft, false), QColor(QStringLiteral("#935f5c")));
EXPECT_EQ(defaultAppColor(AppColor::NegativeSoft, true), QColor(QStringLiteral("#9b7678"))); EXPECT_EQ(defaultAppColor(AppColor::NegativeSoft, true), QColor(QStringLiteral("#c79e9b")));
// Accent and neutral icon roles are derived from the palette, so they have // Accent and neutral icon roles are derived from the palette, so they have
// no static default. A theme with no palette (System) keeps following the // no static default. A theme with no palette (System) keeps following the
// platform instead of pinning one scheme's ink. // platform instead of pinning one scheme's ink.