[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

@ -1196,7 +1196,10 @@ InkPalette shippedPalette(const QString &theme, bool dark)
.arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light")));
const PaletteConfig config = PaletteConfig::fromFile(file);
// 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()) {
return {};
}
@ -1760,6 +1763,48 @@ TEST(IconInk, DisabledStaysBelowQuietOnEveryShippedTheme)
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.
*/

View file

@ -151,18 +151,18 @@ TEST_F(ThemeMigrationTest, IsIdempotent)
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::PositiveStrong, false), QColor(QStringLiteral("#104a22")));
EXPECT_EQ(defaultAppColor(AppColor::PositiveStrong, true), QColor(QStringLiteral("#99e2ae")));
EXPECT_EQ(defaultAppColor(AppColor::PositiveSoft, false), QColor(QStringLiteral("#5d7f6f")));
EXPECT_EQ(defaultAppColor(AppColor::PositiveSoft, true), QColor(QStringLiteral("#5e8873")));
EXPECT_EQ(defaultAppColor(AppColor::PositiveStrong, false), QColor(QStringLiteral("#007e28")));
EXPECT_EQ(defaultAppColor(AppColor::PositiveStrong, true), QColor(QStringLiteral("#32c05c")));
EXPECT_EQ(defaultAppColor(AppColor::PositiveSoft, false), QColor(QStringLiteral("#4c7659")));
EXPECT_EQ(defaultAppColor(AppColor::PositiveSoft, true), QColor(QStringLiteral("#8ab196")));
EXPECT_EQ(defaultAppColor(AppColor::Negative, false), QColor(QStringLiteral("#b3261e")));
EXPECT_EQ(defaultAppColor(AppColor::Negative, true), QColor(QStringLiteral("#ff8a80")));
EXPECT_EQ(defaultAppColor(AppColor::NegativeStrong, false), QColor(QStringLiteral("#7c1b15")));
EXPECT_EQ(defaultAppColor(AppColor::NegativeStrong, true), QColor(QStringLiteral("#ffb3ac")));
EXPECT_EQ(defaultAppColor(AppColor::NegativeSoft, false), QColor(QStringLiteral("#9a6c6f")));
EXPECT_EQ(defaultAppColor(AppColor::NegativeSoft, true), QColor(QStringLiteral("#9b7678")));
EXPECT_EQ(defaultAppColor(AppColor::NegativeStrong, false), QColor(QStringLiteral("#da0b00")));
EXPECT_EQ(defaultAppColor(AppColor::NegativeStrong, true), QColor(QStringLiteral("#ff8378")));
EXPECT_EQ(defaultAppColor(AppColor::NegativeSoft, false), QColor(QStringLiteral("#935f5c")));
EXPECT_EQ(defaultAppColor(AppColor::NegativeSoft, true), QColor(QStringLiteral("#c79e9b")));
// 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
// platform instead of pinning one scheme's ink.