[Icons] Make the emphasis ladder structural on every theme

The chroma ladder landed with four P1s from review: reachContrast picked
its pole by background lightness instead of by what each pole can reach,
the generated neutral ink was solved on the window surface only, Fusion's
neutral family was still the green accent, and quiet sat below the text
floor while disabled reused the palette gray.

Pick the pole by achievable contrast, solve fallback and preview inks on
the harder of the window and button surfaces, re-author Fusion's neutral
trio in slate blue, and keep quiet on the shared text floor by muting its
saturation to 45% instead of blending it into the surface. Disabled is now
derived from the quiet ink, so it is structurally dimmer than every
enabled level on every theme.

Re-author all soft roles at a 4.60 target with a rounding margin, rebuild
the per-theme preview generator the rebase dropped, and add a Disabled row
to every treatment sheet.
This commit is contained in:
Lukas Brübach 2026-11-07 03:20:29 +01:00
parent daa1a73956
commit 497c230b03
16 changed files with 587 additions and 225 deletions

View file

@ -170,11 +170,13 @@ PaletteConfig fromAccent(const QColor &accent, int intensity, const QString &sch
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);
// engine renders before the palette is saved. The harder of the two surfaces
// is the one the floor has to be solved against.
const QColor iconSurface = ThemeIconInk::harderSurface(bg_win, bg_btn, winText);
const QColor iconInk = ThemeIconInk::glyphInk(winText, iconSurface, 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);
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,

View file

@ -73,6 +73,22 @@ ThemeIconFill::Scheme activeScheme()
: ThemeIconFill::Scheme::Light;
}
/**
* @brief The ink a disabled glyph is painted with.
*
* A disabled icon used to be the palette's disabled text, a fixed gray whose
* contrast swings with the surface: on the darkest themes it out-reads the quiet
* level, so an enabled quiet glyph looks more disabled than a disabled one.
* Deriving it from the theme's own quiet ink keeps the order structural.
*/
QColor disabledGlyphInk(const QPalette &palette)
{
const QColor quiet = themeManager->appColor(AppColor::IconSoft);
const QColor surface = ThemeIconInk::harderSurface(palette.color(QPalette::Active, QPalette::Window),
palette.color(QPalette::Active, QPalette::Button), quiet);
return ThemeIconInk::disabledInk(quiet, surface);
}
/**
* @brief Reads an SVG through the `theme:` search path.
*/
@ -390,7 +406,7 @@ QColor IconTint::resolve(QIcon::Mode mode) const
if (source == Source::AppColor) {
// A disabled control must not keep signalling its semantic color.
if (mode == QIcon::Disabled) {
return palette.color(QPalette::Disabled, QPalette::WindowText);
return disabledGlyphInk(palette);
}
// The theme authors one color per emphasis level, so the paint path
// uses the resolved role verbatim. The filled treatments only shape the
@ -412,13 +428,14 @@ QColor IconTint::resolve(QIcon::Mode mode) const
const QPalette::ColorGroup group = mode == QIcon::Disabled ? QPalette::Disabled : QPalette::Active;
const QPalette::ColorRole effective =
mode == QIcon::Selected && paletteRole == QPalette::WindowText ? QPalette::HighlightedText : paletteRole;
const QColor color = palette.color(group, effective);
// 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)) {
return themeManager->appColor(emphasisRole(AppColor::Icon, emphasis));
// A neutral label role resolves to the theme's authored icon ink. A disabled
// one uses the dimmed quiet ink so it always sits below the quiet level;
// semantic roles keep the color the palette gave them.
if (effective == QPalette::WindowText || effective == QPalette::Text) {
return group == QPalette::Disabled ? disabledGlyphInk(palette)
: themeManager->appColor(emphasisRole(AppColor::Icon, emphasis));
}
return color;
return palette.color(group, effective);
}
QIcon loadThemedIcon(QStringView prefix, IconTint tint, IconStyle style)

View file

