mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-10 20:13:24 -08:00
[Icons] Order the emphasis ladder by chroma, not contrast
Contrast cannot order the ladder. On a dark surface the highest-contrast tone is the palest one, so a 7:1 primary came out near-white while the secondary kept the family's color; the ladder read inverted, and for the shipped semantic greens and reds the primary level was already satisfied by the secondary, so the two levels resolved to the same hex. Emphasis is chromatic intensity now. Quiet recedes toward the surface at the 3:1 non-text floor, secondary keeps the family color at the 4.5:1 text floor, and primary is the most chromatic tone the same floor allows. Every level stays legible on the window and the button surface, and the primary is a strictly more saturated member of its family on both schemes. The reformatted palettes move the two upper levels onto the shared floor and leave the quiet level alone.
This commit is contained in:
parent
2e4731d3a5
commit
daa1a73956
13 changed files with 135 additions and 84 deletions
|
|
@ -14,13 +14,13 @@ namespace
|
|||
constexpr double ACCENT_MIX = 0.70;
|
||||
// An icon that labels a control is carrying text; see ui-assets.md.
|
||||
constexpr double LABEL_CONTRAST_FLOOR = 4.5;
|
||||
// A primary glyph has to out-read the default one on both schemes. The accent is
|
||||
// a mid-tone: on a light surface it is *lighter* than the neutral ink, and on a
|
||||
// dark surface it is *darker*, so neither "the accent as-is" nor "a contrast
|
||||
// floor shared with the default" keeps the primary above the secondary. A 7:1
|
||||
// target does: it darkens the accent on light schemes and lightens it on dark
|
||||
// ones, so the primary level always out-contrasts the default level.
|
||||
constexpr double PRIMARY_CONTRAST = 7.0;
|
||||
// A primary glyph is the most chromatic member of its family. Contrast cannot
|
||||
// order the ladder: on a dark surface the highest-contrast tone is the palest
|
||||
// one, so a contrast target makes the primary look like a washed-out secondary
|
||||
// and the ladder reads inverted. Primary instead raises the saturation by this
|
||||
// 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;
|
||||
|
|
@ -105,11 +105,59 @@ double labelContrastFloor()
|
|||
return LABEL_CONTRAST_FLOOR;
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
/** @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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Raises a color's saturation, then solves its value for the floor.
|
||||
*
|
||||
* Saturation is raised before the value is solved, because reaching a contrast
|
||||
* floor by adding white is what desaturates a tone in the first place. If the
|
||||
* hue cannot reach the floor at the raised saturation inside the gamut, the step
|
||||
* shrinks until it can, so an already-vivid tone keeps what it has. A gray has
|
||||
* no saturation to raise and falls back to the contrast search.
|
||||
*/
|
||||
QColor chromaticStep(const QColor &color, const QColor &surface, double floor, int stepPoints)
|
||||
{
|
||||
if (color.hue() < 0) {
|
||||
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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
QColor forEmphasis(const QColor &color, const QColor &surface, IconEmphasis emphasis)
|
||||
{
|
||||
switch (emphasis) {
|
||||
case IconEmphasis::Primary:
|
||||
return reachContrast(color, surface, PRIMARY_CONTRAST).toRgb();
|
||||
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();
|
||||
case IconEmphasis::Secondary:
|
||||
|
|
@ -120,7 +168,7 @@ QColor forEmphasis(const QColor &color, const QColor &surface, IconEmphasis emph
|
|||
|
||||
double primaryContrastFloor()
|
||||
{
|
||||
return PRIMARY_CONTRAST;
|
||||
return LABEL_CONTRAST_FLOOR;
|
||||
}
|
||||
|
||||
double quietBlend()
|
||||
|
|
|
|||
|
|
@ -99,11 +99,12 @@ double labelContrastFloor();
|
|||
/**
|
||||
* @brief Applies an emphasis level to an already-hued glyph color.
|
||||
*
|
||||
* Secondary keeps the hue untouched, so it is the no-op the default needs.
|
||||
* Primary pushes the color to a higher floor than the default, because the
|
||||
* accent is a mid-tone: on a light scheme it is lighter than the neutral ink and
|
||||
* on a dark one darker, so only a stronger contrast target keeps the primary
|
||||
* level above the secondary on both. Quiet
|
||||
* 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.
|
||||
*
|
||||
|
|
@ -114,7 +115,7 @@ double labelContrastFloor();
|
|||
*/
|
||||
QColor forEmphasis(const QColor &color, const QColor &surface, IconEmphasis emphasis);
|
||||
|
||||
/** @brief Contrast floor the primary level reaches, so it out-reads the default. */
|
||||
/** @brief Contrast floor the primary level reaches, so it stays legible. */
|
||||
double primaryContrastFloor();
|
||||
|
||||
/** @brief How far a quiet glyph is blended into its surface. */
|
||||
|
|
|
|||
|
|
@ -71,13 +71,13 @@ Accent = #4a82e6
|
|||
[AppColors]
|
||||
AccentStrong = #3a6fd6
|
||||
AccentSoft = #8fb8f2
|
||||
Icon = #7b9fe4
|
||||
IconStrong = #b1c7ef
|
||||
Icon = #7ca0e5
|
||||
IconStrong = #6a9dff
|
||||
IconSoft = #70809d
|
||||
Positive = #6dd58c
|
||||
PositiveStrong = #99e2ae
|
||||
Positive = #5bb174
|
||||
PositiveStrong = #30b758
|
||||
PositiveSoft = #5e8873
|
||||
Negative = #ff8a80
|
||||
NegativeStrong = #ffb3ac
|
||||
Negative = #ed8077
|
||||
NegativeStrong = #ff7367
|
||||
NegativeSoft = #9b7678
|
||||
|
||||
|
|
|
|||
|
|
@ -71,13 +71,13 @@ Accent = #2f6fd6
|
|||
[AppColors]
|
||||
AccentStrong = #3a6fd6
|
||||
AccentSoft = #8fb8f2
|
||||
Icon = #2f59ad
|
||||
IconStrong = #213e79
|
||||
Icon = #2f58ac
|
||||
IconStrong = #054ddf
|
||||
IconSoft = #687899
|
||||
Positive = #1b7f3b
|
||||
PositiveStrong = #104a22
|
||||
Positive = #166931
|
||||
PositiveStrong = #006b22
|
||||
PositiveSoft = #5d7f6f
|
||||
Negative = #b3261e
|
||||
NegativeStrong = #7c1b15
|
||||
Negative = #b0251d
|
||||
NegativeStrong = #ba0a00
|
||||
NegativeSoft = #9a6c6f
|
||||
|
||||
|
|
|
|||
|
|
@ -71,13 +71,13 @@ Accent = #ff1e1e1e
|
|||
[AppColors]
|
||||
AccentStrong = #ff139740
|
||||
AccentSoft = #ffc9fd62
|
||||
Icon = #5db87c
|
||||
IconStrong = #aad9b9
|
||||
Icon = #5eb97d
|
||||
IconStrong = #30be61
|
||||
IconSoft = #6e8d79
|
||||
Positive = #6dd58c
|
||||
PositiveStrong = #99e2ae
|
||||
Positive = #5fb97a
|
||||
PositiveStrong = #32bf5c
|
||||
PositiveSoft = #698e74
|
||||
Negative = #ff8a80
|
||||
NegativeStrong = #ffbfb9
|
||||
Negative = #f8867c
|
||||
NegativeStrong = #ff8075
|
||||
NegativeSoft = #a37c78
|
||||
|
||||
|
|
|
|||
|
|
@ -71,13 +71,13 @@ Accent = #fff0f0f0
|
|||
[AppColors]
|
||||
AccentStrong = #ff139740
|
||||
AccentSoft = #ffc9fd62
|
||||
Icon = #107e36
|
||||
IconStrong = #0b5826
|
||||
Icon = #107d36
|
||||
IconStrong = #007e2c
|
||||
IconSoft = #6c937a
|
||||
Positive = #1b7f3b
|
||||
PositiveStrong = #135929
|
||||
Positive = #1b7d3a
|
||||
PositiveStrong = #007e28
|
||||
PositiveSoft = #70927b
|
||||
Negative = #b3261e
|
||||
NegativeStrong = #7d1b15
|
||||
Negative = #cf2c23
|
||||
NegativeStrong = #da0c00
|
||||
NegativeSoft = #b37d7b
|
||||
|
||||
|
|
|
|||
|
|
@ -71,13 +71,13 @@ Accent = #c9995a
|
|||
[AppColors]
|
||||
AccentStrong = #b7823b
|
||||
AccentSoft = #e4c58f
|
||||
Icon = #bc8c4a
|
||||
IconStrong = #d4b68c
|
||||
Icon = #bd8d4a
|
||||
IconStrong = #d0851e
|
||||
IconSoft = #86745a
|
||||
Positive = #6dd58c
|
||||
PositiveStrong = #99e2ae
|
||||
Positive = #55a66d
|
||||
PositiveStrong = #2dab52
|
||||
PositiveSoft = #588061
|
||||
Negative = #ff8a80
|
||||
NegativeStrong = #ffada6
|
||||
Negative = #df7970
|
||||
NegativeStrong = #ff5e50
|
||||
NegativeSoft = #996b66
|
||||
|
||||
|
|
|
|||
|
|
@ -71,13 +71,13 @@ Accent = #a5712f
|
|||
[AppColors]
|
||||
AccentStrong = #b7823b
|
||||
AccentSoft = #e4c58f
|
||||
Icon = #735125
|
||||
IconStrong = #4e3719
|
||||
Icon = #725025
|
||||
IconStrong = #7e4b09
|
||||
IconSoft = #817059
|
||||
Positive = #1b7f3b
|
||||
PositiveStrong = #0f4520
|
||||
Positive = #15642e
|
||||
PositiveStrong = #006621
|
||||
PositiveSoft = #61795d
|
||||
Negative = #b3261e
|
||||
NegativeStrong = #741914
|
||||
Negative = #a7231c
|
||||
NegativeStrong = #b10a00
|
||||
NegativeSoft = #9a665b
|
||||
|
||||
|
|
|
|||
|
|
@ -71,13 +71,13 @@ Accent = #6a4df0
|
|||
[AppColors]
|
||||
AccentStrong = #5b3ee0
|
||||
AccentSoft = #a28bf7
|
||||
Icon = #927fea
|
||||
IconStrong = #b7aaf1
|
||||
Icon = #9380eb
|
||||
IconStrong = #927aff
|
||||
IconSoft = #736b97
|
||||
Positive = #6dd58c
|
||||
PositiveStrong = #99e2ae
|
||||
Positive = #519f69
|
||||
PositiveStrong = #2ba44f
|
||||
PositiveSoft = #517a63
|
||||
Negative = #ff8a80
|
||||
NegativeStrong = #ffada6
|
||||
Negative = #d5736b
|
||||
NegativeStrong = #ff4f40
|
||||
NegativeSoft = #926567
|
||||
|
||||
|
|
|
|||
|
|
@ -71,13 +71,13 @@ Accent = #5a3ee2
|
|||
[AppColors]
|
||||
AccentStrong = #5b3ee0
|
||||
AccentSoft = #a28bf7
|
||||
Icon = #5439d0
|
||||
IconStrong = #39278e
|
||||
Icon = #5338ce
|
||||
IconStrong = #4118fe
|
||||
IconSoft = #7569a4
|
||||
Positive = #1b7f3b
|
||||
PositiveStrong = #0e431f
|
||||
Positive = #15622e
|
||||
PositiveStrong = #006420
|
||||
PositiveSoft = #5c776d
|
||||
Negative = #b3261e
|
||||
NegativeStrong = #711913
|
||||
Negative = #a4231b
|
||||
NegativeStrong = #ae0900
|
||||
NegativeSoft = #95646d
|
||||
|
||||
|
|
|
|||
|
|
@ -71,13 +71,13 @@ Accent = #8fa6c0
|
|||
[AppColors]
|
||||
AccentStrong = #54687e
|
||||
AccentSoft = #9db0c4
|
||||
Icon = #8392a1
|
||||
IconStrong = #acb6c0
|
||||
Icon = #8493a2
|
||||
IconStrong = #6b95bf
|
||||
IconSoft = #6d737a
|
||||
Positive = #6dd58c
|
||||
PositiveStrong = #99e2ae
|
||||
Positive = #52a16a
|
||||
PositiveStrong = #2ba650
|
||||
PositiveSoft = #517c61
|
||||
Negative = #ff8a80
|
||||
NegativeStrong = #ffada6
|
||||
Negative = #d8756c
|
||||
NegativeStrong = #ff5344
|
||||
NegativeSoft = #926865
|
||||
|
||||
|
|
|
|||
|
|
@ -71,13 +71,13 @@ Accent = #54687e
|
|||
[AppColors]
|
||||
AccentStrong = #54687e
|
||||
AccentSoft = #9db0c4
|
||||
Icon = #4a5b6f
|
||||
IconStrong = #333e4c
|
||||
Icon = #495a6e
|
||||
IconStrong = #375a84
|
||||
IconSoft = #6e7580
|
||||
Positive = #1b7f3b
|
||||
PositiveStrong = #0f4620
|
||||
Positive = #16662f
|
||||
PositiveStrong = #006721
|
||||
PositiveSoft = #5b7b6a
|
||||
Negative = #b3261e
|
||||
NegativeStrong = #761a14
|
||||
Negative = #aa241c
|
||||
NegativeStrong = #b40a00
|
||||
NegativeSoft = #96696b
|
||||
|
||||
|
|
|
|||
|
|
@ -1440,13 +1440,14 @@ TEST_F(ThemeIconRenderTest, EmphasisSelectsTheAuthoredVariant)
|
|||
}
|
||||
|
||||
/**
|
||||
* @brief The authored primary level out-reads the default one on both schemes.
|
||||
* @brief The authored primary level is the most chromatic member of its family.
|
||||
*
|
||||
* The three levels are authored against the window and button surfaces, so the
|
||||
* ordering has to be visible on both, not just on the window the palette is
|
||||
* usually previewed against.
|
||||
* Contrast cannot order the ladder: on a dark surface the highest-contrast tone
|
||||
* is the palest one, so a contrast target makes the primary read as a washed-out
|
||||
* secondary. Chroma orders the levels instead; contrast only has to stay above
|
||||
* the legibility floor on every surface the icon is drawn on.
|
||||
*/
|
||||
TEST_F(ThemeIconRenderTest, PrimaryOutReadsSecondaryOnBothSchemes)
|
||||
TEST_F(ThemeIconRenderTest, PrimaryIsTheMostChromaticLevelOnBothSchemes)
|
||||
{
|
||||
for (const bool dark : {false, true}) {
|
||||
const InkPalette palette = shippedPalette(QStringLiteral("Fabric"), dark);
|
||||
|
|
@ -1455,9 +1456,8 @@ TEST_F(ThemeIconRenderTest, PrimaryOutReadsSecondaryOnBothSchemes)
|
|||
const QColor primary = palette.roles.value(AppColor::IconStrong);
|
||||
ASSERT_TRUE(secondary.isValid() && primary.isValid()) << (dark ? "dark" : "light");
|
||||
|
||||
EXPECT_GT(primary.hsvSaturation(), secondary.hsvSaturation()) << (dark ? "dark" : "light");
|
||||
for (const QColor &surface : {palette.window, palette.button}) {
|
||||
EXPECT_GT(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::contrastRatio(secondary, surface))
|
||||
<< (dark ? "dark" : "light");
|
||||
EXPECT_GE(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::primaryContrastFloor())
|
||||
<< (dark ? "dark" : "light");
|
||||
}
|
||||
|
|
@ -1559,7 +1559,9 @@ TEST(IconInk, ShippedPalettesAuthorTheEmphasisLadder)
|
|||
<< theme.toStdString() << (dark ? " dark" : " light");
|
||||
EXPECT_GE(primary, ThemeIconInk::primaryContrastFloor())
|
||||
<< theme.toStdString() << (dark ? " dark" : " light");
|
||||
EXPECT_GT(primary, secondary) << theme.toStdString() << (dark ? " dark" : " light");
|
||||
EXPECT_GT(palette.roles.value(AppColor::IconStrong).hsvSaturation(),
|
||||
palette.roles.value(AppColor::Icon).hsvSaturation())
|
||||
<< theme.toStdString() << (dark ? " dark" : " light");
|
||||
EXPECT_GE(quiet, ThemeIconInk::quietContrastFloor())
|
||||
<< theme.toStdString() << (dark ? " dark" : " light");
|
||||
EXPECT_LT(quiet, secondary) << theme.toStdString() << (dark ? " dark" : " light");
|
||||
|
|
@ -1573,7 +1575,7 @@ TEST(IconInk, ShippedPalettesAuthorTheEmphasisLadder)
|
|||
<< theme.toStdString() << (dark ? " dark" : " light");
|
||||
EXPECT_LT(ThemeIconInk::contrastRatio(palette.roles.value(soft), surface), baseContrast)
|
||||
<< theme.toStdString() << (dark ? " dark" : " light");
|
||||
EXPECT_GT(ThemeIconInk::contrastRatio(palette.roles.value(strong), surface), baseContrast)
|
||||
EXPECT_GT(palette.roles.value(strong).hsvSaturation(), palette.roles.value(base).hsvSaturation())
|
||||
<< theme.toStdString() << (dark ? " dark" : " light");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue