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

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

Open
kizu opened this issue Oct 22, 2024 · 12 comments
Open

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

kizu opened this issue Oct 22, 2024 · 12 comments

Comments

@kizu
Copy link
Member

kizu commented Oct 22, 2024

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
Copy link
Contributor

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
Copy link
Member Author

kizu commented Oct 22, 2024

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
Copy link
Contributor

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
Copy link
Contributor

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
Copy link
Contributor

Crissov commented Oct 22, 2024

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
Copy link
Member

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

  • Yeah, duplicates should be fine. There is indeed no reason to have a single-item finite scale; that's equivalent to just clamping.

  • Sorting or not is actually interesting. If we sort, then two values that are expressed in different units might exchange position, if the two units don't scale equally. That may or may not be what the author intended. If we don't sort, we presumably handle misordering the same way that gradients do, by bumping the later one in the list up to the position of the earlier one.

    Whether we sort or not, tho, simple cases can be swapped to the other semantic with strategic use of min()/max(). Tho I suppose defaulting to sorting is easier to handle slightly more complex cases; you can regain the gradient behavior by changing, say, A B C to A max(A, B) max(A, B, C); repetitive but doable. If we default to gradient behavior, tho, regaining the sorting behavior is a lot more complex; I think the simplest way to sort A B C is min(A, B, C) calc(A + B + C - min(A, B, C) - max(A, B, C)) max(A, B, C)), ugh, and it gets worse with 4 values.

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
Copy link
Member Author

kizu commented Oct 23, 2024

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.

@tabatkins
Copy link
Member

Yeah, I don't think either of those issues directly impact this one; they're definitely for different features.

For a name, I'm thinking round-to( <options>? , <input>, <outputs>).

The syntax of <outputs> has a few possibilities. We probably want to be consistent with the other functions and allow bare calculations, which means the list has to be comma-separated (round-to(var(--foo), 2, 3, 5, 7)).

This probably precludes doing literal ranges, tho; we'd need a special syntax to indicate a range. (I suppose in theory we could allow <calc-sum> | <number>{2}, but that's weird.) I guess a keyword could work. Probably we should look for decent use-cases for multiple ranges before doing anything special, tho. (A single range is what clamp() does; is there actually much value in having multiple ranges, or a range combined with single values?)

@Loirooriol
Copy link
Contributor

I mentioned ranges as a possible extension to keep in mind during the design, but we can start with the basic version with discrete values.

@LeaVerou
Copy link
Member

LeaVerou commented Oct 24, 2024

I think there is value in having a way to describe lists of numbers and pass them around, and maybe in the future even — gasp — get elements by index, and this could pave the way.

Also, I don't think we need a new rounding function. Conceptually this still fits nicely within round() — just instead of rounding to a multiple of a certain value, you are rounding to whatever is closest in a list of numbers. If anything, the current syntax could be seen as sugar that generates an infinite list (and I agree infinite lists would be a nice feature, but for later).

There's the open question of whether all lists should live under the same function, with an argument for the type of values:

<number-list> = list(of number, <number>#)

Which would then allow expanding it to allow combining different values, or a dedicated function:

<number-list> = number-list(<number>#)

or

<number-list> = numbers(<number>#)

And then round() becomes:

<round()> = round( <rounding-strategy>?, <calc-sum>, [ <calc-sum> | <number-list>]?)

Being able to express lists of numbers and lengths is also insanely useful for design systems, as there are many scales, and it would be very valuable to be able to a) pass them around as a whole, b) snap a value to one of them c) get the previous/next value from an existing value

@LeaVerou
Copy link
Member

LeaVerou commented Dec 4, 2024

I keep coming back to this, and I have spent inordinate amounts of mental cycles trying to hack this together with CSS's existing primitives (e.g. clamp()) to no avail so far.

I wonder if it would be easier to make progress on this if it were under a new function name, e.g. snap-to() or calc-snap() or whatever.

@tabatkins
Copy link
Member

+1 on snap-to(), imo

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

No branches or pull requests

6 participants