[Icons] Author icon emphasis colors as palette roles

The emphasis levels were computed from the accent at paint time, so a
theme could not vary them: every theme got the same derived strong and
soft tones, and a semantic hue was pushed toward the accent whether the
author wanted that or not.

Give each family three authored roles instead. Icon/IconStrong/IconSoft
carry the neutral glyph ink and Positive/Negative gain the same Strong
and Soft companions. Emphasis becomes a lookup inside the family, and a
caller that names a variant pins that level at every emphasis. The paint
path returns the authored color verbatim; only the filled treatments keep
their saturation floor, because carrying a disc or a fill is a treatment
decision.

Shipped palettes author the full ladder for both schemes; the palette
editor exposes the new rows and the generator writes them. System keeps
deriving its neutral ink from the platform palette, since it ships no
palette of its own.
This commit is contained in:
Lukas Brübach 2026-11-07 00:12:45 +01:00
parent 115d16a74b
commit 03a4a33ba5
21 changed files with 551 additions and 277 deletions

View file

@ -1,6 +1,7 @@
#include "palette_generator.h"
#include "../theme_config.h"
#include "../theme_icon_ink.h"
#include <QColor>
#include <QMap>
@ -167,10 +168,19 @@ PaletteConfig fromAccent(const QColor &accent, int intensity, const QString &sch
// the lightened, desaturated companion used for button-gradient highlights.
cfg.appColors[AppColor::AccentStrong] = hl;
cfg.appColors[AppColor::AccentSoft] = hsl(accent.lightness() + 60, qRound(accent.hslSaturation() * 70 / 100.0));
// Neutral icon ink follows the generated accent at the same three emphasis
// levels a hand-authored theme stores, so the preview matches what the icon
// engine renders before the palette is saved.
const QColor iconInk = ThemeIconInk::glyphInk(winText, bg_win, hl);
cfg.appColors[AppColor::Icon] = iconInk;
cfg.appColors[AppColor::IconStrong] = ThemeIconInk::forEmphasis(hl, bg_win, IconEmphasis::Primary);
cfg.appColors[AppColor::IconSoft] = ThemeIconInk::forEmphasis(iconInk, bg_win, IconEmphasis::Quiet);
// Semantic roles stay independent of the accent: a generated palette must
// still paint add/ready green and remove/not-ready red.
cfg.appColors[AppColor::Positive] = defaultAppColor(AppColor::Positive, dark);
cfg.appColors[AppColor::Negative] = defaultAppColor(AppColor::Negative, dark);
for (const AppColor::Role role : {AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft,
AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}) {
cfg.appColors[role] = defaultAppColor(role, dark);
}
// BrightText
QColor bright;

View file

@ -65,10 +65,19 @@ static const QMap<AppColor::Role, const char *> APP_ROLE_DESCRIPTIONS = {
QT_TRANSLATE_NOOP("PaletteGridWidget", "Vivid primary accent (e.g. home-tab button gradient start)")},
{AppColor::AccentSoft,
QT_TRANSLATE_NOOP("PaletteGridWidget", "Lightened, desaturated accent (e.g. home-tab button gradient end)")},
{AppColor::Icon, QT_TRANSLATE_NOOP("PaletteGridWidget", "Neutral icon ink at the default action weight")},
{AppColor::IconStrong,
QT_TRANSLATE_NOOP("PaletteGridWidget", "Neutral icon ink for the one action a surface exists for")},
{AppColor::IconSoft,
QT_TRANSLATE_NOOP("PaletteGridWidget", "Neutral icon ink for a glyph that labels rather than acts")},
{AppColor::Positive,
QT_TRANSLATE_NOOP("PaletteGridWidget", "Positive state and additive actions (ready, add, increment)")},
{AppColor::PositiveStrong, QT_TRANSLATE_NOOP("PaletteGridWidget", "Positive state at primary emphasis")},
{AppColor::PositiveSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Positive state at quiet emphasis")},
{AppColor::Negative,
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")},
};
PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent)

View file

@ -458,12 +458,25 @@ QColor defaultAppColor(AppColor::Role role, bool dark)
switch (role) {
case AppColor::AccentStrong:
case AppColor::AccentSoft:
// Derived from the palette highlight by ThemeManager::appColor().
// The neutral icon family is palette-derived like the accent, so a
// theme without a palette of its own (System) keeps following the
// platform instead of pinning one scheme's ink.
case AppColor::Icon:
case AppColor::IconStrong:
case AppColor::IconSoft:
return {};
case AppColor::Positive:
return QColor(dark ? QStringLiteral("#6dd58c") : QStringLiteral("#1b7f3b"));
case AppColor::PositiveStrong:
return QColor(dark ? QStringLiteral("#99e2ae") : QStringLiteral("#104a22"));
case AppColor::PositiveSoft:
return QColor(dark ? QStringLiteral("#5e8873") : QStringLiteral("#5d7f6f"));
case AppColor::Negative:
return QColor(dark ? QStringLiteral("#ff8a80") : QStringLiteral("#b3261e"));
case AppColor::NegativeStrong:
return QColor(dark ? QStringLiteral("#ffb3ac") : QStringLiteral("#7c1b15"));
case AppColor::NegativeSoft:
return QColor(dark ? QStringLiteral("#9b7678") : QStringLiteral("#9a6c6f"));
}
return {};

View file

@ -13,6 +13,13 @@
// set. Stored in the same palette-<scheme>.toml under an [AppColors] section
// and editable from the palette editor, so theme authors can control colors
// 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.
namespace AppColor
{
Q_NAMESPACE
@ -20,8 +27,15 @@ enum Role
{
AccentStrong,
AccentSoft,
Icon,
IconStrong,
IconSoft,
Positive,
PositiveStrong,
PositiveSoft,
Negative,
NegativeStrong,
NegativeSoft,
};
Q_ENUM_NS(Role)
} // namespace AppColor
@ -47,9 +61,13 @@ Q_ENUM_NS(Role)
* @brief Fallback for an application color role when the active theme does not
* define one.
*
* Accent roles return an invalid QColor because ThemeManager derives them from
* the palette highlight. Semantic roles return an opaque default that suits the
* scheme, so themes written before the role existed still render it.
* Accent roles and the neutral Icon family return an invalid QColor because
* ThemeManager derives them from the palette: the accent from the highlight,
* the icon ink from the neutral text color plus the highlight. Semantic roles
* return an opaque default that suits the scheme, so themes written before the
* role existed still render it. A theme with no palette of its own (the System
* theme) keeps deriving its icon ink from the platform palette instead of
* pinning one scheme's colors.
*/
QColor defaultAppColor(AppColor::Role role, bool dark);

View file

@ -2,7 +2,6 @@
#include "libcockatrice/utility/svg_recolor.h"
#include "theme_icon_badge.h"
#include "theme_icon_fill.h"
#include "theme_icon_ink.h"
#include "theme_icon_sticker.h"
#include "theme_manager.h"
@ -318,6 +317,35 @@ private:
mutable bool hasResolvedAsset = false;
mutable bool hasIntrinsicSize = false;
};
/**
* @brief The role an emphasis level selects inside a base role's family.
*
* The theme authors all three levels, so emphasis is a lookup instead of a
* color computation. Naming a variant (`IconStrong`, `PositiveSoft`) is an
* explicit request and keeps that role at every emphasis; only the base roles
* take part in the mapping. Accent roles have no neutral middle member, so they
* are always used as named.
*/
AppColor::Role emphasisRole(AppColor::Role role, IconEmphasis emphasis)
{
switch (role) {
case AppColor::Icon:
return emphasis == IconEmphasis::Primary ? AppColor::IconStrong
: emphasis == IconEmphasis::Quiet ? AppColor::IconSoft
: AppColor::Icon;
case AppColor::Positive:
return emphasis == IconEmphasis::Primary ? AppColor::PositiveStrong
: emphasis == IconEmphasis::Quiet ? AppColor::PositiveSoft
: AppColor::Positive;
case AppColor::Negative:
return emphasis == IconEmphasis::Primary ? AppColor::NegativeStrong
: emphasis == IconEmphasis::Quiet ? AppColor::NegativeSoft
: AppColor::Negative;
default:
return role;
}
}
} // namespace
IconTint::IconTint(QPalette::ColorRole role, IconEmphasis _emphasis) : paletteRole(role), emphasis(_emphasis)
@ -342,7 +370,6 @@ IconTint IconTint::quiet()
QColor IconTint::resolve(QIcon::Mode mode) const
{
const QPalette &palette = qApp->palette();
const QColor surface = palette.color(QPalette::Active, QPalette::Window);
const QColor accent = palette.color(QPalette::Active, QPalette::Highlight);
if (source == Source::AppColor) {
@ -350,13 +377,11 @@ QColor IconTint::resolve(QIcon::Mode mode) const
if (mode == QIcon::Disabled) {
return palette.color(QPalette::Disabled, QPalette::WindowText);
}
// The three treatments paint the same tone: a line glyph, a badge disc
// and a sticker fill must not disagree on how saturated the role is.
// The fill treatments apply this floor themselves, so this only changes
// the line path, and only for the desaturated dark-scheme tones. The
// emphasis then decides how loud that tone is, without changing its hue.
const QColor color =
ThemeIconInk::forEmphasis(ThemeIconFill::saturated(themeManager->appColor(appRole)), surface, emphasis);
// The theme authors one color per emphasis level, so the paint path
// uses the resolved role verbatim. The filled treatments apply their
// own saturation floor when a disc or a fill needs mass; a line glyph
// keeps the color the author picked.
const QColor color = themeManager->appColor(emphasisRole(appRole, emphasis));
if (mode != QIcon::Selected) {
return color;
}
@ -373,14 +398,10 @@ QColor IconTint::resolve(QIcon::Mode mode) const
const QPalette::ColorRole effective =
mode == QIcon::Selected && paletteRole == QPalette::WindowText ? QPalette::HighlightedText : paletteRole;
const QColor color = palette.color(group, effective);
// A neutral label role is inked as an icon, not as body text: it borrows a
// little accent chroma and is held to a contrast floor. Semantic roles and
// the selected-row swap keep the color the palette gave them.
// A neutral label role resolves to the theme's authored icon ink. Semantic
// roles and the selected-row swap keep the color the palette gave them.
if (group == QPalette::Active && (effective == QPalette::WindowText || effective == QPalette::Text)) {
// The primary action of a surface takes the accent itself; the other
// levels stay neutral. Emphasis then sets the contrast the glyph pays.
const QColor base = emphasis == IconEmphasis::Primary ? accent : ThemeIconInk::glyphInk(color, surface, accent);
return ThemeIconInk::forEmphasis(base, surface, emphasis);
return themeManager->appColor(emphasisRole(AppColor::Icon, emphasis));
}
return color;
}

View file

@ -47,18 +47,20 @@ enum class IconStyle
};
/**
* @brief Selects how much attention a glyph claims inside its surface.
* @brief Selects which authored level of a color family a glyph uses.
*
* Tint says which hue, style says how much shape, emphasis says how loud. The
* three are independent: a primary action can be a line glyph in the theme
* accent, and a quiet one can be a sticker in a dimmed tone.
* Tint says which family, style says how much shape, emphasis says how loud.
* The three are independent: a primary action can be a line glyph in the
* theme's primary ink, and a quiet one can be a sticker in that family's quiet
* level.
*
* Secondary is the default and the color every icon had before the axis
* existed, so existing call sites keep their look. Primary is for the one
* action a surface exists for: a neutral primary glyph takes the accent, and a
* tinted one is pushed to full contrast. Quiet is for glyphs that label rather
* than act, such as the magnifier inside a search field: legible, but never
* competing with the buttons next to it.
* Each family (neutral icon, positive, negative) stores one color per level, so
* emphasis is a lookup rather than a computation: the palette editor's rows are
* the whole source of truth. Secondary is the default and the color every icon
* had before the axis existed, so existing call sites keep their look. Primary
* is for the one action a surface exists for. Quiet is for glyphs that label
* rather than act, such as the magnifier inside a search field: legible, but
* never competing with the buttons next to it.
*/
enum class IconEmphasis
{
@ -93,19 +95,23 @@ public:
*
* A disabled application color resolves to the disabled foreground, so an
* unavailable control does not keep its semantic color. A selected palette
* color keeps the WindowText -> HighlightedText swap. Application colors go
* through the saturation floor shared by the filled treatments, so a line
* glyph, a badge disc and a sticker fill carry the same tone.
* color keeps the WindowText -> HighlightedText swap.
*
* Application colors are returned verbatim: the theme author stores one
* value per emphasis level, so the paint path does not rewrite it. The
* filled treatments apply their own saturation floor to a fill they have to
* carry; a line glyph keeps the author's color.
*
* Neutral label roles (WindowText, Text) resolve to icon ink rather than the
* raw palette color: ThemeIconInk::glyphInk borrows a little accent chroma
* and holds the glyph to a contrast floor, because a 2px glyph in body-text
* ink reads thinner than the text beside it. Semantic roles keep the hue the
* call site asked for.
* raw palette color: ThemeIconInk::glyphInk derives it from the accent and
* holds the glyph to a contrast floor, because a 2px glyph in body-text ink
* reads thinner than the text beside it. A theme that authors the neutral
* icon roles overrides that derivation. Semantic roles keep the hue the call
* site asked for.
*
* Emphasis is applied after the hue: Primary lifts a neutral glyph to the
* accent and pushes a tinted one to full contrast; Quiet blends either
* toward the surface down to the non-text legibility floor.
* Emphasis selects the family's authored variant, so the palette editor's
* rows are the whole source of truth. A caller that names a variant
* explicitly (a strong or soft role) pins that level at every emphasis.
*/
QColor resolve(QIcon::Mode mode) const;

View file

@ -3,6 +3,7 @@
#include "../../client/settings/cache_settings.h"
#include "pixel_map_generator.h"
#include "theme_config.h"
#include "theme_icon_ink.h"
#include <QApplication>
#include <QChar>
@ -651,8 +652,33 @@ QColor ThemeManager::appColor(AppColor::Role role) const
qBound(0, qRound(accent.hslSaturation() * SOFT_SATURATION_PERCENT / 100.0), 255),
qBound(0, accent.lightness() + SOFT_LIGHTNESS_OFFSET, 255));
}
case AppColor::Icon:
case AppColor::IconStrong:
case AppColor::IconSoft: {
// Legacy fallback for a theme written before the icon roles
// existed, or one with no palette of its own (System). The three
// levels are the ink rule this series shipped, applied once here so
// authored roles never get recomputed at paint time.
const QPalette &palette = qApp->palette();
const QColor surface = palette.color(QPalette::Active, QPalette::Window);
const QColor ink =
ThemeIconInk::glyphInk(palette.color(QPalette::Active, QPalette::WindowText), surface, accent);
switch (role) {
case AppColor::IconStrong:
return ThemeIconInk::forEmphasis(accent, surface, IconEmphasis::Primary);
case AppColor::IconSoft:
return ThemeIconInk::forEmphasis(ink, surface, IconEmphasis::Quiet);
case AppColor::Icon:
default:
return ink;
}
}
case AppColor::Positive:
case AppColor::PositiveStrong:
case AppColor::PositiveSoft:
case AppColor::Negative:
case AppColor::NegativeStrong:
case AppColor::NegativeSoft:
// 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());