@ -12,6 +12,16 @@ namespace
// accent fill. Every shipped theme accents in blue or violet, so the borrowed hue
// stays neutral chrome instead of implying a state.
constexpr double ACCENT_MIX = 0.70;
// The Positive family owns green and the Negative family owns red. An accent in
// either band is a semantic color the theme did not mean as chrome, so the
// neutral glyph keeps the accent's intensity but not its hue.
constexpr int SEMANTIC_GREEN_START = 80;
constexpr int SEMANTIC_GREEN_END = 165;
constexpr int SEMANTIC_RED_START = 340;
constexpr int SEMANTIC_RED_END = 25;
// Slate blue: the chrome hue every shipped theme already leans toward, and the
// least likely to be mistaken for a state.
constexpr int NEUTRAL_CHROME_HUE = 210;
// An icon that labels a control is carrying text; see ui-assets.md.
constexpr double LABEL_CONTRAST_FLOOR = 4.5;
// A primary glyph is the most chromatic member of its family. Contrast cannot
@ -21,11 +31,21 @@ constexpr double LABEL_CONTRAST_FLOOR = 4.5;
// many HSV points over the secondary and then solves the value back to the text
// floor, so it is louder in color at the same legibility.
constexpr int PRIMARY_SATURATION_STEP = 64;
// A quiet glyph is blended this far into its surface. It has to stay findable
// without competing with the actions beside it.
constexpr double QUIET_BLEND = 0.55;
// WCAG 2.2 SC 1.4.11, the floor for a non-text element that carries meaning.
constexpr double QUIET_CONTRAST_FLOOR = 3.0;
// Quiet recedes by carrying less of the family's chroma, not by dropping closer
// to the surface: the disabled level owns the low-contrast end. It keeps this
// share of the secondary level's saturation.
constexpr int QUIET_SATURATION_PERCENT = 45;
// The quiet level shares the text floor with the other two levels.
constexpr double QUIET_CONTRAST_FLOOR = LABEL_CONTRAST_FLOOR;
// A disabled glyph recedes past the quiet level, so it is blended this much
// further into the surface...
constexpr double DISABLED_BLEND = 0.55;
// ...and then held to a floor below the quiet one so it stays findable.
constexpr double DISABLED_CONTRAST_FLOOR = 2.5;
// 8-bit quantization can drop a solved color just under the ratio it was solved
// for, so every search aims this much above the floor. Without it a palette
// value generated at exactly 4.5:1 measures 4.45:1 after the round trip.
constexpr double CONTRAST_ROUNDING_MARGIN = 0.10;
// Binary-search depth for reachContrast(). 24 halvings resolve to well under
// one 8-bit channel step, so the result is stable across platforms.
constexpr int CONTRAST_SEARCH_STEPS = 24;
@ -59,18 +79,18 @@ QColor blend(const QColor &color, const QColor &target, double amount)
QColor reachContrast(const QColor &color, const QColor &background, double minRatio)
{
if (contrastRatio(color, background) >= minRatio) {
const double target = minRatio + CONTRAST_ROUNDING_MARGIN;
if (contrastRatio(color, background) >= target) {
return color;
}
// Move away from the background: toward whichever pole actually reaches
// further. Black and white cross at a luminance of about 0.18, not 0.5, so
// the midpoint test would pick white on a surface where black is far more
// legible -- and could drag an ink that already cleared the floor to white.
// The hue is kept by interpolating, not by replacing.
const QColor black(Qt::black);
const QColor white(Qt::white);
const QColor pole = contrastRatio(black, background) >= contrastRatio(white, background) ? black : white;
if (contrastRatio(pole, background) < minRatio) {
// Move toward whichever pole the background leaves room for. A medium-light
// surface is lighter than mid-gray but still too light to reach a text floor
// with white, so "light background means move toward black" is the wrong
// test: compare what each pole can actually reach.
const double blackRatio = contrastRatio(QColor(Qt::black), background);
const double whiteRatio = contrastRatio(QColor(Qt::white), background);
const QColor pole = blackRatio >= whiteRatio ? QColor(Qt::black) : QColor(Qt::white);
if (qMax(blackRatio, whiteRatio) < target) {
return pole;
}
@ -90,9 +110,39 @@ QColor reachContrast(const QColor &color, const QColor &background, double minRa
return blend(color, pole, high);
}
bool isSemanticHue(int hue)
{
if (hue < 0) {
return false;
}
return (hue >= SEMANTIC_GREEN_START && hue <= SEMANTIC_GREEN_END) || hue >= SEMANTIC_RED_START ||
hue <= SEMANTIC_RED_END;
}
int neutralChromeHue()
{
return NEUTRAL_CHROME_HUE;
}
QColor harderSurface(const QColor &first, const QColor &second, const QColor &ink)
{
const double inkLuminance = relativeLuminance(ink);
const auto distance = [inkLuminance](const QColor &surface) {
return std::abs(relativeLuminance(surface) - inkLuminance);
};
return distance(first) <= distance(second) ? first : second;
}
QColor glyphInk(const QColor &text, const QColor &surface, const QColor &accent, double accentMix)
{
return reachContrast(blend(text, accent, accentMix), surface, LABEL_CONTRAST_FLOOR).toRgb();
// A green or red accent is a Positive or Negative color, so borrowing its
// hue would paint neutral chrome the same color as a semantic state. Keep
// the accent's intensity, replace its hue with the chrome hue.
QColor source = accent;
if (isSemanticHue(accent.hue())) {
source = QColor::fromHsv(NEUTRAL_CHROME_HUE, accent.hsvSaturation(), qMax(accent.value(), 128));
}
return reachContrast(blend(text, source, accentMix), surface, LABEL_CONTRAST_FLOOR).toRgb();
}
double defaultAccentMix()
@ -107,11 +157,46 @@ double labelContrastFloor()
namespace
{
/** @brief Whether black reaches a higher contrast against @p surface than white. */
bool darkerPoleWins(const QColor &surface)
{
return ThemeIconInk::contrastRatio(QColor(Qt::black), surface) >=
ThemeIconInk::contrastRatio(QColor(Qt::white), surface);
}
/** @brief The luminance a color needs to reach @p ratio against @p surface. */
double targetLuminance(const QColor &surface, double ratio)
{
const double surfaceLuminance = ThemeIconInk::relativeLuminance(surface);
return surfaceLuminance > 0.5 ? (surfaceLuminance + 0.05) / ratio - 0.05 : (surfaceLuminance + 0.05) * ratio - 0.05;
return darkerPoleWins(surface) ? (surfaceLuminance + 0.05) / ratio - 0.05
: (surfaceLuminance + 0.05) * ratio - 0.05;
}
/**
* @brief The value that puts @p hue at @p saturation on @p surface's floor.
*
* Luminance is monotone in HSV value at a fixed hue and saturation, so a
* bisection finds the boundary: the closest tone to the surface that still
* clears the floor, on either scheme. Returns an invalid color when even full
* value cannot reach the floor at that saturation.
*/
QColor solveValue(int hue, int saturation, const QColor &surface, double floor)
{
const double target = targetLuminance(surface, floor + CONTRAST_ROUNDING_MARGIN);
if (ThemeIconInk::relativeLuminance(QColor::fromHsv(hue, saturation, 255)) < target) {
return {};
}
int low = 0;
int high = 255;
for (int search = 0; search < CONTRAST_SEARCH_STEPS; ++search) {
const int middle = (low + high) / 2;
if (ThemeIconInk::relativeLuminance(QColor::fromHsv(hue, saturation, middle)) >= target) {
high = middle;
} else {
low = middle;
}
}
return QColor::fromHsv(hue, saturation, high).toRgb();
}
/**
@ -129,28 +214,35 @@ QColor chromaticStep(const QColor &color, const QColor &surface, double floor, i
return ThemeIconInk::reachContrast(color, surface, floor);
}
const double target = targetLuminance(surface, floor);
const int hue = color.hue();
const int secondary = color.hsvSaturation();
const int primary = qMin(255, secondary + stepPoints);
for (int saturation = primary; saturation > secondary; --saturation) {
if (ThemeIconInk::relativeLuminance(QColor::fromHsv(hue, saturation, 255)) < target) {
continue;
for (int saturation = qMin(255, secondary + stepPoints); saturation > secondary; --saturation) {
const QColor solved = solveValue(hue, saturation, surface, floor);
if (solved.isValid()) {
return solved;
}
int low = 0;
int high = 255;
for (int search = 0; search < CONTRAST_SEARCH_STEPS; ++search) {
const int middle = (low + high) / 2;
if (ThemeIconInk::relativeLuminance(QColor::fromHsv(hue, saturation, middle)) >= target) {
high = middle;
} else {
low = middle;
}
}
return QColor::fromHsv(hue, saturation, high).toRgb();
}
return ThemeIconInk::reachContrast(color, surface, floor);
}
/**
* @brief Mutes a color's saturation, then solves its value for the floor.
*
* The mirror of chromaticStep: quiet stays on the same floor as the other
* levels and recedes by carrying less of the family's chroma, so it cannot drop
* below the disabled level's contrast by construction. A gray has no saturation
* to mute and falls back to the contrast search.
*/
QColor mutedStep(const QColor &color, const QColor &surface, double floor, int percent)
{
if (color.hue() < 0) {
return ThemeIconInk::reachContrast(color, surface, floor);
}
const int saturation = qBound(0, qRound(color.hsvSaturation() * percent / 100.0), 255);
const QColor solved = solveValue(color.hue(), saturation, surface, floor);
return solved.isValid() ? solved : ThemeIconInk::reachContrast(color, surface, floor);
}
} // namespace
QColor forEmphasis(const QColor &color, const QColor &surface, IconEmphasis emphasis)
@ -159,23 +251,23 @@ QColor forEmphasis(const QColor &color, const QColor &surface, IconEmphasis emph
case IconEmphasis::Primary:
return chromaticStep(color, surface, LABEL_CONTRAST_FLOOR, PRIMARY_SATURATION_STEP).toRgb();
case IconEmphasis::Quiet:
return reachContrast(blend(color, surface, QUIET_BLEND), surface, QUIET_CONTRAST_FLOOR).toRgb();
return mutedStep(color, surface, QUIET_CONTRAST_FLOOR, QUIET_SATURATION_PERCENT).toRgb();
case IconEmphasis::Secondary:
break;
}
return color;
}
QColor disabledInk(const QColor &quiet, const QColor &surface)
{
return reachContrast(blend(quiet, surface, DISABLED_BLEND), surface, DISABLED_CONTRAST_FLOOR).toRgb();
}
double primaryContrastFloor()
{
return LABEL_CONTRAST_FLOOR;
}
double quietBlend()
{
return QUIET_BLEND;
}
double quietContrastFloor()
{
return QUIET_CONTRAST_FLOOR;

View file

@ -32,6 +32,27 @@
*/
namespace ThemeIconInk
{
/**
* @brief Whether @p hue belongs to the band the Positive or Negative family owns.
*
* A green accent is a Positive color and a red accent is a Negative one, whether
* or not the theme means it that way. Neutral chrome must not borrow either hue,
* or "settings" and "add" are painted the same color.
*/
bool isSemanticHue(int hue);
/** @brief The hue neutral chrome falls back to when the accent is semantic. */
int neutralChromeHue();
/**
* @brief The harder of two surfaces to keep an ink legible on.
*
* Contrast is monotone in the luminance distance between an ink and its
* surface, so the surface nearest @p ink is the one it reads worst on. Choosing
* that one keeps a single authored ink above the floor on both.
*/
QColor harderSurface(const QColor &first, const QColor &second, const QColor &ink);
/**
* @brief WCAG relative luminance of an opaque color.
*/
@ -54,9 +75,10 @@ QColor blend(const QColor &color, const QColor &target, double amount);
/**
* @brief Pushes @p color away from @p background until it reaches @p minRatio.
*
* Moves @p color toward black on a light background and toward white on a dark
* one, so the hue survives the adjustment. Returns @p color unchanged when it
* already meets the floor, which is the common case for a shipped palette.
* Moves @p color toward whichever pole, black or white, actually reaches
* @p minRatio against @p background, so the hue survives the adjustment.
* Returns @p color unchanged when it already meets the floor, which is the
* common case for a shipped palette.
*
* @param color Color to adjust.
* @param background Surface the color is drawn on.
@ -99,14 +121,12 @@ double labelContrastFloor();
/**
* @brief Applies an emphasis level to an already-hued glyph color.
*
* Secondary keeps the color untouched, so it is the no-op the default needs.
* Primary is the most chromatic member of its family: it raises the saturation
* to the most that still clears the text floor. Contrast alone cannot order the
* ladder, because on a dark surface the highest-contrast tone is the palest one;
* a primary that reads as a washed-out version of the secondary is exactly the
* inversion this rule removes. Quiet
* blends the color toward the surface it sits on and stops at the non-text
* legibility floor, so a labelling glyph recedes without disappearing.
* The three levels share one legibility floor and differ by chroma, because
* contrast cannot order them: on a dark surface the highest-contrast tone is the
* palest one, so a contrast target inverts the ladder. Primary is the most
* chromatic member of its family, secondary keeps the color untouched (the
* no-op the default needs), and quiet mutes the color to a fraction of its
* saturation before solving it back to the same floor.
*
* @param color Hue-resolved glyph color.
* @param surface Surface the glyph is drawn on.
@ -115,13 +135,27 @@ double labelContrastFloor();
*/
QColor forEmphasis(const QColor &color, const QColor &surface, IconEmphasis emphasis);
/** @brief Contrast floor the primary level reaches, so it stays legible. */
/**
* @brief The ink a disabled glyph is painted with, given the quiet level.
*
* A disabled glyph is the quiet ink receded further toward the surface and held
* to a floor below the quiet one. The palette's own disabled text is a fixed
* gray whose contrast varies with the surface, so on the darkest themes it
* out-reads the quiet level and an enabled glyph looks more disabled than a
* disabled one. Deriving the disabled ink from the quiet ink makes that order
* structural instead of incidental.
*/
QColor disabledInk(const QColor &quiet, const QColor &surface);
/**
* @brief Contrast floor the primary level reaches, so it stays legible.
*
* Primary is ordered by chroma, not contrast, so it shares the text floor
* rather than clearing a higher one.
*/
double primaryContrastFloor();
/** @brief How far a quiet glyph is blended into its surface. */
double quietBlend();
/** @brief Contrast floor a quiet glyph keeps, the WCAG non-text minimum. */
/** @brief Contrast floor the quiet level reaches, shared with the other levels. */
double quietContrastFloor();
} // namespace ThemeIconInk

View file

@ -660,12 +660,13 @@ QColor ThemeManager::appColor(AppColor::Role role) const
// 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);
const QColor text = palette.color(QPalette::Active, QPalette::WindowText);
const QColor surface = ThemeIconInk::harderSurface(palette.color(QPalette::Active, QPalette::Window),
palette.color(QPalette::Active, QPalette::Button), text);
const QColor ink = ThemeIconInk::glyphInk(text, surface, accent);
switch (role) {
case AppColor::IconStrong:
return ThemeIconInk::forEmphasis(accent, surface, IconEmphasis::Primary);
return ThemeIconInk::forEmphasis(ink, surface, IconEmphasis::Primary);
case AppColor::IconSoft:
return ThemeIconInk::forEmphasis(ink, surface, IconEmphasis::Quiet);
case AppColor::Icon: