css-houdini-drafts icon indicating copy to clipboard operation
css-houdini-drafts copied to clipboard

[css-typed-om] Inputs for the CSSColorValue constructors

Open tabatkins opened this issue 4 years ago • 47 comments

I'm writing the spec for color values right now, and the overall structure is straightforward: a CSSColorValue superclass, and subclasses for each type of color function.

I'm struggling a bit with the best design for the constructor arguments and/or the shorthand functions.

Some arguments, like hue angles, are easy - they're just a CSSNumericValue that needs to match <angle>. But most of the arguments to color functions are percentages. Obviously I'll accept a CSSNumericValue that matches <percentage>, but I'd like easier ways to invoke these - writing new CSSRGB(CSS.percent(10), CSS.percent(0), CSS.percent(100)) isn't great.

In particular, I'd like to allow them to accept JS numbers, interpreting the range 0-1 as being equivalent to a percent between 0% and 100%, so you could instead write the preceding function as new CSSRGB(.1, 0, 1).

This would generally be unproblematic and straightforward, except that rgb() accepts <number> as well, in the range 0-255. And elsewhere in TypedOM (such as new CSSScale()), passing a raw JS number is equivalent to passing a CSSUnitValue(x, "number") (see the "rectify a numberish value" algo). So this leaves me with several possibilities, none of which I find great:

  1. Always allow JS numbers in the color functions in the 0-1 range. There's no constructor form that's a direct equivalent to the rgb(0, 128, 255) syntax.

  2. Allow JS numbers in the color functions in the 0-1 range, but also allow CSSUnitValue(x, "number") to be passed to CSSRGB, with the 0-255 range.

  3. Don't allow JS numbers at all, only CSSNumericValues. CSSRGB accepts CSSUnitValue(x, "number") in the 0-255 range.

  4. (Definitely bad, not doing this) Allow JS numbers in the 0-1 range for all the color functions except CSSRGB, allow them in the 0-255 range for CSSRGB (in addition to CSSUnitValue(x, "number")).

  5. Same as (3), but also have shorthand functions like CSS.rgb() (akin to the CSS.px() family for numeric values) that act like (1).

I'm considering going with (1), but (5) would be fine as well. I don't like the others very much. Anyone else have opinions?

tabatkins avatar Dec 09 '20 19:12 tabatkins

Oh, forgot to list the big downside of (1) - if you wanted to make a lookalike custom function for rgb() that took 0-255 arguments (in the CSS) in the same way, you'd have to remember to translate any "number" arguments into JS numbers, rather than passing them thru directly.

Well, calling it "big" is perhaps a bit much, but it's the largest downside for (1), at least.

tabatkins avatar Dec 09 '20 19:12 tabatkins

Another option (and a bit more modern JS) could be to use a dictionary initializer as a single argument to the constructor. It also buys you some extensibility and flexibility.

Something like: CSSRGB({r: 0, g: 128, b: 255}) or CSSRGB({rPct: 0, gPct: 50, bPct: 100}) (not personally a fan of the abbreviation but trying to keep it succinct)

plinss avatar Dec 10 '20 04:12 plinss

@LeaVerou the design decisions for color.js seem directly relevant here, so given your expertise in API design I look forward to your comments.

My expertise is more on the functionality, rather than the API surface.

svgeesus avatar Dec 14 '20 17:12 svgeesus

In particular, I'd like to allow them to accept JS numbers, interpreting the range 0-1 as being equivalent to a percent between 0% and 100%, so you could instead write the preceding function as new CSSRGB(.1, 0, 1).

That is mostly going to work, except of course for CIE Lightness where 100% maps to 100, not 1.

svgeesus avatar Dec 14 '20 17:12 svgeesus

This is a very common issue with any library working with RGB colors. Typically the decision is to go with one of the two and if one wants to use a different range, they need to convert. In Color.js we went with 0-1 for reasons I will detail below, chroma.js and d3 Color went with 0-255.

I would suggest going with numbers in the 0-1 range, as the 0-255 range is a relic. It originates in using 8 bit for each color component, and seems completely arbitrary otherwise. Note that rgb() is not restricted to 8 bit anymore as it can accept non-integers. Also, all of the new RGB-based colors in Color 4 use 0-1 as well, including color(srgb). The more using > 8 bit per component becomes commonplace, the more arbitrary 0-255 will seem. 0-1 future-proofs this API.

