From 0aa0407f162e3cf1e5c71b0d1f252ab29558c88a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= <44814898+BruebachL@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:07:46 +0100 Subject: [PATCH] [Icons] Add an Informational semantic color family Face-down play and the exile zone are states that are neither a gain nor a loss, and the pre-#7595 art painted them blue (arrow_right_blue). #7595 dropped that hue, and the icon engine has had only Positive and Negative since, so both states fall back to neutral ink. Add the family with the same three-level ladder the other semantic roles use: authored roles per theme, scheme defaults otherwise, an emphasis mapping, the palette-editor rows and the generator seed. The defaults clear the 4.5:1 label floor on both schemes and the ladder's saturation ordering; the existing emphasis, hue and preview tests now cover the new family. Preview sheets that name roles resolve an unauthored one through the same default the client paints. --- .../palette_editor/palette_generator.cpp | 9 ++-- .../palette_editor/palette_grid_widget.cpp | 5 +++ cockatrice/src/interface/theme_config.cpp | 9 ++++ cockatrice/src/interface/theme_config.h | 13 +++--- cockatrice/src/interface/theme_icon.cpp | 4 ++ cockatrice/src/interface/theme_manager.cpp | 3 ++ .../icon_tint_preview_test.cpp | 11 +++-- .../icon_treatment_preview_test.cpp | 11 +++-- .../theme_icon_render_test.cpp | 45 +++++++++++++++---- .../theme_migration/theme_migration_test.cpp | 6 +++ 10 files changed, 93 insertions(+), 23 deletions(-) diff --git a/cockatrice/src/interface/palette_editor/palette_generator.cpp b/cockatrice/src/interface/palette_editor/palette_generator.cpp index d5d70c4ef..b64858b68 100644 --- a/cockatrice/src/interface/palette_editor/palette_generator.cpp +++ b/cockatrice/src/interface/palette_editor/palette_generator.cpp @@ -178,9 +178,12 @@ PaletteConfig fromAccent(const QColor &accent, int intensity, const QString &sch cfg.appColors[AppColor::IconStrong] = ThemeIconInk::forEmphasis(iconInk, iconSurface, IconEmphasis::Primary); cfg.appColors[AppColor::IconSoft] = ThemeIconInk::forEmphasis(iconInk, iconSurface, IconEmphasis::Quiet); // Semantic roles stay independent of the accent: a generated palette must - // still paint add/ready green and remove/not-ready red. - for (const AppColor::Role role : {AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft, - AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}) { + // still paint add/ready green, remove/not-ready red, and the neutral + // information state blue. + for (const AppColor::Role role : + {AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft, AppColor::Negative, + AppColor::NegativeStrong, AppColor::NegativeSoft, AppColor::Informational, AppColor::InformationalStrong, + AppColor::InformationalSoft}) { cfg.appColors[role] = defaultAppColor(role, dark); } diff --git a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp index 73e821b90..51c1f8fbb 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp @@ -78,6 +78,11 @@ static const QMap APP_ROLE_DESCRIPTIONS = { QT_TRANSLATE_NOOP("PaletteGridWidget", "Negative state and removal actions (not ready, remove, decrement)")}, {AppColor::NegativeStrong, QT_TRANSLATE_NOOP("PaletteGridWidget", "Negative state at primary emphasis")}, {AppColor::NegativeSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Negative state at quiet emphasis")}, + {AppColor::Informational, + QT_TRANSLATE_NOOP("PaletteGridWidget", + "Informational state that is neither a gain nor a loss (face-down, exile)")}, + {AppColor::InformationalStrong, QT_TRANSLATE_NOOP("PaletteGridWidget", "Informational state at primary emphasis")}, + {AppColor::InformationalSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Informational state at quiet emphasis")}, }; PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent) diff --git a/cockatrice/src/interface/theme_config.cpp b/cockatrice/src/interface/theme_config.cpp index 848be2231..d170e4fd1 100644 --- a/cockatrice/src/interface/theme_config.cpp +++ b/cockatrice/src/interface/theme_config.cpp @@ -482,6 +482,15 @@ QColor defaultAppColor(AppColor::Role role, bool dark) return QColor(dark ? QStringLiteral("#ff8378") : QStringLiteral("#da0b00")); case AppColor::NegativeSoft: return QColor(dark ? QStringLiteral("#c79e9b") : QStringLiteral("#935f5c")); + // Informational is the third semantic family: a state that is neither a + // gain nor a loss (a face-down play, the exile zone). It carries the + // same three-level ladder so its emphasis is authored, not computed. + case AppColor::Informational: + return QColor(dark ? QStringLiteral("#7aa9f2") : QStringLiteral("#1c64c4")); + case AppColor::InformationalStrong: + return QColor(dark ? QStringLiteral("#69aeff") : QStringLiteral("#0a4fb5")); + case AppColor::InformationalSoft: + return QColor(dark ? QStringLiteral("#9db2cd") : QStringLiteral("#526a8a")); } return {}; diff --git a/cockatrice/src/interface/theme_config.h b/cockatrice/src/interface/theme_config.h index 68734e762..666bdcd44 100644 --- a/cockatrice/src/interface/theme_config.h +++ b/cockatrice/src/interface/theme_config.h @@ -15,11 +15,11 @@ // beyond what Qt's palette can express. // // The icon families carry one role per emphasis level. Icon is the neutral -// glyph ink; IconStrong and IconSoft are its primary and quiet levels. Positive -// and Negative follow the same pattern, so an emphasis never has to be computed -// from a hue at paint time: the theme authors all three levels. Naming a variant -// explicitly (`IconStrong`) pins that level; naming the family (`Icon`) lets the -// caller's emphasis choose. +// glyph ink; IconStrong and IconSoft are its primary and quiet levels. Positive, +// Negative and Informational follow the same pattern, so an emphasis never has +// to be computed from a hue at paint time: the theme authors all three levels. +// Naming a variant explicitly (`IconStrong`) pins that level; naming the family +// (`Icon`) lets the caller's emphasis choose. namespace AppColor { Q_NAMESPACE @@ -36,6 +36,9 @@ enum Role Negative, NegativeStrong, NegativeSoft, + Informational, + InformationalStrong, + InformationalSoft, }; Q_ENUM_NS(Role) } // namespace AppColor diff --git a/cockatrice/src/interface/theme_icon.cpp b/cockatrice/src/interface/theme_icon.cpp index f0c9ef84f..eeaae5a97 100644 --- a/cockatrice/src/interface/theme_icon.cpp +++ b/cockatrice/src/interface/theme_icon.cpp @@ -54,6 +54,10 @@ AppColor::Role emphasisRole(AppColor::Role role, IconEmphasis emphasis) return emphasis == IconEmphasis::Primary ? AppColor::NegativeStrong : emphasis == IconEmphasis::Quiet ? AppColor::NegativeSoft : AppColor::Negative; + case AppColor::Informational: + return emphasis == IconEmphasis::Primary ? AppColor::InformationalStrong + : emphasis == IconEmphasis::Quiet ? AppColor::InformationalSoft + : AppColor::Informational; default: return role; } diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index 1ad0309b2..d0fa816dd 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -683,6 +683,9 @@ QColor ThemeManager::appColor(AppColor::Role role) const case AppColor::Negative: case AppColor::NegativeStrong: case AppColor::NegativeSoft: + case AppColor::Informational: + case AppColor::InformationalStrong: + case AppColor::InformationalSoft: // Semantic colors are independent of the accent: a missing role must // not silently paint "remove" in the theme's highlight color. return defaultAppColor(role, isDarkModeActive()); diff --git a/tests/theme_migration/icon_tint_preview_test.cpp b/tests/theme_migration/icon_tint_preview_test.cpp index b4244cef0..7cc6ca153 100644 --- a/tests/theme_migration/icon_tint_preview_test.cpp +++ b/tests/theme_migration/icon_tint_preview_test.cpp @@ -68,10 +68,12 @@ template QPixmap renderAtDpr(const QSize &logicalSize, qreal d * @brief Writes one semantic-tint sheet per shipped palette theme. * * The theme files are the only place the shipped tones live, so each sheet is - * rendered from the palette the theme actually ships: both semantic families, - * three glyphs each, at the two sizes the UI uses, on the window and button - * surfaces the contrast is measured against. A disabled row closes each family - * so the enabled/disabled order is visible next to the tone that carries it. + * rendered from the palette the theme actually ships: all three semantic + * families, three glyphs each, at the two sizes the UI uses, on the window and + * button surfaces the contrast is measured against. A disabled row closes each + * family so the enabled/disabled order is visible next to the tone that carries + * it. A theme that does not author Informational falls back to the scheme + * default, which is what the client paints. */ TEST(IconTintPreview, WritesOneSheetPerShippedTheme) { @@ -100,6 +102,7 @@ TEST(IconTintPreview, WritesOneSheetPerShippedTheme) const QList families = { {"Positive", AppColor::Positive, {"increment", "ready_start", "arrow_up"}}, {"Negative", AppColor::Negative, {"decrement", "not_ready_start", "remove_row"}}, + {"Informational", AppColor::Informational, {"arrow_right", "view", "arrow_to_top"}}, }; const QStringList themes = { QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), diff --git a/tests/theme_migration/icon_treatment_preview_test.cpp b/tests/theme_migration/icon_treatment_preview_test.cpp index 1d027a9e7..ec2f087a9 100644 --- a/tests/theme_migration/icon_treatment_preview_test.cpp +++ b/tests/theme_migration/icon_treatment_preview_test.cpp @@ -69,7 +69,7 @@ template QPixmap renderAtDpr(const QSize &logicalSize, qreal d * @brief Writes one badge/sticker sheet per shipped palette theme. * * The treatments are only useful if they read on every palette, so each sheet - * is rendered from the palette the theme actually ships: both semantic + * is rendered from the palette the theme actually ships: all three semantic * families, three glyphs each, as line glyphs, stickers and badges, at the two * sizes the UI uses, on the window and button surfaces the contrast is measured * against. A disabled row closes each family: every treatment falls back to the @@ -102,9 +102,14 @@ TEST(IconTreatmentPreview, WritesOneSheetPerShippedTheme) AppColor::Role role; const char *glyphs[3]; }; + // The hover panel's action grid is the first call site that puts a + // treatment on an action glyph, so the sheet renders the glyphs that grid + // actually uses: play/clone/hand (Positive), reduce life and graveyard + // (Negative), place face down and exile (Informational). const QList families = { - {"Positive", AppColor::Positive, {"increment", "ready_start", "arrow_up"}}, - {"Negative", AppColor::Negative, {"decrement", "not_ready_start", "remove_row"}}, + {"Positive", AppColor::Positive, {"arrow_right", "increment", "arrow_left"}}, + {"Negative", AppColor::Negative, {"arrow_down", "decrement", "remove_row"}}, + {"Informational", AppColor::Informational, {"arrow_right", "view", "arrow_to_top"}}, }; const QStringList themes = { QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), diff --git a/tests/theme_migration/theme_icon_render_test.cpp b/tests/theme_migration/theme_icon_render_test.cpp index 93203be6c..cac4c5a7f 100644 --- a/tests/theme_migration/theme_icon_render_test.cpp +++ b/tests/theme_migration/theme_icon_render_test.cpp @@ -1535,9 +1535,11 @@ TEST_F(ThemeIconRenderTest, EmphasisSelectsTheAuthoredVariant) AppColor::Role soft; }; - for (const Family &family : {Family{AppColor::Icon, AppColor::IconStrong, AppColor::IconSoft}, - Family{AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft}, - Family{AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}}) { + for (const Family &family : + {Family{AppColor::Icon, AppColor::IconStrong, AppColor::IconSoft}, + Family{AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft}, + Family{AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}, + Family{AppColor::Informational, AppColor::InformationalStrong, AppColor::InformationalSoft}}) { EXPECT_EQ(IconTint(family.base, IconEmphasis::Primary).resolve(QIcon::Normal), themeManager->appColor(family.strong)); EXPECT_EQ(IconTint(family.base).resolve(QIcon::Normal), themeManager->appColor(family.base)); @@ -1636,6 +1638,19 @@ TEST_F(ThemeIconRenderTest, EmphasisKeepsASemanticHue) EXPECT_GT(ink.red(), ink.green()) << theme.toStdString() << static_cast(role); EXPECT_GT(ink.red(), ink.blue()) << theme.toStdString() << static_cast(role); } + + for (const AppColor::Role role : + {AppColor::Informational, AppColor::InformationalStrong, AppColor::InformationalSoft}) { + // Informational may come from the scheme default when the theme + // does not author it, so measure the value the client paints. + const QColor ink = roles.value(role, defaultAppColor(role, dark)); + if (!ink.isValid()) { + continue; + } + ++checked; + EXPECT_GT(ink.blue(), ink.red()) << theme.toStdString() << static_cast(role); + EXPECT_GT(ink.blue(), ink.green()) << theme.toStdString() << static_cast(role); + } } } EXPECT_GT(checked, 0) << "no shipped theme authored a semantic emphasis level"; @@ -1821,7 +1836,8 @@ TEST(IconInk, DefaultSemanticFamilyMeetsTheLadderOnPlatformSurfaces) 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}}) { + std::tuple{AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}, + std::tuple{AppColor::Informational, AppColor::InformationalStrong, AppColor::InformationalSoft}}) { const QColor secondary = defaultAppColor(base, scheme.dark); const QColor primary = defaultAppColor(strong, scheme.dark); const QColor quiet = defaultAppColor(soft, scheme.dark); @@ -1908,6 +1924,11 @@ TEST_F(ThemeIconRenderTest, EmphasisPreviewWritesPngsWhenRequested) AppColor::NegativeSoft, AppColor::Negative, AppColor::NegativeStrong}, + {"Informational", + {"arrow_right", "view", "arrow_to_top"}, + AppColor::InformationalSoft, + AppColor::Informational, + AppColor::InformationalStrong}, }; const QStringList themes = { QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), @@ -1942,9 +1963,10 @@ TEST_F(ThemeIconRenderTest, EmphasisPreviewWritesPngsWhenRequested) // The paint path is a lookup now, so the sheet shows the authored // rows verbatim rather than a color recomputed from the accent. const auto toneFor = [&](const Family &family, int level) { - return level == 0 ? palette.roles.value(family.quiet) - : level == 1 ? palette.roles.value(family.secondary) - : palette.roles.value(family.primary); + const AppColor::Role role = level == 0 ? family.quiet : level == 1 ? family.secondary : family.primary; + // A theme written before the role existed does not author it; + // the client paints the scheme default, so the sheet does too. + return palette.roles.value(role, defaultAppColor(role, dark)); }; const auto scheme = dark ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light; @@ -2100,6 +2122,11 @@ TEST_F(ThemeIconRenderTest, EmphasisTreatmentsPreviewWritesPngsWhenRequested) AppColor::NegativeSoft, AppColor::Negative, AppColor::NegativeStrong}, + {"Informational", + {"arrow_right", "view", "arrow_to_top"}, + AppColor::InformationalSoft, + AppColor::Informational, + AppColor::InformationalStrong}, }; const QStringList themes = { QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), @@ -2184,7 +2211,9 @@ TEST_F(ThemeIconRenderTest, EmphasisTreatmentsPreviewWritesPngsWhenRequested) for (const Family &family : families) { for (int level = 0; level < 4; ++level) { const bool disabled = level == 3; - const QColor ink = disabled ? disabledTone : palette.roles.value(roleAt(family, level)); + const QColor ink = disabled ? disabledTone + : palette.roles.value(roleAt(family, level), + defaultAppColor(roleAt(family, level), dark)); const QString levelName = disabled ? QStringLiteral("Disabled") : QString::fromLatin1(LEVEL_NAMES[level]); ASSERT_TRUE(ink.isValid()) diff --git a/tests/theme_migration/theme_migration_test.cpp b/tests/theme_migration/theme_migration_test.cpp index 3bd8be3a2..c19089545 100644 --- a/tests/theme_migration/theme_migration_test.cpp +++ b/tests/theme_migration/theme_migration_test.cpp @@ -167,6 +167,12 @@ TEST(ThemeAppColorTest, SemanticDefaultsFollowTheScheme) 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"))); + EXPECT_EQ(defaultAppColor(AppColor::Informational, false), QColor(QStringLiteral("#1c64c4"))); + EXPECT_EQ(defaultAppColor(AppColor::Informational, true), QColor(QStringLiteral("#7aa9f2"))); + EXPECT_EQ(defaultAppColor(AppColor::InformationalStrong, false), QColor(QStringLiteral("#0a4fb5"))); + EXPECT_EQ(defaultAppColor(AppColor::InformationalStrong, true), QColor(QStringLiteral("#69aeff"))); + EXPECT_EQ(defaultAppColor(AppColor::InformationalSoft, false), QColor(QStringLiteral("#526a8a"))); + EXPECT_EQ(defaultAppColor(AppColor::InformationalSoft, true), QColor(QStringLiteral("#9db2cd"))); // Accent and neutral icon roles have no static default: an older theme // written before the roles existed falls back to deriving them from its // palette rather than pinning one scheme's ink.