w3c / csswg-drafts

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

[css-mixins] Improve ergonomics of `using` by allowing wildcards #10954

Open LeaVerou opened 1 month ago

LeaVerou commented 1 month ago

using is a great first step, but in practice if you actually need access to design system variables using will get extremely unwieldy.

For example, this is what it would look like if you want access to Open Props colors:

@function --foo(--bar) using (
    --blue-1, --blue-2, --blue-3, --blue-4, --blue-5, --blue-6, --blue-7, --blue-8, --blue-9, --blue-10, --blue-11, --blue-12,
    --brown-1, --brown-2, --brown-3, --brown-4, --brown-5, --brown-6, --brown-7, --brown-8, --brown-9, --brown-10, --brown-11, --brown-12,
    --camo-1, --camo-2, --camo-3, --camo-4, --camo-5, --camo-6, --camo-7, --camo-8, --camo-9, --camo-10, --camo-11, --camo-12,
    --choco-1, --choco-2, --choco-3, --choco-4, --choco-5, --choco-6, --choco-7, --choco-8, --choco-9, --choco-10, --choco-11, --choco-12,
    --cyan-1, --cyan-2, --cyan-3, --cyan-4, --cyan-5, --cyan-6, --cyan-7, --cyan-8, --cyan-9, --cyan-10, --cyan-11, --cyan-12,
    --gray-1, --gray-2, --gray-3, --gray-4, --gray-5, --gray-6, --gray-7, --gray-8, --gray-9, --gray-10, --gray-11, --gray-12,
    --green-1, --green-2, --green-3, --green-4, --green-5, --green-6, --green-7, --green-8, --green-9, --green-10, --green-11, --green-12,
    --indigo-1, --indigo-2, --indigo-3, --indigo-4, --indigo-5, --indigo-6, --indigo-7, --indigo-8, --indigo-9, --indigo-10, --indigo-11, --indigo-12,
    --jungle-1, --jungle-2, --jungle-3, --jungle-4, --jungle-5, --jungle-6, --jungle-7, --jungle-8, --jungle-9, --jungle-10, --jungle-11, --jungle-12,
    --lime-1, --lime-2, --lime-3, --lime-4, --lime-5, --lime-6, --lime-7, --lime-8, --lime-9, --lime-10, --lime-11, --lime-12,
    --orange-1, --orange-2, --orange-3, --orange-4, --orange-5, --orange-6, --orange-7, --orange-8, --orange-9, --orange-10, --orange-11, --orange-12,
    --pink-1, --pink-2, --pink-3, --pink-4, --pink-5, --pink-6, --pink-7, --pink-8, --pink-9, --pink-10, --pink-11, --pink-12,
    --purple-1, --purple-2, --purple-3, --purple-4, --purple-5, --purple-6, --purple-7, --purple-8, --purple-9, --purple-10, --purple-11, --purple-12,
    --red-1, --red-2, --red-3, --red-4, --red-5, --red-6, --red-7, --red-8, --red-9, --red-10, --red-11, --red-12,
    --sand-1, --sand-2, --sand-3, --sand-4, --sand-5, --sand-6, --sand-7, --sand-8, --sand-9, --sand-10, --sand-11, --sand-12,
    --stone-1, --stone-2, --stone-3, --stone-4, --stone-5, --stone-6, --stone-7, --stone-8, --stone-9, --stone-10, --stone-11, --stone-12,
    --teal-1, --teal-2, --teal-3, --teal-4, --teal-5, --teal-6, --teal-7, --teal-8, --teal-9, --teal-10, --teal-11, --teal-12,
    --violet-1, --violet-2, --violet-3, --violet-4, --violet-5, --violet-6, --violet-7, --violet-8, --violet-9, --violet-10, --violet-11, --violet-12,
    --yellow-1, --yellow-2, --yellow-3, --yellow-4, --yellow-5, --yellow-6, --yellow-7, --yellow-8, --yellow-9, --yellow-10, --yellow-11, --yellow-12
)

It gets even worse if you want access to other tokens too.

With wildcards, it would become the much more manageable:

@function --foo(--bar) using (
    --blue-*, --brown-*, --camo-*, --choco-*, --cyan-*, --gray-*, --green-*, 
    --indigo-*, --jungle-*, --lime-*, --orange-*, --pink-*, --purple-*, --red-*, 
    --sand-*, --stone-*, --teal-*, --violet-*, --yellow-*
)

And for many other design systems that use some kind of prefix for all colors (e.g. --wa-color-*), it would be even simpler. It would even allow passing all custom properties, by simply doing --*.

tabatkins commented 1 month ago

I'm slightly against this. The use-case is reasonable, but I think we should solve it more directly.

Basically, a function knows exactly what variables it is going to use. There's no need to import every single color; just write the ones you're actually using in your using list.

The problem is that a function doesn't know what theming variables its own called functions will want to use, and so pulling in the entire theme, as in your example, makes sense. This means it would probably end up being common practice to just write using (--*) on all your functions, just in case, and that sucks.

We do want to make sure that an outer function can override a theming variable for the functions it calls (and the functions they call, etc, if they're not further overriden). This sort of theming control is important.

Maybe using variables can crawl up the scope tree to find a valid declaration for the given variabale, bottoming out at the element it's used on? So if an outer function just doesn't mention a given a variable, then an inner function with using (--foo) will look higher up the call tree for that variable. But if an outer function does redefine --foo in its body, then the inner function will see that definition.

Here's an example:

@function --outer() {
  --v1: 10;
  result: --inner();
}
@function --inner() using (--v1: 100, --v2: 200) {
  result: calc(var(--v1) + var(--v2));
}
.foo {
  --v1: 1;
  --v2: 2;
  z-index: --outer();
}

Under the current specced behavior, the z-index is 210 - --inner() sees the --v1 defined by --outer() (10), but doesn't see any --v2 at all, so uses its default value (200).

Under the alternative I describe above, the z-index is 12 - --inner() still sees the --v1 defined by --outer() (10), but since it doesn't see a --v2 from --outer(), it moves up the call tree and takes the --v2 from the element (2).

@andruud, any thoughts?

LeaVerou commented 1 month ago

I'm slightly against this. The use-case is reasonable, but I think we should solve it more directly.

I agree that we should, and there are several open issues to do so. However, even if we solve it for the common case, I think there will always be use cases where you want to pull in multiple properties with a shared prefix.

Basically, a function knows exactly what variables it is going to use. There's no need to import every single color; just write the ones you're actually using in your using list.

The problem is that a function doesn't know what theming variables its own called functions will want to use, and so pulling in the entire theme, as in your example, makes sense.

Or we define that the using lists of each function are separate, a function calling another function doesn't need to specify a superset of using for the child call to work. It’s harder to implement, but much better ergonomics IMO.

This means it would probably end up being common practice to just write using (--*) on all your functions, just in case, and that sucks.

Agreed. One solution could be to simply not allow that, specify that you need at least one character.

We do want to make sure that an outer function can override a theming variable for the functions it calls (and the functions they call, etc, if they're not further overriden). This sort of theming control is important.

I had assumed a function’s parameters shadow any custom properties in using already. If not, they should.

Maybe using variables can crawl up the scope tree to find a valid declaration for the given variabale, bottoming out at the element it's used on? So if an outer function just doesn't mention a given a variable, then an inner function with using (--foo) will look higher up the call tree for that variable. But if an outer function does redefine --foo in its body, then the inner function will see that definition.

I like that.

zaygraveyard commented 1 month ago

Wouldn't CSS Variable Groups (#9992) be a viable solution?

andruud commented 1 month ago

@andruud, any thoughts?

I'm not sure we should have using at all. It's basically "dynamic scoping" (especially your most recent proposal). Getting your variables bound to a "random" place in the call stack sounds like it will make complex call trees unpleasant to reason about, and I don't think that opting in certain variable names to this behavior helps either.

The problem is that a function doesn't know what theming variables its own called functions will want to use

That problem exists because we have using in the first place.


LeaVerou commented 1 month ago

Agreed with @andruud that using feels like a bit of a wart.

What if variable references that the function is not able to resolve are propagated and resolve at the point of calling? Would something like that work?

tabatkins commented 1 month ago
  • We could drop using, treat the element variables as "global", and make them always available in all functions (shadowed by locals, obviously).
  • Or, we could make using apply to the the element variables only. Passing values from one function to the next would then happen in a civilized manner, i.e. with regular parameters only.

Both of these mean that functions can have arguments that one calling context is allowed to control (the element itself), but all other contexts can't. If you write a function that calls other functions, the "global" variables they're grabbing simply bypass you entirely.

Neither of them fix the "dynamic scoping" aspect of the concept, they just make that scoping even less controllable. I'm not sure why we'd want that.

The point of using is to allow theming variables to be passed into a function without having to explicitly list every single one of them in every single function definition and every single call site. In fuller programming languages, all these variables would probably be bound into a single wrapper object for easy passing, or globally referenced but locally redirectable. That latter behavior is basically what I'm trying to achieve with my using behavior suggestion.

Like, check out my Bikeshed usage of with m.withMessageState() and the corresponding definition of withMessageState() - the printing functions all reference the global MessageState object for their config by default, but by using with m.withMessageState(), I can override their config for all printing within this call stack. This isn't a particular unusual pattern, and it's very useful. CSS, tho, is too weak of a programming language to actually implement this natively, but we can reproduce the functionality for a specific use-case, and "reference 100+ theming variables initially set at the root" is exactly the use-case that needs this.

Looked at another way, this is just carrying the custom property inheritance behavior from elements into functions. We already recognize that being able to override a custom property on an entire subtree by changing it on the common ancestor is useful; it would be quite bad if every single element in the subtree had to manually indicate it was inheriting all the custom properties its descendants were using.


The point of adding using was just to make it clearer to authors of functions what variables were available to them, and users of functions what variables are used by the function (without having to inspect the entire body). We could remove it and just instead make all the locally-visible variables at the call site available (so, overridden by function arguments or custom props inside the function body), so it's exactly like custom property inheritance in the tree. I just thought that would be somewhat less good. But I'm not opposed to it, if that's preferred.

I am opposed to (a) not exposing element variables at all, so arg lists have to be enormous and coordinated, or (b) exposing specifically element variables, in a way that can't be overridden at the call site, as it means inlining a function can change its behavior. Maintaining beta reduction is important, I think.

tabatkins commented 1 month ago

What if variable references that the function is not able to resolve are propagated and resolve at the point of calling? Would something like that work?

If I'm understanding this correctly, this is what I just suggested with "remove using and just 'inherit' all the visible custom properties into each function". Right?

LeaVerou commented 1 month ago

What if variable references that the function is not able to resolve are propagated and resolve at the point of calling? Would something like that work?

If I'm understanding this correctly, this is what I just suggested with "remove using and just 'inherit' all the visible custom properties into each function". Right?

I think so, yes. I think that makes a lot more sense.

Q: It would be nice if there was a JS API to add pure computational functions to CSS down the line. Would this preclude such an API?

tabatkins commented 1 month ago

Q: It would be nice if there was a JS API to add pure computational functions to CSS down the line. Would this preclude such an API?

Not necessarily. It just means that, rather than declaring which vars you want, and being given an object containing just them, we'd have to give you an object containing every visible var.

mirisuzanne commented 1 month ago

I like the proposal to remove using and instead allow variables to 'inherit' from further up the call stack. It seems like this may be ready for brining to the group for resolution?

andruud commented 1 month ago

I like the proposal to remove using and instead allow variables to 'inherit' from further up the call stack.

That is "full" dynamic scoping, then. If that's indeed what we want, well OK.

But should mention a good point (made elsewhere) by @mgiuca, that dynamic scoping breeds chaos for closures/higher-order functions. (Imagine passing a function around in a value and have that behave differently depending on where it's invoked.) So I think a resolution to do dynamic scoping, is also a resolution to never do closures or similar in CSS. (That might be OK.)

mirisuzanne commented 1 month ago

That makes sense. I wonder if people have use-cases in mind for that.

LeaVerou commented 4 weeks ago

Dynamic scoping is how CSS variables generally behave though, so it seems like that ship has sailed?

tabatkins commented 3 weeks ago

I don't think what we decide here has any relevance for closures later, actually. Functions as we're defining them simply do not have a meaningful lexical scope - they're defined at the stylesheet level, which doesn't contain anything except global state; importantly it doesn't contain any custom properties. So all non-global references must be either lexical from arguments, or dynamic from the calling environment (the element the value is being set on); that's the only choice.

If we ever introduced closures (property-level function definitions, rather than global), we could, if we chose, distinguish between lexical variables (drawn from the element the function was defined on) and dynamic (drawn from the element the function is executed on). I imagine we'd reuse this using syntax, then, to specify values that are captured at definition time, and everything else (arguments, and any unspecified variables) take from the calling context, as global functions do.