View file

@ -71,6 +71,13 @@ Accent = #4a82e6
[AppColors]
AccentStrong = #3a6fd6
AccentSoft = #8fb8f2
Icon = #7b9fe4
IconStrong = #b1c7ef
IconSoft = #70809d
Positive = #6dd58c
PositiveStrong = #99e2ae
PositiveSoft = #5e8873
Negative = #ff8a80
NegativeStrong = #ffb3ac
NegativeSoft = #9b7678

View file

@ -71,6 +71,13 @@ Accent = #2f6fd6
[AppColors]
AccentStrong = #3a6fd6
AccentSoft = #8fb8f2
Icon = #2f59ad
IconStrong = #213e79
IconSoft = #687899
Positive = #1b7f3b
PositiveStrong = #104a22
PositiveSoft = #5d7f6f
Negative = #b3261e
NegativeStrong = #7c1b15
NegativeSoft = #9a6c6f

View file

@ -69,7 +69,15 @@ Accent = #ff1e1e1e
[AppColors]
AccentStrong = #ff139740
AccentSoft = #ffc9fd62
Positive = #6dd58c
Negative = #ff8a80
AccentStrong = #ff139740
AccentSoft = #ffc9fd62
Icon = #5db87c
IconStrong = #aad9b9
IconSoft = #6e8d79
Positive = #6dd58c
PositiveStrong = #99e2ae
PositiveSoft = #698e74
Negative = #ff8a80
NegativeStrong = #ffbfb9
NegativeSoft = #a37c78

View file

@ -69,7 +69,15 @@ Accent = #fff0f0f0
[AppColors]
AccentStrong = #ff139740
AccentSoft = #ffc9fd62
Positive = #1b7f3b
Negative = #b3261e
AccentStrong = #ff139740
AccentSoft = #ffc9fd62
Icon = #107e36
IconStrong = #0b5826
IconSoft = #6c937a
Positive = #1b7f3b
PositiveStrong = #135929
PositiveSoft = #70927b
Negative = #b3261e
NegativeStrong = #7d1b15
NegativeSoft = #b37d7b

View file

@ -71,6 +71,13 @@ Accent = #c9995a
[AppColors]
AccentStrong = #b7823b
AccentSoft = #e4c58f
Icon = #bc8c4a
IconStrong = #d4b68c
IconSoft = #86745a
Positive = #6dd58c
PositiveStrong = #99e2ae
PositiveSoft = #588061
Negative = #ff8a80
NegativeStrong = #ffada6
NegativeSoft = #996b66

View file

@ -71,6 +71,13 @@ Accent = #a5712f
[AppColors]
AccentStrong = #b7823b
AccentSoft = #e4c58f
Icon = #735125
IconStrong = #4e3719
IconSoft = #817059
Positive = #1b7f3b
PositiveStrong = #0f4520
PositiveSoft = #61795d
Negative = #b3261e
NegativeStrong = #741914
NegativeSoft = #9a665b

