Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update contrast function and tests, fixes #2743 #2754

Merged
merged 1 commit into from
Mar 15, 2016

Conversation

Synchro
Copy link
Member

@Synchro Synchro commented Dec 7, 2015

This PR cleans up the contrast function so that it actually does what it is supposed to! There's a minor BC break in that it no longer uses the threshold param, which is also what SASS has done, but it's unlikely this will actually affect anyone.

Note that I have trashed some earlier commits in my repo that were based on the wrong revision in favour of this single commit.

@xi
Copy link

xi commented Mar 3, 2016

Some tests still use the deprecated threshold param. Is this intentional?

@Synchro
Copy link
Member Author

Synchro commented Mar 3, 2016

Of course - we still need to check still that old source files don't break; Deprecation doesn't necessarily imply a BC break.

matthew-dean added a commit that referenced this pull request Mar 15, 2016
Update contrast function and tests, fixes #2743
@matthew-dean matthew-dean merged commit bcc8ced into less:master Mar 15, 2016
@Benno007
Copy link

Benno007 commented May 11, 2016

Seems like removing the threshold broke the implementation on my work's website 👎 Now where we had white buttons before we have black text because its choosing black instead of white.

@Synchro
Copy link
Member Author

Synchro commented May 11, 2016

It will be doing that because black has higher contrast in that situation. If you want it to use a fixed colour, use a fixed colour, don't use contrast.

@Benno007
Copy link

Our use case has multiple themes that required us to use that kind of thing initially because most of the colours were set with variables. But I'm just doing a workaround having to duplicate styles for each theme now - our build plan automatically runs an npm update so we can't deploy til we fix these so sadly have to do it not the best way.

@mhawk0
Copy link

mhawk0 commented May 25, 2016

Please, can we have "threshold" param back?
Or could somebody provide a drop-in mixin which would handle the threshold, please?

@mhawk0
Copy link

mhawk0 commented May 25, 2016

amxmm1d
This illustrates the difference between less 1.6 and 1.7. The color used here is #1687af (for background and as an argument in contrast method). Are you sure the second one is how it should be now? For me, the first box's text has better contrast than the second one...

http://codepen.io/anon/pen/ezOgQZ

@seven-phases-max
Copy link
Member

seven-phases-max commented May 25, 2016

@mhawk0 Your example points to the earlier change (when contrast switched its threshold value from luma to luminance (or up-side-down)).
(More over your codepen example actually shows that since v1.7.0 the contrast(#1687af); actually returns white, i.e. the opposite to what you stated above).


This PR made the different thing: removing the threshold parameter at all (which was quite lame thing from the very beginning to be honest). See #2743 for more details.

So in general the recommendation is to update your Less version and forget of the threshold parameter. If you still find the legacy version to be useful you can always copy the old code into a plugin of your custom functions.

@mhawk0
Copy link

mhawk0 commented May 30, 2016

Thank you for the answer. I'm pretty sure that codepen uses less older than 1.7 (I just don't know how to prove it), since contrast(#1687af); returns black (#000000) on my localhost, where I have less 1.7.1 installed. If it returned white, there wouldn't be an issue and my complaint above would be completely invalid.

@matthew-dean
Copy link
Member

matthew-dean commented Jun 17, 2016

Per #2906 (comment), I think the contrast() changes took some developers by surprise, especially in versioning.

"Fixing" the contrast function may be what this does, but I misunderstood that to mean that this was a bug fix. When it's actually a major implementation change that produces a more "correct" result.

Someone mentioned to me recently that if you have a bug long enough, then developers will build things that depend on the bug.

One suggestion might be to revert this change, and then get a proper 3.0 branch going with this and other pending breaking changes (with a document that tracks those breaking changes). I believe the color mathematicians that say the reason for the change is sound, but it probably should not have been shipped in Less 2.7.

@Synchro
Copy link
Member Author

Synchro commented Jun 17, 2016

The threshold param was only ever included for compatibility with sass, and what it did was entirely arbitrary - I've never seen any justification for what it did, and sass dropped it in exactly the same way with identical impact. Because what it did alter would only affect colours in the middle of the range (i.e. near the threshold), it was only ever going to be edge cases that ran into it, as we're seeing here. One of the deleted comments in here said that there wasn't such a thing as a "more correct" contrast function when there definitively is - the WCAG formula that contrast now uses. The black/white flips mentioned in #2906 are the result of not specifying target colours and relying on defaults. If you want to use specific colours, use specific colours - there are lots of examples in the test suite that show what it does. Abandoning the entire thing because of a few edge cases that were relying on wrong behaviour isn't a good formula for progress.

@matthew-dean
Copy link
Member

Abandoning the entire thing because of a few edge cases that were relying on wrong behaviour isn't a good formula for progress.

Of course not. I never suggested that. I'm talking about nothing more than versioning and documentation.

@Synchro
Copy link
Member Author

Synchro commented Jun 17, 2016

OK. Just to refer back to @mhawk0's examples: the contrast ratio for white on #1687af is 4.1:1; for black it's 5.12:1, so black is the correct choice.

@matthew-dean
Copy link
Member

black is the correct choice

Black may satisfy a particular color contrast algorithm, but I would agree with @mhawk0's assessment that the white text I perceive as being higher contrast. But I don't know a better contrast algorithm. Do we have a testing page that shows contrast output between the old function and the new (or other algorithms)? It should be visually apparent when one is more correct, since which one "contrasts better" is a matter of human perception.

One of the deleted comments in here said that there wasn't such a thing as a "more correct" contrast function

That's correct. There is not. There are dozens of "color space" models, if not more, and the mathematical value of a color in that space differs for each model, which means that the calculated "contrast" between two colors in that color space also differs. For example: https://www.npmjs.com/package/color-space#spaces

@Synchro
Copy link
Member Author

Synchro commented Jun 18, 2016

Not really, because luma is simply luma, and it's not dependent on any particular colour space. We happen to be obtaining its value from an RGB source because it's convenient, but we could start from any other colour space and we would still end up with the same value, within the margin of rounding error.

There is a color metrics package linked from that color space one, and it will accept colours from any space and still return a single luma value.

Personally I think that the white looks better too, but the example given produces a WCAG fail for white and a pass for black, which I'm sure matters to some. I don't think we are really best placed to come up with a new formula. I have no objection to this change being moved to 3.0, but I do think it's better than the more arbitrary calc we had before - my very first version did a conversion to HSL and simply compared L values, and nobody complained about the far more severe problems that caused.

There's much more info on the WCAG formula here http://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html#visual-audio-contrast-contrast-73-head

@matthew-dean
Copy link
Member

matthew-dean commented Jun 18, 2016

Some color spaces skew luma based on human perceptual differences in ranges of the color spectrum. (i.e. given the same luma of two RGB colors, a typical human being will perceive one as being brighter, for certain color ranges / comparisons). HUSL is one such example. I do agree however that MOST color spaces commonly used will have the same luma value, especially in CSS. So I was kind of wondering aloud why the previous algorithm produced a more pleasing result, and the answer is likely that this is only true for this particular example, and it's otherwise a crap-shoot for mid-range comparisons.

WCAG is not a bad guideline for contrast algorithm, so that seems reasonable.

There are a few options here:

  1. Leave it the way it is, and just say the result is different now, tough luck. (I don't prefer this option.)
  2. Revert it for a 2.8 release, and commit it to a 3.0 (or "edge" or "dev") branch (which needs to be either created or re-created).
  3. Allow a "weight" parameter, which is like threshold but not really. It would allow a skew in the contrast calculation for color1 or color2 in relation to the source color. Threshold set a luminance threshold to push towards "dark" or "light". But a weight parameter (e.g. -1 = always choose color1, 0 = neutral, 1 = always choose color2, 0.5 means the contrast between source color and color2 only needs to be >=50% of the contrast between source color and color1 for color2 to be the result). So it's a weight of the math, not an arbitrary dividing line of the luminance values. In fact, this is probably how threshold ought to have operated originally, but oh well. This could also be combined with no.2.

The reason why I suggest no.3 is that it's a very easy adjustment for the breaking change. We can then say that mid-range comparisons are different, so they may need to add a value of -0.1 or 0.1 for those particular colors in that particular contrast comparison. This ALSO will allow for someone wanting a "preference" of a background color until the foreground passes below a certain level of readability, and not sooner. (It's still contrast math, but just a weighted contrast.) So that provides additional functionality that's useful for theming beyond just adjusting to the contrast algorithm change.

@Synchro
Copy link
Member Author

Synchro commented Jun 18, 2016

Surely the only outcome of 3 is that you'll break a different set of people's colours, and require them to change their code to cope with it - how is that different to what we've got now?

@matthew-dean
Copy link
Member

Surely the only outcome of 3 is that you'll break a different set of people's colours, and require them to change their code to cope with it - how is that different to what we've got now?

Nope. Not using that parameter would mean it has the current behavior. Unless you're talking about backwards compatibility with the threshold parameter (if used), in which case the range could be from 0 to 1 rather than -1 to 1. It's not an exact match, but by then you're narrowing more and more the potentially affected code base. Still a better outcome than changing the colors without an easy fix IMO.

@seven-phases-max
Copy link
Member

seven-phases-max commented Jun 29, 2016

The major problem with the N3 (not counting me thinking it's just another artificial backdoor crack - since instead of writing a generic code to work with ideally whatever input colors, we again need to test and fine-tune some magic number to meet a set of colors we'd expect), is that it also relies on the order of the color parameters, while the (current version) of the function is supposed to be order independent, i.e. both contrast(a, b, c) and contrast(a, c, b) return the same color (i.e. basically it's supposed to be generic like min(b, c) = min(c, b))

So my proposal to meet the issue is as always - separate contrast99 function (contrast-WCAG?) (though I did not look too deep into the algo they suggest so I have no glue of code changes required).


P.S. After all we already have a weight-hack there:

contrast(darken(#1687af, -50%));
contrast(darken(#1687af, +50%));
contrast(#1687af + #111);
contrast(#1687af * .42);
// etc.

tada! Not friendly, sure - but not really less friendly than an explicit parameter that you never know what to set to until you manually try some values with exact colors.

@matthew-dean
Copy link
Member

So my proposal to meet the issue is as always - separate contrast99 function (contrast-WCAG?)

@seven-phases-max Your proposal is looking less problematic with time. There really isn't a way to reasonably resolve the change. They're different types of calculations. I might propose an alternative syntax.

What about a keyword switch rather than a new function name? As in:

constrast(wcag, #1687af, black, white);
constrast(legacy, #1687af, black, white, 0.4);

With one of them becoming default with the keyword ommitted. It might allow for future expansion.

On the other hand, I'm not against contrast-wcag() (lower case for convention).

@seven-phases-max
Copy link
Member

seven-phases-max commented Sep 26, 2016

contrast(wcag, #1687af, black, white);
contrast(legacy, #1687af, black, white, 0.4);

I'm afraid it's even more worse. It will be the forth breaking change to the function (relatively) recently and it only makes the situation even more cluttered (after all the issue is as simple as people compile their old sources and get a different result). So I'd rather keep things as is saying "OK, we've done a bad thing, sorry. Here's how you change your code to get what you need":

contrast(#1687af * shift, black, white);
// with `shift > 1` to shift the result towards darker color
//  and `shift < 1` to shift the result towards lighter color

Obviously, a fix like:
contrast(#1687af, black, white, 0.4); -> contrast(legacy, #1687af, black, white, 0.4); would me more friendly, but contrast(wcag, #1687af, black, white); syntax spoils the very idea imho. Instead I'd rather revert the PR back and make a new function name for the newer version.

@matthew-dean
Copy link
Member

Instead I'd rather revert the PR back and make a new function name for the newer version.

Or revert the PR back and make a new function that could eventually replace both? But yeah, I agree that completely changing the methodology of the function was not great.

@matthew-dean
Copy link
Member

If we're going to revert, can we revert now before 3.0 is done? (Whenever that will be?)

@roelvanduijnhoven
Copy link
Contributor

I contributed to one of the earlier BC commits. But that was minor in the way that it corrected the output of the color slightly based on the spec. This one completely breaks the functionality as it was intended to work before and breaks dozens and dozens of tests on our application!

Can we please make sure that this bug gets fixed ASAP in an upcoming minor update?

If you really want parity on function name and behaviour with SASS please schedule it for a major bump in version.

@roelvanduijnhoven
Copy link
Contributor

Did anybody already create a PR that reverts this functionality? If not I am willing to pivot it. Can not believe this major break is out there since may!

@seven-phases-max
Copy link
Member

seven-phases-max commented Nov 18, 2016

Can not believe this major break is out there since may!

Not so "major" counting the number of comments/commenters here, is it? (Yes, it's maybe annoying but nor that critical really, no nuclear plant is going to explode from it AFAIK :P).

roelvanduijnhoven pushed a commit to roelvanduijnhoven/less.js that referenced this pull request Nov 23, 2016
Reverted PR was less#2754.

This reverts commit bcc8ced, reversing
changes made to 2f07cd7.
@roelvanduijnhoven
Copy link
Contributor

I meant major as in the sem-ver definition! Not blaming anyone. Just hoping to get this one resolved! Thanks for the input.

Created a PR that reverts the functionality to get this thing moving 🚋.

@matthew-dean
Copy link
Member

@roelvanduijnhoven Reverted in 2.7.2. If someone has a suggestion for a new contrast function name for 3.0, please post it.

@Synchro
Copy link
Member Author

Synchro commented Jan 5, 2017

How about wcag_contrast, to make it clear where it's from?

@matthew-dean
Copy link
Member

matthew-dean commented Jan 6, 2017

I initially was against 2 different functions, but as they are incompatible algorithms, I think that's fine. I later found a perceptual contrast algorithm that claims to be even more accurate (linked from another one), so there's lots of ways to do it. There isn't really one that's more correct than another.

However, they're not just incompatible algorithms but also different measurements (measurements of different values). It's been argued that the existing (legacy) contrast function doesn't measure contrast. And it's true, it sort of doesn't. It measures the luma threshold to choose a "light" (higher luma) or "dark" (lower luma) value, which @seven-phases-max noted made a breaking change from lightness to gamma-corrected perceptual brightness.

So even aligning them as "contrast" functions with 2 algorithms isn't quite right. That was the major problem with this change. It's taking similar inputs but not measuring the same data points against each other.

What I mean is:

  • legacy contrast compares value1 against the threshold value (value4), and then chooses value2 or value3.
  • @Synchro's function compares value1 against value2 and value3, and then chooses the greatest difference.

@Synchro - you said about the threshold value: "what it did was entirely arbitrary - I've never seen any justification for what it did." It isn't arbitrary. It's simply the comparison luma value. It's 0.43 by default instead of 0.5 because that's the perceptual brightness mid-point.

The legacy function is not silly or bad, and the threshold value was not an unreasonable way to shift bias. It's a perfectly reasonable function for what it does, but perhaps calling that "contrast" is misleading.

So what about... for 3.0:

Renaming the legacy function threshold(), and then calling the wcag version contrast()? I think calling them contrast-foo and contrast-bar (or bar-contrast) implies the same things are being measured, which they aren't. They're very different functions, even though they have superficially similar inputs.

What do you think?

@roelvanduijnhoven
Copy link
Contributor

That would work @matthew-dean! What about deprecating this behavior in a new minor release? So in the next 2.x.

  • Using contrast(a, b) obviously works as it did before.
  • Using contrast(a, b, c) is deprecated. We introduce threshold in this release and point to that function. I do not know if Less supports deprecation warnings, if so: we throw one too.

Then at 3.x we drop contrast(a, b, c) entirely.

@seven-phases-max
Copy link
Member

seven-phases-max commented Jan 9, 2017

I'd offer something like select-color family of names for all of them (because this is what they actually do) if it wasn't so long and still quite non-obvious/not-self-documenting...

@matthew-dean
Copy link
Member

matthew-dean commented Jan 9, 2017

@roelvanduijnhoven It's actually contrast(a, b, c, d) currently. See: http://lesscss.org/functions/#color-operations-contrast

Most of my spare time / energy is focused on helping to get 3.x shipped, so I don't really want to invest more than the minimum amount of time on 2.x releases.

Obviously @Synchro and probably others see value in measuring contrast, which really is what his version is really doing (measuring contrast of b and c in regards to a), so I don't see any reason to drop it entirely. But it might make sense to rename both?

What about this.....

Right now, here's our two functions in pseudo-code:

// threshold()
if luma(a) < d output b;
else output c;

// contrast()
if abs(luma(a) - luma(b)) > abs(luma(a) - luma(c)) output b;
else output c;

What if we simplified this into a single function:
compare(luma, a, b, c [, d]);

  1. First argument is the function we're going to execute against arguments to compare values.
  2. Second argument is the first thing we're comparing against.
  3. If there are 4 arguments, a compares against b or c and chooses b or c (greater difference).
  4. If there are 5 arguments, a compares against b and chooses c or d (less than = c, greater than or equal to = d).
  5. If a comparison argument (a or b) is already a number (not a color), use that number instead of running through the function in the first argument.

So, for example, to replicate a threshold (legacy contrast).

compare(luma, #222222, 0.43, #101010, #dddddd)

To replicate newer contrast:

compare(luma, #222222, #101010, #dddddd)

This would have the advantages of:
A. You can use functions other than luma().
B. The result does not necessarily have to be a color. You could output values other than colors based on the result of the comparison.
C. The input arguments don't have to be colors either. If the result can produce numbers, then those numbers can be compared to select the result.

What do you think?

@Synchro
Copy link
Member Author

Synchro commented Jan 9, 2017

That's actually quite similar to what I was thinking way back in the very first version of the contrast function. At that time, the only way of implementing conditions was with guards, which was very cumbersome, and getting it to switch colours on a whole theme was just impractical, so I effectively wrote the contrast function as a kind of embedded conditional. At the time I wrote some other oddments that had similarly abstract applications, such as random().

@matthew-dean
Copy link
Member

matthew-dean commented Jan 9, 2017

OR you simplify this whole process to an if function. This might be the most straightforward at all. Let the user do the work.

@a: #222222;
@b: #101010;
@c: #dddddd

// legacy
if(luma(@a) < 0.43, @b, @c);

// wcag contrast
if(abs(luma(@a) - luma(@b)) > abs(luma(@a) - luma(@c)), @b, @c);

I think I like this most of all. It's even more generic and solves way way way more problems.

@matthew-dean
Copy link
Member

@Synchro Instead of an embedded conditional, how about just a conditional? ;)

@matthew-dean
Copy link
Member

I'm leaning towards executive decision-ing this. It makes no sense to create narrow use-case functions who essentially compare as an if/then/else statement instead of just exposing an if/then/else. I mean, we're essentially bending over backwards to add additional things / ways to compare. It adds more complexity every time, instead of a generic comparison function.

@roelvanduijnhoven
Copy link
Contributor

roelvanduijnhoven commented Jan 10, 2017

Long discussion. I took some time to read the history.

The problem with this PR has been that it was a major break in a minor update. That is why I complained, and asked for a revert.

However: had this been a major bump (to 3) it would have been fine. Obviously I see that the function contrast is actually pretty weird. And we all want to go forward.

So. My take on how to go forward? Land the revert in next minor release (done). And simply update the contrast function to what is implemented in this PR for 3.

The only thing I would advise is to deprecate the threshold parameter in the next minor version. So people can prepare this way before moving on to 3. The thing is.. what are you going to tell them? There is no alternative function, right? Update If you land if function there is an alternative you can point htem to.

@matthew-dean
Copy link
Member

@roelvanduijnhoven The problem with that is, as noted in this thread, that the newer function does not always produce a desirable result.

I think a generic purpose if would make all of this a non-issue.

@Synchro
Copy link
Member Author

Synchro commented Jan 10, 2017

I think it's worth retaining a standardised function for sites that have to pass WCAG validation (e.g. all US gov sites). It seems silly to require that possibly non-technical authors are up to speed on precise calculations, especially given the difficulties we have had historically in doing exactly that - not having it is asking for a zillion incorrect implementations.

@matthew-dean
Copy link
Member

matthew-dean commented Jan 10, 2017

@Synchro A fair point. What about replacing contrast() in 3.0 and adding an if at a later date? I think even retaining the existing one as threshold() would be ok if needed.

We could retain threshold() and later replace it with if() since the calculation is fairly simple.

@gyoshev
Copy link

gyoshev commented Jan 11, 2017

@matthew-dean wouldn't that introduce another breaking change by renaming threshold() to if()? Even if both functions are introduced side-by-side in v3, authors will have to make the change twice; once from v2 contrast() to v3 threshold(), and again to if() when it becomes available.

It seems that everybody in this thread is on the same page about the following functions:

  • A contrast(@bg, @a, @b) function that calculates according to WCAG will cater for the use case of calculating contrast. This is the breaking change that was suggested in the PR and should be accepted for v3.
  • An if(@cond, @ifTrue, @ifFalse) function will cater for the current hack of using the old contrast parameter as a ternary if-statement.

It's also great that authors who depended on contrast() for doing the proper calculation - no changes needed.

@matthew-dean
Copy link
Member

matthew-dean commented Jan 11, 2017

@gyoshev No, not renaming threshold() to if(), I meant that when / if if() was added, it would make threshold() more irrelevant. But perhaps it would be fine not to remove it. If if() is added, threshold() could just be marked deprecated for some future major release, with the example code of how it would be replaced using if().

I'm fine with using the WCAG contrast() for 3.0. It does sound like consensus is in that direction. Is anyone against renaming the legacy contrast function threshold as an immediate fallback solution?

I doubt that if() could happen anytime soon, since it would involve changing the parsing rules, probably to special case if() into accepting a when condition as the first parameter. when conditions can currently be separated by commas because of legacy @media syntax (although we've updated documentation to point towards using an or keyword vs. a comma), so parsing would have to dis-allow a comma within a condition (just within a function), since that would designate an argument separator in a function. Then, the condition would need to be evaluated within the function tree type (flattening variables and other function results), before being sent to the actual Less.js if function for evaluation. (Similar to: https://github.com/less/less.js/blob/3.x/lib/less/tree/mixin-definition.js#L156 I think...?)

If someone wants to do that work and make a PR, you're welcome to it.

In the short term, let's just address the first question i.e. accept new contrast() and add threshold() in 3.0?

@Synchro
Copy link
Member Author

Synchro commented Jan 11, 2017

My only (minor) objection is that threshold has a specific meaning in most image processing apps - reducing all colours to black or white according to a split point. Though you can make it work like that if you don't provide any colour values (so it defaults to B&W), that is quite underselling it. It seems a reasonable solution otherwise.

@matthew-dean
Copy link
Member

@Synchro That's true.......... then what do you suggest?

@matthew-dean
Copy link
Member

@Synchro luma-threshold()?

@Synchro
Copy link
Member Author

Synchro commented Jan 11, 2017

That'll do.

@matthew-dean
Copy link
Member

Okay, that's good enough for me. I'll do that unless someone has a strong objection, and I'll add if() to the 4.0 roadmap.

@seven-phases-max
Copy link
Member

a strong objection, and I'll add if() to the 4.0 roadmap.

No objections, just some concerns of implementation details. I hope you realize that for if(a > b, ...) to work we need to implement full-featured parsing of conditional expressions of variable values (since function/mixin args are nothing but variables)). That's quite a lot of work (not even counting possible a > b selector conflicts to be also considered and complex expressions handling nightmare). Aside of this, it's fine I guess :)

@matthew-dean
Copy link
Member

@seven-phases-max Haha, yes, once I started to look at parsing and evaluation of when statements, I can see that it's quite a lot of work. Still seems worth it. Is this already open as an issue?

@seven-phases-max
Copy link
Member

Is this already open as an issue?

I think we've discussed this in details at #2072 (comment) but the issue itself about a different thing. The closest one is #1894 but it's too abstract so I guess it'd be fine to create a new one (and closing #1894 in favour of it).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

8 participants