w3c / csswg-drafts

CSS Working Group Editor Drafts
https://drafts.csswg.org/
Other
4.48k stars 659 forks source link

[css-values] Proposal: `round()` to a finite scale #11067

Open kizu opened 1 day ago

kizu commented 1 day ago

Background

This is a proposal that I briefly discussed with @fantasai at CSWG F2F in A Coruña this year. The problem it tries to solve came up when I was writing my latest article, so I finally found time to write it.

One of the motivations for this proposal is a problem that was, for example, mentioned by @matthiasott in his talk at CSS Day 2024 (a bit after 39:31), where he argues that using container query units for fluid type, while possible, leads to too many font-sizes on the page, making it not possible to create a harmonic type scale.

But what if we could augment the existing round() to help with this and other similar cases?

Proposal

The current syntax of round() is round(<rounding-strategy>?, A, B?) where A and B are calculations that must share a type and resolve to any can resolve to any <number>, <dimension>, or <percentage>.

My proposal is to change it to round(<rounding-strategy>?, A, B*) — use * instead of ? for the last argument.

When only one value is provided as B, round() will work the same as now.

When multiple values are provided to B, the values would be treated as a scale of the only values that the A should be rounded to.

These values must be the only possible outcomes of the round() function: this is not rounding the value to either of the values in the regular sense, but using the provided values as a finite scale.

If we were to think of a single B, it would represent an infinite linear scale, for example the default 1 argument there represents an infinite 1 2 3 4 N scale, but if we'd want to round to a finite scale, for example, to find the closest prime number to a certain list of them, we could do

round(var(--foo), 1 2 3 5 7 11 13 17 19 23 29 31 37 41 43 47 53 59 61 67 71 73 79 83 89 97 101);

This way, if we'd provide --foo: 10, it will round to the closest prime, resulting in 11. And if we will provide something that is equally close to two values, like --foo: 6, then the <rounding-strategy>? will come into play: we could choose how exactly we'd want to handle cases like it — round things to the closest, or ceil/floor it.

Implementation-wise, this should not be too complex: something like a binary search of the first argument with the provided list could be good enough.

Other use-cases

Aside from the typographic scale and choosing the closest prime number, I remember stumbling upon many different use cases.

One prominent one: regular spacing scale. Various design systems can have a scale like 2px 4px 8px 12px 16px 20px 24px 32px 48px for spacing, and similar to the Matthias's case with fluid typography, it would be great to evaluate some container query length units to the closest value from such a scale.

There were other cases I encountered, but at the moment of writing this proposal I don't remember them: will update if I'll stumble upon them later, and if anyone had them as well — drop them in the comments, I'll update the proposal with them as well.

Current Workaround

Today, the simplest way we can attempt to approach this with the current CSS is by using a rather complicated complex conditional calculation which I described in my article. For the above list of primes (with some lower ones removed), it looks like this:

    --limit: 102;
    --closest-prime: calc(
      var(--limit)
      -
      max(
        min(1, 11 - var(--x)) * (var(--limit) - 11),
        min(1, 13 - var(--x)) * (var(--limit) - 13),
        min(1, 17 - var(--x)) * (var(--limit) - 17),
        min(1, 19 - var(--x)) * (var(--limit) - 19),
        min(1, 23 - var(--x)) * (var(--limit) - 23),
        min(1, 29 - var(--x)) * (var(--limit) - 29),
        min(1, 31 - var(--x)) * (var(--limit) - 31),
        min(1, 37 - var(--x)) * (var(--limit) - 37),
        min(1, 41 - var(--x)) * (var(--limit) - 41),
        min(1, 43 - var(--x)) * (var(--limit) - 43),
        min(1, 47 - var(--x)) * (var(--limit) - 47),
        min(1, 53 - var(--x)) * (var(--limit) - 53),
        min(1, 59 - var(--x)) * (var(--limit) - 59),
        min(1, 61 - var(--x)) * (var(--limit) - 61),
        min(1, 67 - var(--x)) * (var(--limit) - 67),
        min(1, 71 - var(--x)) * (var(--limit) - 71),
        min(1, 73 - var(--x)) * (var(--limit) - 73),
        min(1, 79 - var(--x)) * (var(--limit) - 79),
        min(1, 83 - var(--x)) * (var(--limit) - 83),
        min(1, 89 - var(--x)) * (var(--limit) - 89),
        min(1, 97 - var(--x)) * (var(--limit) - 97),
        min(1, 101 - var(--x)) * (var(--limit) - 101)
      )
    );

This works! But this is not something that is easy to write or maintain by hand.

Alternatives Considered

Initially, I was thinking about either introducing a new function, or looking if we could somehow do this with clamp(). For some reason, I was not thinking about round() as a possible alternative, as I was stuck with it rounding to an infinite scale. But clamp() works very differently from round(), where it keeps the original value if it fits into the provided range. But we're really rounding it to a scale, and it was @fantasai that proposed to think about just augmenting round().

Out of Scope

I think there is something about being able to specify an alternative, non-linear infinite list, maybe in a form of an equation similar to the one in the nth-child, but maybe a bit more complex.

It would be great to be able to round something to a scale like 2 4 8 16 32 64 etc, or be able to specify a fallback if the value falls outside of the chosen finite scale.

In the future, I think these would be nice to have, but I'd propose to have separate issues discussing them and bikeshedding their syntax. A finite scale is a common enough case, and seems to be simple enough to implement, that I'd want us to first focus on it.

Loirooriol commented 1 day ago