View file

@ -71,6 +71,13 @@ Accent = #6a4df0
[AppColors]
AccentStrong = #5b3ee0
AccentSoft = #a28bf7
Icon = #927fea
IconStrong = #b7aaf1
IconSoft = #736b97
Positive = #6dd58c
PositiveStrong = #99e2ae
PositiveSoft = #517a63
Negative = #ff8a80
NegativeStrong = #ffada6
NegativeSoft = #926567

View file

@ -71,6 +71,13 @@ Accent = #5a3ee2
[AppColors]
AccentStrong = #5b3ee0
AccentSoft = #a28bf7
Icon = #5439d0
IconStrong = #39278e
IconSoft = #7569a4
Positive = #1b7f3b
PositiveStrong = #0e431f
PositiveSoft = #5c776d
Negative = #b3261e
NegativeStrong = #711913
NegativeSoft = #95646d

View file

@ -3,8 +3,14 @@
# overrides. Only the identity and semantic app colors are provided here, so
# buttons keep their classic greens while the actual palette stays native to
# the OS.
# Icon, IconStrong and IconSoft are deliberately absent: a theme with no
# palette of its own derives its icon ink from the platform palette.
[AppColors]
AccentStrong = #ff148c3c
AccentSoft = #ff78c850
Positive = #ff6dd58c
Negative = #ffff8a80
AccentStrong = #ff148c3c
AccentSoft = #ff78c850
Positive = #ff6dd58c
PositiveStrong = #ff99e2ae
PositiveSoft = #ff5e8873
Negative = #ffff8a80
NegativeStrong = #ffffb3ac
NegativeSoft = #ff9b7678

View file

@ -3,8 +3,14 @@
# overrides. Only the identity and semantic app colors are provided here, so
# buttons keep their classic greens while the actual palette stays native to
# the OS.
# Icon, IconStrong and IconSoft are deliberately absent: a theme with no
# palette of its own derives its icon ink from the platform palette.
[AppColors]
AccentStrong = #ff148c3c
AccentSoft = #ff78c850
Positive = #ff1b7f3b
Negative = #ffb3261e
AccentStrong = #ff148c3c
AccentSoft = #ff78c850
Positive = #ff1b7f3b
PositiveStrong = #ff104a22
PositiveSoft = #ff5d7f6f
Negative = #ffb3261e
NegativeStrong = #ff7c1b15
NegativeSoft = #ff9a6c6f

View file

@ -71,6 +71,13 @@ Accent = #8fa6c0
[AppColors]
AccentStrong = #54687e
AccentSoft = #9db0c4
Icon = #8392a1
IconStrong = #acb6c0
IconSoft = #6d737a
Positive = #6dd58c
PositiveStrong = #99e2ae
PositiveSoft = #517c61
Negative = #ff8a80
NegativeStrong = #ffada6
NegativeSoft = #926865

View file

@ -71,6 +71,13 @@ Accent = #54687e
[AppColors]
AccentStrong = #54687e
AccentSoft = #9db0c4
Icon = #4a5b6f
IconStrong = #333e4c
IconSoft = #6e7580
Positive = #1b7f3b
PositiveStrong = #0f4620
PositiveSoft = #5b7b6a
Negative = #b3261e
NegativeStrong = #761a14
NegativeSoft = #96696b