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

Add contrast function #488

Closed
wants to merge 5 commits into from
Closed

Add contrast function #488

wants to merge 5 commits into from

Conversation

Synchro
Copy link
Member

@Synchro Synchro commented Nov 24, 2011

I've added a contrast colour function. Given a colour, it returns black for colours > 50% lightness, white for < 50%. This ensures you don't end up with white on white or black on black. It's used like this:

.someblock {
background-color: @background;
color: contrast(@background);
}

There are probably more subtle ways of doing this, but it should always work, unlike say, inverting the lightness value.

@Synchro
Copy link
Member Author

Synchro commented Nov 25, 2011

I applied the patch from #486 which allowed me to run the test suite and confirm this code passes.

@matthew-dean
Copy link
Member

This is great, but instead of returning white, shouldn't it return an opposite lightness value? Or is that already possible?

@Synchro
Copy link
Member Author

Synchro commented Nov 29, 2011

No, that's exactly the common use case that people do wrong, as mentioned in the pull request. Think what happens on 50% grey. For readability there apparently needs to be at least a 10% difference in lightness - I've no idea how to calculate something meaningful for that (some colour theory stuff is insanely complicated!), so falling back to black and white is a simplistic approach but should be reasonably useful, which is why I said it could be more subtle. The calculated colour could of course be modified further using LESS's other colour operations, but at least this way we're ensured it's readable.
A similar approach is described here.

@Synchro
Copy link
Member Author

Synchro commented Dec 12, 2011

I just noticed that SASS has this function in Compass. It uses variables to define default light and dark colours and a threshold, but I can't do that easily in LESS because it lacks conditionals. I guess light and dark colours could be added as additional parameters and do the conditions in JS.

@matthew-dean
Copy link
Member

I think the upcoming mixin guards should handle this more gracefully.

See: #191

@Synchro
Copy link
Member Author

Synchro commented Dec 28, 2011

Well that would deal with the conditional part, but that's not the end of the story because less doesn't provide access to colour components (why I needed to do the contrast calculation in JS), and guards don't fix that. To paraphrase your example I'd need to do something like this:

@text: lightness(@bgcol) > 127 ? @lightText : @darkText;

less lacks a lightness function anyway, but switching on an explicit value (in your example the theme name) is much less useful - it's practically identical to setting the colour value explicitly, which is the same thing as not using a condition at all. While I can see guards being useful for conditions in general, they don't add any functionality themselves - we'd need a whole load of other colour functions (which could include a contrast function anyway) before they could be usable for this.

@matthew-dean
Copy link
Member

It should work like this (I think, as the mixin guards aren't final):

.contrast(@color) ? lightness(@color) > 50% {
   color: black;
}
.contrast(@color) ? lightness(@color) <= 50% {
   color: white;
}

.myselector {
   @baseColor: #ABABAB;
   .contrast(@baseColor);
}

@Synchro
Copy link
Member Author

Synchro commented Dec 28, 2011

That looks workable but it's pretty verbose, and it still needs implementations of colour functions. All seems like quite a long way around?

@matthew-dean
Copy link
Member

Nope, color functions already exist as follows:

lighten(@color, 10%);     // return a color which is 10% *lighter* than @color
darken(@color, 10%);      // return a color which is 10% *darker* than @color

saturate(@color, 10%);    // return a color 10% *more* saturated than @color
desaturate(@color, 10%);  // return a color 10% *less* saturated than @color

fadein(@color, 10%);      // return a color 10% *less* transparent than @color
fadeout(@color, 10%);     // return a color 10% *more* transparent than @color

spin(@color, 10);         // return a color with a 10 degree larger in hue than @color
spin(@color, -10);        // return a color with a 10 degree smaller hue than @color

hue(@color);        // returns the `hue` channel of @color
saturation(@color); // returns the `saturation` channel of @color
lightness(@color);  // returns the 'lightness' channel of @color 

It may be more verbose, but a contrast function seems arbitrary in its logic. I wouldn't necessarily expect a color to always return black or white (as a contrasting color), for example. It's what makes sense to you, but it's hard to see that going in the core library. By doing a mixin, you can establish the logic for yourself in your own library, and then simply import the library whenever you want to use your .contrast() mixin.

@Synchro
Copy link
Member Author

Synchro commented Dec 28, 2011

OK, sorry I'd missed those colour channel functions. I agree that black and white are a bit arbitrary - A better implementation might be more like the one I linked to in SASS, which takes a background, light and dark colours as params, which could reasonably default to black and white.

I like having contrast as a function rather than a mixin as it lets me define the colours globally, independent of any selector, exactly as you typically define other colours in a theme.

@bgcol: #000;
@lightText: #AAA;
@darkText: #333;
@text: contrast(@bgcol, @lightText, @darkText);
@text2: contrast(@bgcol);

I don't really see that this is any different to having things like:

@text3: spin(@lightText, 10);

@matthew-dean
Copy link
Member

Now that I think about it, I think you are identifying a general issue, which is that I can't define a value output using a custom mixin. So, while it can output the entire property, it would be nice to, as you point out, just output a color value. I think LESS mixins can only output property / value pairs, unless I'm mistaken.

@alixaxel
Copy link

@MatthewDL: Indeed, only pairs work in LESS; it would be awesome to have something like SASS @return.

@noeltock
Copy link

For simple color lightness/darkness, you'd have to convert the HEX code to RGB and then to HSL, then isolate the L and increase or decrease this value. You then convert it back to RGB. At this point you can either 1) Create an RGBA output for plenty of situations (with the added bonus of opacity) or 2) Convert another step back to HEX.

@alixaxel
Copy link

@noeltock: That seems a bit redundant, but sure. The only problem is how to increase or decrease the lightness without mixin guards. @Synchro: With mixin guards, I came up with this, that ensures a minimum contrast level of 4:1:

.contrast(@background) when (lightness(@background) > 50%) {
    color: darken(@background, 80%);
}

.contrast(@background) when (lightness(@background) <= 50%) {
    color: lighten(@background, 80%);
}

By taking advantage of the mixin arity, it's also possible to mimic Compass behaviour:

@whiteText: #dddddd;
@blackText: #333333;

.contrast(@background, @whiteText: @whiteText, @blackText: @blackText) when (lightness(@background) > 50%) {
    color: @blackText;
}

.contrast(@background, @whiteText: @whiteText, @blackText: @blackText) when (lightness(@background) <= 50%) {
    color: @whiteText;
}

Color Contrast Verification: http://www.hp.com/hpinfo/abouthp/accessibility/webaccessibility/color_tool.html

Merged latest from upstream and updated tests too, all pass.
@Synchro
Copy link
Member Author

Synchro commented Jan 10, 2012

I just committed an expansion of my original version making the contrast function work more like SASS's, with optional params for light and dark values.

Are mixin guards usable yet? I find the guards syntax verbose and a bit confusing, and it's a bit limiting not being able to get plain colour values out. With my function it would be:

@whiteText: #dddddd;
@blackText: #333333;
@textcol: contrast(@background, @whiteText, @blackText);

Plus I could of course turn it around and derive a contrasting background colour from a text colour since it's a generic function unrelated to my classes.

Defaulting to black and white seems to give a worst-case contrast ratio of around 4.5. That tool is interesting: I found several combinations at lower contrast values much more readable than higher ones (e.g. a 3.7 that was far easier to read than a 5.9). Curious!

I'm also looking at being able to do further manipulations to colour values, so having done the above, I might want to go on to do something like:

@hilite: spin(@textcol, 20);

and I get the feeling that using guards for that would end up being quite messy.

@noeltock
Copy link

@Synchro I agree, was just explaining the process behind a more suitable color lightness/darkness changer, not just b/w (that's what I do on my websites, but via PHP, including the colorized image generation if any). Could be done via JS too, but may be overkill at this point. Cheers

@alixaxel
Copy link

@Synchro: The spin function is already part of LESS and it only changes the hue - depending on the saturation and lightness that may result in the exact same color. Regarding the contrast function / mixin, while I agree that returns would be a nice thing to have (I'm not holding my breath for it though), it's possible to take advantage of it too:

#contrast {
    .background(@color, @black: #000, @white: #fff) when (lightness(@color) > 50%) {
        background-color: @color;
        color: @black;
    }

    .background(@color, @black: #000, @white: #fff) when (lightness(@color) <= 50%) {
        background-color: @color;
        color: @white;
    }

    .foreground(@color, @black: #000, @white: #fff) when (lightness(@color) > 50%) {
        background-color: @black;
        color: @color;
    }

    .foreground(@color, @black: #000, @white: #fff) when (lightness(@color) <= 50%) {
        background-color: @white;
        color: @color;
    }
}

div { // lets use and mix a linear gradient from #fff to #00f
    #contrast > .background(mix(#fff, #00f, 50%), #333, yellow);
    background-image: -moz-linear-gradient(top, #fff, #00f);
    background-image: -webkit-linear-gradient(top, #fff, #00f);
    background-image: linear-gradient(top, #fff, #00f);
}

Guards seems to be available as of LESS 1.2.0, and while their syntax may seem strange they are quite powerful.

@Synchro
Copy link
Member Author

Synchro commented Jan 11, 2012

Oh I know that spin is there and what it does, I was just using it as an example of processing a colour value. As far as I'm concerned, the contrast function is a generic colour processing function just like spin, lighten etc (put a value in, get a changed value out), and the result could be used anywhere that a colour value can be used, not just for text, so if it's logical to implement spin as a function, then the same applies to contrast. If I was using contrast in a set of LESS rules for creating a complete colour palette (not related to any classes), using guards would result is a much more complicated and less flexible file, which seems to run counter to less' spirit.
As you say, returns would resolve this problem, but we don't have them yet and in the mean time it's much messier with guards.
I looked at SASS' implementation again, and I see that they include a threshold parameter, rather than assuming 50%.

@alixaxel
Copy link

@Synchro: Well, if your contrast() function is pushed into the main branch I'll certainly use it. :-)

@cloudhead
Copy link
Member

Why not have a real contrast function like that of photoshop or other image tools?

Less will not have @return because mixins are all about rules, not values.. Sass's approach is wrong. But this is what js functions are for, so let's try to have a real contrast function.

@cloudhead cloudhead closed this Jan 19, 2012
@Synchro
Copy link
Member Author

Synchro commented Jan 20, 2012

Huh? Are you proposing to add image processing functions to LESS? What do you mean by a real contrast function? Are you suggesting we use a more complex colour calculation that takes hue into account as well as lightness?

@matthew-dean
Copy link
Member

Image processing? I hope not. That doesn't seem necessary. If LESS can be used to apply CSS to inline SVG elements, you're all set. Of course, I haven't tried styling SVG yet.

@Synchro
Copy link
Member Author

Synchro commented Jan 20, 2012

I was being obtuse. I don't understand the rejection of my patch - it seems that the entire premise has not been understood. Cloudhead said "But this is what js functions are for, so let's try to have a real contrast function.", which is exactly what I provided. This has nothing to do with images or photoshop, just CSS; I wrote it because I needed it and there's no other way of doing this in LESS; and it can't be simulated through a combination of other LESS functions.

@matthew-dean
Copy link
Member

It's not understood probably because your function still seems mathematically arbitrary. A spin function has a mathematically expected relationship because of a well-defined color wheel. Contrast, however, is typically not a pre-determined math relationship, but has an adjustable numerical value. And what a contrast function really does in any photo/image editor is "push" the lightness and darkness of relative colors to opposite sides. If you are on the > 50% side, you get X more lightness, and if you are on the < 50%, you get X more darkness (or X less lightness).

So, I think that's kind of what you are going for, but your syntax just feels wrong and incompatible with current functions.

So, a true contrast function would look like this:

.myselector {
  color: contrast(@mycolor, 20%);
}

The contrast function would be similar to this (similar to @alixaxel's code):

.myselector {
  .contrast(@mycolor, 20%);
}

.contrast(@color, @percent) when (lightness(@color) > 50%) {
    color: lighten(@color, @percent);
}

.contrast(@color, @percent) when (lightness(@color) < 50%) {
    color: darken(@color, @percent);
}

I don't know how useful that is, but that's an actual contrast function and contains no arbitrary magic numbers, other than the threshold. Of course, you can reverse the logic, or you SHOULD be able to entirely replicate the color-contrast function in Compass:

.contrast-color(@color, @dark, @light, @threshold) when (lightness(@color) > @threshold) {
    color: @dark;
}
.contrast-color(@color, @dark, @light, @threshold) when (lightness(@color) <= @threshold) {
    color: @light;
}

I haven't tested that, BUT, if that's your goal, and your syntax was written like that, then I could totally see a need for that....

As I'm writing it out, I'm now getting what you are aiming for, which is to return a conditional color.

So, the only thing you had different was the @Threshold. I think the contrast function should work both ways, with an assumption of 50% when the @Threshold is omitted.

So that we can do:

.myselector {
  border: 1px solid contrast(@mycolor, @dark, @light);
  color: contrast(@forecolor, @darkForeColor, @lightForeColor, 80%);
}

Okay, now it makes sense. I think you're right that what you were aiming at was misunderstood because I think you just described it in a confusing way. But after seeing Compass, I see what you were aiming for, which is not actually achievable with a mixin guard.

I think this should be reopened and implemented, but with the above syntax and an optional threshold.

@cloudhead
Copy link
Member

I guess I don't really understand the purpose of this? How can we increase the contrast of a color unless we know what the colours around it are? Contrast is only relevant with colour pairs, you can't "contrast" a single colour.

@matthew-dean
Copy link
Member

Contrast (the way we use it in Photoshop and on display screens) actually has nothing to do with a color pair. A color can have more contrast based on how light it is. It just depends on which side of a threshold you are on. If it is light, it gets lighter. If it is dark, it gets darker. So, as a result, two colors on either side of a threshold will contrast more with each other, because their lightness have been pushed in opposite relative directions.

In this case, like @Synchro was saying, similar to how spin outputs the color with a color relationship on the color wheel, a contrast function would output a color based on the relationship of a color to a lightness threshold (a contrast split point).

It's almost like a colorif function, except the only thing being tested is lightness. So, in my last example, if @mycolor has a lightness of over 50%, it will push the contrasting color (the @dark color). If the lightness is below 50%, it will again push the contrasting color, the @light color.

Of course, @light and @dark are arbitrary and defined by the user, so it's not inherent that they are contrasting, but this function is more useful than simply pushing it darker or lighter based on a threshold. In this case, you are able to select the resulting color based on the threshold point.

I think that some variation of it is a good idea in that it's a color function that doesn't exist, and it's basically a way to output a color based on that color's positional values.

@Synchro
Copy link
Member Author

Synchro commented Jan 21, 2012

Thanks for coming back to this.
As you've realised, this is simply a function that returns one of two colours based on the lightness of a third.
As it stands the two colours are indeed arbitrary, but we could examine them to see which is lighter, rather than using a fixed parameter order. I don't know how useful the threshold parameter would be, I've not thought about what it would actually mean, though keeping the definition compatible with SASS is a free bonus.
I don't think you can expect to generate a contrasting colour directly as unlike a function like spin, there is more than one answer. I don't know that relying entirely on lightness is accurate, as I noticed when testing that HP page, but because we're just picking between two options and not generating a new colour that is less important. Just a simple means of avoiding white on white or black on black would be a good start!
How about I make the function work identically to SASS' implementation, adding an optional threshold and allowing light and dark to be provided in any order?

@alixaxel
Copy link

@MatthewDL: I don't think what Photoshop does applies to the web, we really must work with color pairs here.

@cloudhead: I'm still confused what you mean by a "real contrast function", @Synchro function seems pretty much complete. As much as I like LESS, I think @return and conditionals / loops (and accessors in some cases) would really benefit the language, right now it's pretty difficult for someone to implement useful logic relying only on mixins.

As it is, if I want to make a complete contrast feature, I have to write mixins for foreground and background colors, border colors, outline colors and so on (and multiply that * 2, because each mixin has to have two guard) - that's a lot of typing and a lot of wasted bandwidth compared to a return based approach. SASS just seems more appealing to build flexible stylesheets.

@returns would really solve these issues, as one could implement a contrast function fit to the job without waiting for it to find it's way into the main branch. Loops would make the process of building grid systems, sprites and so on much faster and with a smaller bandwidth footprint. Conditionals, it seems to me, would be way shorter than guards.

I'm sorry, but I just don't understand the reasons behind the current approach and I just can't stop thinking "what if"...

@matthew-dean
Copy link
Member

@alixaxel Semantics do matter, and how contrast is normally defined should at least be a consideration, but it's true that simply contrasting a color to either lighter or darker based on it's current value is not altogether useful, so some flexibility is needed.

Not to speak for @cloudhead, but he has said before that he wants to keep LESS declarative. I'm not 100% an expert on the concept, but the reasons for not having some things that SASS has are philosophical, and I understand some of the motivations. LESS is pretty simple right now, so there are trade-offs. It's simplicity vs. scriptability. I look at the SASS frameworks and say, "Holy frig, this is way over-engineered."

Having said that, I do sometimes feel cheated that there are built-in functions that can return a property in LESS and that I don't get to. Like you said, sometimes I just want to return a single property, like a color, and not every line of CSS that includes a color component.

I don't know the best "declarative" way to solve that stumbling block, but it sounds like we're all circling around a real issue, but maybe haven't tapped the best solution yet.

@Synchro
Copy link
Member Author

Synchro commented Jan 22, 2012

I don't think we need to worry too much about usage elsewhere since our context is quite specific - we're only talking about CSS colour values. There isn't really much we can 'import' from the image processing view of your average contrast slider since we have none of the other stuff that that relies on, for example a lightness histogram. In the context of CSS colours, the only thing we can really think about with respect to contrast is how much of it there is relative to another colour. Perhaps to be accurate a pure contrast function should return the difference in lightness between two colours, though I don't see that being very useful as it would still deny us the ability to choose between colours based on the result of it because LESS doesn't do conditions. My function uses only the lightness value to calculate contrast, and while that's not ideal from a perceptual point of view, it's good enough to be useful. If you find the name confusing, perhaps we should rename it to something like 'conditional lightness' or 'pickContrastingColour' (but better!), but whatever we call it, it's still useful as it is.

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.

5 participants