Would infinities be automatically included?

round(down, 0, 2 3 5 7 11); /* -infinity or nan? */

Would the list need to be in non-decreasing order, or leave it up to the browser to sort it?

Would the list accept duplicates? I guess it would be a way to have a finite list with one item...

But IMO it can be confusing that providing 2 values results in a 2-values finite list, while providing 1 value results in an infinite list.

It may be better to introduce a new syntax. It could also be useful to round to intervals, e.g.

round(var(--num), [0, 2 .. 3, 4, 5 .. 6])
kizu commented 1 day ago

All good questions!

Would infinities be automatically included?

round(down, 0, 2 3 5 7 11); /* -infinity or nan? */

Hmm, in this case I'd expect 2 — the closest available value even if it is higher than the target. The goal is usually to have a set of “design tokens” and choose only from them, taking the “closest”, so in the regular cases the up/down are for choosing one from two closest items. In this case, the 2 is the only available item, and is the closest.

Would the list need to be in non-decreasing order, or leave it up to the browser to sort it? Would the list accept duplicates? I guess it would be a way to have a finite list with one item...

Hmm, an author in me wants the browser to do the work for me, and do both: remove the duplicates, and then sort the array. Is this a potential performance issue? I imagine 99% of real-world cases won't have too many items there, and if we really don't want people to sort thousands of items, we could have a certain limit in-place.

I remember the toggle() specified in a way where it should not contain duplicates, but it was never implemented by anyone, and I don't know of any other precedents that could help with this.

But the author experience would be for sure much better if the duplicates and non-strict order is allowed.

But IMO it can be confusing that providing 2 values results in a 2-values finite list, while providing 1 value results in an infinite list.

I can see how this could be confusing, yes, but I wonder if this edge case is too unrealistic to try, and solve it. I'd go with it producing a finite list of 1 element, which is, uh, just that value, but better than falling back to infinite list, as it won't ever be something an author wants.

It may be better to introduce a new syntax. It could also be useful to round to intervals, e.g.

round(var(--num), [0, 2 .. 3, 4, 5 .. 6])

Oh, so 2 .. 3 in this case will be something like “if a value falls between these, keep the value intact”, like a mini-clamp() inside this list?

This is an interesting idea, and I can see how introducing a new syntax can help to add things like this one. Although, I am not sure if I remember use cases for this specific case, but if there are some — we can consider it. But maybe this is something that could be potentially solved better with native functions and conditionals?

Many tools could be used for the same job, and I wonder if real-life cases that will need something like that will always want something even more complicated. Like, if the value is higher than 6, then do round(var(--num), 2) — this is easy to do with if() if we'd have number comparisons in it, and could be more expressive than introducing a new syntax to round().

SebastianZ commented 1 day ago

Would infinities be automatically included?

round(down, 0, 2 3 5 7 11); /* -infinity or nan? */

I'd say NaN. Infinitely small or large numbers are unexpected in the given use cases.

Would the list need to be in non-decreasing order, or leave it up to the browser to sort it?

For author's comfort, the UA should do the sorting. Though authors should still be encouraged to sort the values in increasing order.

Would the list accept duplicates? I guess it would be a way to have a finite list with one item...

It should accept duplicates, but rather to be more resilient against mistakes than for providing a way for a single-item list. There's presumably no use case for a list with a single item. And differenciating finite and infinite lists by syntax is less error-prone.

But IMO it can be confusing that providing 2 values results in a 2-values finite list, while providing 1 value results in an infinite list.

It may be better to introduce a new syntax.

As noted above, I agree on that.

Sebastian

Loirooriol commented 1 day ago

the closest available value even if it is higher than the target

I disagree, disobeying the rounding strategy seems very unexpected.

I'd say NaN. Infinitely small or large numbers are unexpected in the given use cases.

The advantage of infinities is that then you can use a min/max/clamp wrapper to set the right limits. Though we could require the author to explicitly add infinities into the list in order to avoid NaN.

Crissov commented 1 day ago

I’m not at all against solving this use case. Maybe round() is indeed the best place to do so.

In #2826 re random(), I suggested a function called choose() or select() to get a single item from a list, but this focused on choosing by index and did not consider choosing by value. However, I think that feature could be added therein just as well.

PS: Contrarily, med() and mode() in #905 and mid() etc. in #4700 naturally considered only the list values, i.e. they supported no needle to compare with.

tabatkins commented 1 day ago

This use-case sounds potentially useful. I'd be willing to spec this if the WG thinks it's worthwhile.

I do think it's probably worth putting this under a new function name, rather than reusing round(); this is kinda rounding but not quite, especially if we allow ranges where the value is unchanged. This also avoids us having to be consistent with round() for the round(down, 0, 2 3 5) case - we can just say that this resolves to 2, and you can manually use -infinity if you want.

kizu commented 18 hours ago

A separate function could work as well, yes.

Another thing that I forgot to mention, but about which I was thinking when initially coming up with this proposal: two other proposals by @LeaVerou :

And another thought that I had: I can see similarities with type grinding, where a registered custom property for an <integer>, with a <number> assigned, converts it into an acceptable value. The current @property does not allow defining the rounding strategy in this case, but I wonder if what I want could've been expressed as a custom type: define a subtype of a <number> or <dimension>, but with a limited possible values, so when assigning to it, it would “choose” the closest. But, I guess, working with a type like that would be rather cumbersome as well: need to register another property, and use as an intermediate step between the value you want to round and where you want to use it.