Something like: CSSRGB({r: 0, g: 128, b: 255}) or CSSRGB({rPct: 0, gPct: 50, bPct: 100}) (not personally a fan of the abbreviation but trying to keep it succinct)

This creates unnecessary error conditions: what happens when both r and rPct are specified?

Stepping back for a bit, speccing Typed OM for colors is a fairly substantial undertaking and it would be good if we could get some consensus on the overall architecture and design decisions before discussing the minutiae of constructor arguments. I know there are many people in the group who would have input on said design decisions and would love to partake, myself and @svgeesus included.

LeaVerou avatar Dec 14 '20 18:12 LeaVerou

Something like: CSSRGB({r: 0, g: 128, b: 255}) or CSSRGB({rPct: 0, gPct: 50, bPct: 100}) (not personally a fan of the abbreviation but trying to keep it succinct)

This creates unnecessary error conditions: what happens when both r and rPct are specified?

That could be trivially resolved. You either raise an exception or decide which one wins.

Using an initialization dictionary also allows you to mix and match, e.g. CSSRGB({r: 0, gPct: 50, b: 42})

That said, I agree that 0-255 is a relic, but it is in common use. I'm in favor of making the API more forward-looking, but want to maintain backward compatibility with legacy code as well to ease the transition. I'm also concerned that if we go with pure 0-1 floating point that we make sure all the (0-255)/255 values can be accurately represented by JS numbers so they at least can round-trip without rounding errors (I think that's the case, but someone should check). e.g. if someone does new CSSRGB(42/255, 42/255, 42/255) and we have an accessor that returns a color channel in the 0-255 range, it needs to return 42, not 42.0000001 or 41.99999999. There's also the question of how these serialize, would the preceding example serialize as rgb(42, 42, 42) or rgb(16.47%, 16.47%, 16.47%)? css-color-4 specifies the former.

Even if we decide that the only values are r, g, and b in the 0-1 range, using an initialization dictionary is more readable, and is much more future-proof. It's simply a more forgiving pattern. Not to mention, it's endorsed by the TAG: https://www.w3.org/TR/2020/NOTE-design-principles-20201110/#prefer-dict-to-bool and https://www.w3.org/TR/2020/NOTE-design-principles-20201110/#naming-optional-parameters

Actually, the more I think about the serialization issue, the more I think the CSSRGB constructor should only take values comparable with the CSS rgb() function. This is an object model meant to represent the rgb() function as it stands after all, not an opportunity to invent something new. So I'm voting in favor of option 4 above (with a dictionary constructor, and the ability to take percentages as well as numbers in the 0-255 range). New ways of defining colors should be used in classes that represent the CSS constructs that allow them, e.g. CSSColor() that takes arguments like color() does.

plinss avatar Dec 14 '20 19:12 plinss

In Color.js we went with 0-1 for reasons I will detail below, chroma.js and d3 Color went with 0-255.

chroma.js and d3 Color are both sRGB-only, so that was a reasonable decision at the time but will bite them once they extend to other RGB spaces.

Some spaces, like rec2020 and rec2100, don't even allow 8 bits per component; the choices are 10, or 12 (recommended). Also, when quantized, there are two encoding ranges (narrow and wide) but the float 0..1 representation is always wide, which removes one source of error. For example, in 10 bit narrow-range encoding, color component values range from 64 to 940; in 12 bit narrow-range encoding, they range from 256 to 3760.

svgeesus avatar Dec 14 '20 19:12 svgeesus

(first contribution, hi! and apologies for any conventions I'm breaking). Design / dev tool author here. I favour (1) for the same reasons as @tabatkins and @LeaVerou Lea (0-255 is legacy, 0-1 is computationally simpler).

Some additional data points / perspectives:

  • from user interviews we know that for many developers / designers, rgb 0-255 often results in mental estimations such as "how far along the scale from from eg "no red" to "red" do I want to be. (That's not to say there isn't a lot of muscle memory here)
  • It's also more similar to the way other colour spaces are already expressed (eg the 's' and 'l' of hsl / hsv). We see these becoming more popular for obvious reasons (esp. in an age of design systems / computational color scheme generation)
  • Additionally: Safari (proprietarily) already exposes the P3 colour space with 0-1 for rgb. I wrote something up here a year ago that has some details. (update: the official webkit docs )

maltenuhn avatar Dec 14 '20 19:12 maltenuhn

Something like: CSSRGB({r: 0, g: 128, b: 255}) or CSSRGB({rPct: 0, gPct: 50, bPct: 100}) (not personally a fan of the abbreviation but trying to keep it succinct)

This creates unnecessary error conditions: what happens when both r and rPct are specified?

That could be trivially resolved. You either raise an exception or decide which one wins.

Indeed, you can solve all unnecessary error conditions by raising an error, but that doesn't make them good practice. It's far better to avoid creating the error condition in the first place. Deciding which one wins is the kind of arbitrary decision that makes APIs feel unpredictable and hard to learn.

Using an initialization dictionary also allows you to mix and match, e.g. CSSRGB({r: 0, gPct: 50, b: 42})

That said, I agree that 0-255 is a relic, but it is in common use. I'm in favor of making the API more forward-looking, but want to maintain backward compatibility with legacy code as well to ease the transition. I'm also concerned that if we go with pure 0-1 floating point that we make sure all the (0-255)/255 values can be accurately represented by JS numbers so they at least can round-trip without rounding errors (I think that's the case, but someone should check). e.g. if someone does new CSSRGB(42/255, 42/255, 42/255) and we have an accessor that returns a color channel in the 0-255 range, it needs to return 42, not 42.0000001 or 41.99999999.

I just ran for (let i=0; i<256; i++) { let a = i/255; console.log(a * 255); } and it seems to only log integers. Not sure if there's a compiler optimization at play though.

Even if we decide that the only values are r, g, and b in the 0-1 range, using an initialization dictionary is more readable, and is much more future-proof. It's simply a more forgiving pattern. Not to mention, it's endorsed by the TAG: w3.org/TR/2020/NOTE-design-principles-20201110/#prefer-dict-to-bool and w3.org/TR/2020/NOTE-design-principles-20201110/#naming-optional-parameters

Please note that the TAG guidelines you link to (both of which I'm aware of and have even taught) do not apply here. They are both about optional arguments, which these are not. Also, the first one is mainly about booleans, edited recently to generalize to all optional primitives. Indeed, avoiding boolean positional arguments is one of the most basic API design principles (they are called "boolean traps" for good reason). However, red, green, and blue, are numbers with an established meaning, an established order, and all of which are mandatory.

That said, there is less of an established order in other color spaces (e.g. LCH vs HCL), so for consistency, it would make sense to use named parameters here too.

LeaVerou avatar Dec 14 '20 20:12 LeaVerou

(first contribution, hi! and apologies for any conventions I'm breaking). Design / dev tool author here. I favour (1) for the same reasons as @tabatkins and @LeaVerou Lea (0-255 is legacy, 0-1 is computationally simpler).

Hi @maltenuhn! Welcome! 👋🏼

Some additional data points / perspectives:

  • from user interviews we know that for many developers / designers, rgb 0-255 often results in mental estimations such as "how far along the scale from from eg "no red" to "red" do I want to be. (That's not to say there isn't a lot of muscle memory here)

That's fascinating. Do you have a source for these user interviews? I would love to read more about this study!

  • Additionally: Safari (proprietarily) already exposes the P3 colour space with 0-1 for rgb. I wrote something up here a year ago that has some details. (update: the official webkit docs )

Not proprietary, but perfectly valid CSS Color 4 syntax! Unfortunately, they only implemented part of it, no other spaces besides P3, and no actual <percentage> values.

LeaVerou avatar Dec 14 '20 20:12 LeaVerou

Indeed, you can solve all unnecessary error conditions by raising an error, but that doesn't make them good practice. It's far better to avoid creating the error condition in the first place.

We can make many more APIs avoid errors by severely restricting what they can do, that doesn't help authors either. There's a balance needed. Allowing authors more flexibility at the risk of allowing them to make mistakes is a common trade off.

Please note that the TAG guidelines you link to (both of which I'm aware of and have even taught) do not apply here.

The principles they represent do apply.

They are both about optional arguments, which these are not.

First, there is an optional argument to the CSS rgb() function that we seem to be forgetting about here, a. Second, there's no reason to make all the arguments mandatory, new CSSRGB({g:128}) could (and should) be equivalent to new CSSRGB({r: 0, g: 128, b: 0, a: 1}).

Also, the first one is mainly about booleans, edited recently to generalize to all optional primitives.

It used to be about booleans, and despite the anchor (which was left intact to not break links) now talks about the general case, which is why I included the link (and the links were for the benefit of other readers of the thread, I'm aware that you know them).


Back to Tab's original question, I'm now fairly well convinced that bare numbers passed into the constructor of a CSSRGB object (regardless of how they're passed) should be interpreted the same as bare numbers passed into CSS rgb(). This is an object model of the CSS rgb() function, it needs to match the semantics of the rgb() function. Full stop. I have no issue with convenience functions or conversions from other types that take 0-1 numbers, but will strongly object to redefining the semantics of the rgb() function in the object model. It should be all about modeling the rgb() function as it exists, not about creating a new general purpose color object (which I'm in favor of, and agree should use 0-1 numbers). Similarly, object models of other CSS color functions should accept the same arguments as their CSS equivalents.

plinss avatar Dec 14 '20 21:12 plinss

Using an initialization dictionary also allows you to mix and match

We have numeric types already for precisely this reason - if you want percentages, you can just pass a CSS.percent(10) as the value.

I don't think an initialization dict, in general, is the best pattern here - most of the arguments are required, and there's already a well-known and established order for them, coming from CSS (and re-expressed right in the name). So I'm pretty confident positional is still the right way to go here, at least for the simple functions. (color() might get a different treatment.)

So I'm voting in favor of option 4 above

Given that taking raw numbers is a pure convenience (rather than only taking the CSSStyleValue objects that it'll expose post-construction), I'm really loathe to do tricky stuff like that and have the numbers be interpreted in different ways depending on the function. It's just begging for people to hold wrong. My current spec text is doing option 2 (raw JS numbers are always 0-1 percentages, but CSSRGB accepts CSS.number(127) as well).

tabatkins avatar Dec 14 '20 21:12 tabatkins

Using an initialization dictionary also allows you to mix and match

We have numeric types already for precisely this reason - if you want percentages, you can just pass a CSS.percent(10) as the value.

Fair enough.

I don't think an initialization dict, in general, is the best pattern here - most of the arguments are required, and there's already a well-known and established order for them, coming from CSS (and re-expressed right in the name). So I'm pretty confident positional is still the right way to go here, at least for the simple functions. (color() might get a different treatment.)

I disagree. I have no issue with also accepting positional arguments. But I want to push back on them all being required. e.g. does let x = new CSSRGB() throw an exception or is it simply new CSSRGB(0, 0, 0). Similarly, what if an author calls new CSSRGB(42) should that throw or just be new CSSRGB(42, 0, 0)?

So I'm voting in favor of option 4 above

Given that taking raw numbers is a pure convenience (rather than only taking the CSSStyleValue objects that it'll expose post-construction), I'm really loathe to do tricky stuff like that and have the numbers be interpreted in different ways depending on the function. It's just begging for people to hold wrong. My current spec text is doing option 2 (raw JS numbers are always 0-1 percentages, but CSSRGB accepts CSS.number(127) as well).

But that's actually inconsistent with your "passing a raw JS number is equivalent to passing a CSSUnitValue(x, "number")" so will cause even more author confusion IMO. I see no reason why new CSSRGB(100, 150, 200) should yield any different result than rgb(100, 150, 200) in CSS. Anything else is asking for trouble. As I said, we're modeling the existing rgb() function here, not inventing something new.

Frankly if you interpret bare numbers as 0-1 you are doing something tricky and interpreting them in a different way for this function, interpreting 0.5 as equivalent to CSS.number(127) rather than CSS.number(0.5) like you do everywhere else.

So let me make a concrete proposal:

dictionary CSSRGBInit {
    CSSNumberish r = 0; 
    CSSNumberish g = 0; 
    CSSNumberish b = 0;
    CSSNumberish a = 1; 
};
interface CSSRGB {
    constructor(optional CSSNumberish r = 0, optional CSSNumberish g = 0, optional CSSNumerish b = 0, optional CSSNumberish a = 1);
    constructor(optional CSSRGBInit init = {});
    ...
};

Both bare numbers and CSS.number() are interpreted the same as if they were specified in rgb().

This allows for author convenience, is succinct, is consistent with CSS and should be consistent with other CSS color object constructors.

(I don't have an issue if you want to make a new type restricting the values to double (or even octet), number, and percent types, rather than CSSNumberish, or just define other values to throw exceptions.)

plinss avatar Dec 14 '20 22:12 plinss

We can make many more APIs avoid errors by severely restricting what they can do, that doesn't help authors either. There's a balance needed. Allowing authors more flexibility at the risk of allowing them to make mistakes is a common trade off.

True, though I'm not convinced saving the user one division with 255 is a worthy trade off.

Please note that the TAG guidelines you link to (both of which I'm aware of and have even taught) do not apply here.

The principles they represent do apply.

Both Tab and I have explained why they do not:

  1. all arguments (besides a) are required (assigning an arbitrary default can theoretically make any argument optional, but in this case that's externally inconsistent)
  2. There is a very well established order, and arguments never/rarely need to be provided in a different order.

Back to Tab's original question, I'm now fairly well convinced that bare numbers passed into the constructor of a CSSRGB object (regardless of how they're passed) should be interpreted the same as bare numbers passed into CSS rgb(). This is an object model of the CSS rgb() function, it needs to match the semantics of the rgb() function. Full stop. I have no issue with convenience functions or conversions from other types that take 0-1 numbers, but will strongly object to redefining the semantics of the rgb() function in the object model. It should be all about modeling the rgb() function as it exists, not about creating a new general purpose color object (which I'm in favor of, and agree should use 0-1 numbers). Similarly, object models of other CSS color functions should accept the same arguments as their CSS equivalents.

You feel so strongly that the function should mirror the semantics of rgb(), yet you are opposed to it using positional arguments, just like rgb(). 🤔

Please note that color(srgb) uses the 0-1 range when provided with bare numbers. Are we really going to have two different sRGB objects with components in different ranges?

LeaVerou avatar Dec 15 '20 01:12 LeaVerou

As @plinss points out, another solution would be polymorphism. We could accept an array OR an object literal:

new CSSRGB([1, 0, .6])
new CSSRGB({r: 1, g: 0, b: .6});

Aside: can we please name the constructor something other than CSSRGB, i.e. two different acronyms smushed together? A namespace, and/or the word "Color" somewhere would help.

@tabatkins Re-iterating as I think it was lost in the argument discussion:

Stepping back for a bit, speccing Typed OM for colors is a fairly substantial undertaking and it would be good if we could get some consensus on the overall architecture and design decisions before discussing the minutiae of constructor arguments. I know there are many people in the group who would have input on said design decisions and would love to partake, myself and @svgeesus included.

Where can we find this draft? It's not anywhere in this repo. Searching for CSSRGB in the repo yielded no results. There are a lot of design decisions involved that are more substantial than constructor arguments, and it would be good to discuss and iterate earlier rather than later.

LeaVerou avatar Dec 15 '20 01:12 LeaVerou

e.g. does let x = new CSSRGB() throw an exception or is it simply new CSSRGB(0, 0, 0). Similarly, what if an author calls new CSSRGB(42) should that throw or just be new CSSRGB(42, 0, 0)?

Both throw. Those are required arguments in CSS, and there's no particularly good reason to change that in the JS representation, I believe. There's not really a meaningful sense in which 0 is a "default" value for any of the color channels.

But that's actually inconsistent with your "passing a raw JS number is equivalent to passing a CSSUnitValue(x, "number")" so will cause even more author confusion IMO.

Yes, it is inconsistent, but in a different realm. My current spec text has JS numbers be <percentage> for all colors; JS numbers are <number> elsewhere where numbers are the only sensible thing. I think this is a simple enough boundary that the confusion will be low, enough to be outweighed by the convenience of being able to write, say, CSS.hsl(CSS.degrees(60), 1, .5) over CSS.hsl(CSS.degrees(60), CSS.percent(100), CSS.percent(50)).

Where can we find this draft? It's not anywhere in this repo. Searching for CSSRGB in the repo yielded no results. There are a lot of design decisions involved that are more substantial than constructor arguments, and it would be good to discuss and iterate earlier rather than later.

It's still an in-progress edit right now, I'll have first draft up soon.

tabatkins avatar Dec 15 '20 01:12 tabatkins

all arguments (besides a) are required (assigning an arbitrary default can theoretically make any argument optional, but in this case that's externally inconsistent)

In JS all arguments are effectively optional. One way or another you have to define what happens when an author uses new CSSRGB(42), because nothing's stopping them from doing it. If you don't specify the behavior, we'll have interop bugs. In your argument above, throwing an exception is not necessarily best practice and it's better to avoid the error condition in the first place, I agree. Hence reasonable defaults, e.g. 0 for color channels, 1 for alpha.

You feel so strongly that the function should mirror the semantics of rgb(), yet you are opposed to it using positional arguments, just like rgb(). 🤔

I'm explicitly not opposed to positional arguments, see my proposal above. I just want the option to use an initialization dictionary. What I do feel strongly about is not redefining the behavior of a bare number for this one constructor.

Keeping it consistent with a theoretical color object model isn't as valuable as keeping it consistent with CSS syntax that's been in use for over 20 years.

Another advantage of an initialization dictionary is that it does let the author opt-in to a different behavior for bare numbers, by specifying them in a different dictionary slot (If that's a case we really need to support, after all, is it worth adding it to save them writing *255?).

Please note that color(srgb) uses the 0-1 range when provided with bare numbers. Are we really going to have two different sRGB objects with components in different ranges?

Yes. CSS already has them. This is the CSSOM, not a color OM, the need here is to provide an object model for CSS constructs, in this case rgb(). It's a CSS value, not a general-purpose color object. The object's constructor and behavior should match the usage in CSS. The argument to the constructor for CSSColor should match CSS color(), etc.

I'd love to see a generic color object model. And if we create one I'm happy for that to use floats and do away with the 0-255 legacy there. But that's not the scope of this issue or module and we shouldn't let this scope-creep into one.

We could accept an array OR an object literal

I don't see any value in accepting an array. I'm not seriously opposed, but unless there's precedent or alignment with other CSS color constructors I'd rather not invent another new thing here. In fact, we may want to reserve supporting an array in case the rgb() function ever gets list-like capabilities.

plinss avatar Dec 15 '20 01:12 plinss

Both throw. Those are required arguments in CSS, and there's no particularly good reason to change that in the JS representation, I believe. There's not really a meaningful sense in which 0 is a "default" value for any of the color channels.

I don't see any value in making those throw unless the objects are immutable. Why can't I construct an object and then populate the attributes later? Forcing authors to have all the values before constructing an object is an anti-pattern. What if I want to pass an object into a function to get the values for the channels?

plinss avatar Dec 15 '20 01:12 plinss

I'm now fairly well convinced that bare numbers passed into the constructor of a CSSRGB object (regardless of how they're passed) should be interpreted the same as bare numbers passed into CSS rgb(). This is an object model of the CSS rgb() function, it needs to match the semantics of the rgb() function.

I'd love to see a generic color object model. And if we create one I'm happy for that to use floats and do away with the 0-255 legacy there. But that's not the scope of this issue or module and we shouldn't let this scope-creep into one.

Wait, is that all it is doing? An object model of the sRGB-only rgb() function? I don't understand how that is useful.

Does the #rrggbb form have a different object? Does lab() have a different object? From the June 2020 Houdini minutes it seemed that there was consensus to have a color object, regardless of how it had originally been specified - so that there can be color conversion, color manipulation, and extras like WCAG contrast ratio, gamut mapping, and so forth.

svgeesus avatar Dec 15 '20 13:12 svgeesus

Wait, is that all it is doing? An object model of the sRGB-only rgb() function? I don't understand how that is useful.

Having not seen the actual spec prose Tab is working on, I can't say. But I can say what I believe it should be doing.

Yes, there should be a CSSRGB (modulo bikeshedding) class whose primary role is to model a CSS rgb() function. And that's all. The usefulness is in constructing, parsing, manipulating, and serializing CSS without using the ancient string-based APIs. e.g. I should be able to load a style sheet containing color: rgb(1,2,3), access the value of the color property as an object, and set g to 42 via code like value.g = 42, and not have to parse that string and compose a new one. That's the purpose of the CSS Typed OM module, not to be a generic color handling module.

Does the #rrggbb form have a different object? Does lab() have a different object?

Yes, that should be a CSSHexColor (or whatever we call it). We should also have a CSSLAB (for lab()), CSSColor (for color()) etc., that each model the respective CSS constructs, and will serialize as the same construct they were constructed as

From the June 2020 Houdini minutes it seemed that there was consensus to have a color object, regardless of how it had originally been specified - so that there can be color conversion, color manipulation, and extras like WCAG contrast ratio, gamut mapping, and so forth.

Right, and that should be a Color class (and whatever appropriate subclasses, I trust you and Lea to have the best input on the shape of that API). But it should not be a CSS color class as it's representing a color, not a CSS construct. And it should not be in this module (and arguably should not be a CSS module either as it should also be used in Canvas APIs, etc).

We should be able to convert from the CSS color constructs to a Color object and vice-versa. The Color class should have all the conversion, manipulation, and extra functionality, not the CSS color objects (though I'm happy to add convenience methods to the CSS color classes once we've defined the Color class(es)).

I fear the two concepts have become conflated here.

plinss avatar Dec 15 '20 18:12 plinss

Hi all. Author of Popmotion and Framer Motion here, I've spent a fair amount of time working with value ranges and I'd love to ditch a bunch of code in favour of a native color API. Here's some thoughts after reading this discussion.

First of all I recognise there's a huge value in maintaining familiar APIs like the RGB 0-255 range. But for me, that doesn't override the potential wins from option 1.

All finite ranges are IMO best described as ranges 0-1. It's a range that has intrinsically semantic meaning and is easy to visualise mentally. Standardising it between percentage/bounded time/8 bit color makes interpolating between these ranges straightforward.

On the keyed object idea, it'd of course be possible to make new RGB({ r: 0 ... }) to differentiate between 0-1 and 0-255. But compared to new RGB(0, 0.5, 1) this would be unwieldy and only promote wrapper libraries that simplify the syntax. I'd prefer to reduce the keystrokes and the payload. new RGB(0.5) should forward-fill with 0, 0, 1, the common-sense defaults. The short-hand for blue: 1 is new RGB(0,0,1), shorter than new RGB({ b: 1 }).

I don't think an initialization dict, in general, is the best pattern here - most of the arguments are required, and there's already a well-known and established order for them

I totally agree with this, it's even in the name!

Right, and that should be a Color class

I agree with this too. But Houdini has been developing for a while and I can easily imagine another 5(ish?) years waiting for a proper Color API. This should and can be it. It can be renamed, or not, I don't overly care. I just want a sane, native color API. The semantics in this sense really don't have me concerned.

Ultimately I think if the existing standard was rgb(0, 0.5, 1) and we were debating 0-255 this issue wouldn't get past the opening post. As @LeaVerou mentions, it's rooted in legacy and even accepts decimals now. If I want a half-value it's far more intuitive to think "0.5" than "127.5". This is a choice that can be chose for everyone consuming this new API in the coming decades.

mattgperry avatar Dec 15 '20 18:12 mattgperry

Also, to be clear, I'm perfectly happy for the generic Color classes to take simple bare numbers in their constructors. e.g. should there be a RGB Color subclass, new RGB(0, .5, 1) would yield the same color as a new CSSRGB(0, 128, 255) (modulo the rounding issue on 128/255) when converted to a Color object.

The Color class(es) should have a clean, uniform, API that makes the most sense for handling colors. The CSSOM classes however, should take the same inputs and match the semantics of their CSS counterparts.

plinss avatar Dec 15 '20 18:12 plinss

I'd prefer to reduce the keystrokes and the payload. new RGB(0.5) should forward-fill with 0, 0, 1, the common-sense defaults. The short-hand for blue: 1 is new RGB(0,0,1), shorter than new RGB({ b: 1 })

That's not now JS works, if the constructor is expecting RGB(r, g, b) and you call it with RGB(1), you're passing (1, undefined, undefined), shifting arguments around is a completely unexpected behavior.

But Houdini has been developing for a while and I can easily imagine another 5(ish?) years waiting for a proper Color API

And Houdini will continue developing for many years to come. It's all modules that can be implemented independently. There's no reason to expect defining a proper Color API, independent of the CSSOM would take another 5 years, or that it would, in fact take any longer than trying to define a CSSOM that conflates CSS constructs with generic colors.

In fact, as this thread demonstrates, mixing the two concepts is (and will continue to be) a source of contention that will dramatically slow the process. Having a CSSOM that's simply CSS, and a Color API that's just color, splits the concerns and lets each develop at their own pace, focusing on their own needs. Believe me, we'll get a Color API much faster if it's not carrying around 25 years of CSS baggage. We'll also get a reasonable CSSOM faster if we don't try to solve all of color in it.

plinss avatar Dec 15 '20 18:12 plinss

That's not now JS works, if the constructor is expecting RGB(r, g, b) and you call it with RGB(1), you're passing (1, undefined, undefined), shifting arguments around is a completely unexpected behavior.

Fairly familiar with how JS works. If you pass it RGB(1) you can reasonably forward fill GBA with 0,0,1. There’s no shifting. I then went on to say RGB({ b: 1 }) isn’t shorter than just writing RGB(0,0,1) so there’s no real benefit to this either in terms of shorthand.

mattgperry avatar Dec 15 '20 19:12 mattgperry

If you pass it RGB(1) you can reasonably forward fill GBA with 0,0,1

(sigh) I'm not saying it can't be done, I'm saying it shouldn't. It violates the principle of least surprise, e.g. any JS programmer unfamiliar with the API will presume you're passing (1, undefined, undefined), not (0, 0, 1). It also violates the TAG design principles regarding optional arguments. I can guarantee you an API like that would never pass TAG review.

Furthermore, the original intent of offering an initialization dict was to pass different types of values, e.g. numbers vs percentages. The advantages in using one for optional arguments is for clarity more than brevity. It also allows unspecified argument defaults to be controlled by the receiving function rather than the caller.

plinss avatar Dec 15 '20 20:12 plinss

(sigh) I'm not saying it can't be done, I'm saying it shouldn't. It violates the principle of least surprise, e.g. any JS programmer unfamiliar with the API will presume you're passing (1, undefined, undefined), not (0, 0, 1

We’ve crossed wires here. I may have incorrectly used “forward” fill? But said “GBA” get filled. Red is assigned 1, as provided. The rest get assigned their default values 0,0 and 1, as is standard JS. And that this syntax is so short we don’t need a shorthand for setting, for instance, blue, with named objects, as was earlier proposed.

mattgperry avatar Dec 16 '20 08:12 mattgperry

We’ve crossed wires here.

Agreed, I read "forward fill" as filling in missing arguments from the front, I also misread your example as RGB(1) creating blue, sorry for the confusion. FWIW, I'd describe that as "back filling" or more commonly, simply using default values.

The behavior you describe is precisely the same as the proposal I offered in https://github.com/w3c/css-houdini-drafts/issues/1014#issuecomment-744744849

plinss avatar Dec 16 '20 17:12 plinss

From the June 2020 Houdini minutes it seemed that there was consensus to have a color object, regardless of how it had originally been specified - so that there can be color conversion, color manipulation, and extras like WCAG contrast ratio, gamut mapping, and so forth.

Right, and that should be a Color class (and whatever appropriate subclasses, I trust you and Lea to have the best input on the shape of that API). But it should not be a CSS color class as it's representing a color, not a CSS construct. And it should not be in this module (and arguably should not be a CSS module either as it should also be used in Canvas APIs, etc).

We should be able to convert from the CSS color constructs to a Color object and vice-versa. The Color class should have all the conversion, manipulation, and extra functionality, not the CSS color objects (though I'm happy to add convenience methods to the CSS color classes once we've defined the Color class(es)).

I fear the two concepts have become conflated here.

Note that what @svgeesus and I were concerned about, that people will start using CSSColorValue in lieu of a Color class is already happening: https://github.com/fserb/canvas2D/blob/master/spec/color-input.md

LeaVerou avatar May 10 '21 15:05 LeaVerou

Which I'm still fine with, fwiw. I never understood why we'd want to produce a secondary version of the color classes; either we exclusively put a bunch of useful color manipulation methods over there (leaving the TypedOM variants extremely low-power and requiring conversion back and forth by authors to do useful things) or we put them on both (making it unclear why we have two class hierarchies in the first place).

tabatkins avatar May 10 '21 18:05 tabatkins

Let me try to be clear about my feelings on the matter, I want two things: 1) a color class (or class hierarchy) that is used to represent color values across the entire platform, and has color space conversion, color manipulation functions, etc; 2) A set of CSS OM classes that represent the various CSS color constructs well allowing creation and manipulation of CSS style sheets without resorting to string manipulation and parsing.

If it turns out that we can serve both of those needs with a single class (or class hierarchy), then fine. But they have different goals and different needs as to API surface. Yes there's some overlap, but there's also a lot of orthogonal functionality. My preference is that we design the two independently, and then see how they overlap and if it makes sense to combine them. What I don't want is to see a bunch of compromises in the API surface in order to serve two orthogonal purposes, making one (or both) less suited to task. Especially in the early design stages.

This thread started because of just such an impedance mismatch between the two use cases.

A similar example is DOMMatrix, we have CSSOM classes to represent the various CSS constructs, and a Matrix class to handle the actual math. It didn't make sense to conflate them, it likely doesn't make sense to conflate CSS color constructs and a generic color class either.

plinss avatar May 10 '21 19:05 plinss