Open AmeliaBR opened 5 years ago
Could this not use functions and named parameters instead to registerCustomFunction
? Also, I see a golden opportunity here to call the function with the values as direct parameters since the parameter list is already necessarily static.
function registerColorModifier(name, impl) {
registerCustomFunction(name, {
inputProperties: ['--color-space'],
inputArguments: ['<color>', '<percentage-number>'],
returnType: '<color>',
call: impl,
})
}
registerColorModifier("--darker", (styleMap, startingColor, delta) => {
/* convert percentage delta values into numbers */
/* find the correct modifier function based on the color space */
/* return a new CSSColorValue object.
...which first needs to be defined in Typed OM */
})
registerColorModifier("--lighter", (styleMap, startingColor, delta) => {
/* same structure as before, but a different operation */
})
It's slightly more boilerplatey, but it simplifies the model a bit and leaves it a bit more streamlined and easy to use, especially when you're defining a lot of them.
Overall, this looks very close to what I'd put together; great job!
Alternatively, we could stick with the Paint API model: each registered function could be required to have its own class (which maybe extends a superclass that defines the input and return type getters), and the calculations would always use a function with a standard name (calculate, value, or something like that).
Yeah, separate class is my plan. Anything else is just adding complexity to the model without much gain, since one can just inherit from a base class, as you say.
An alternative would be to register the custom function as a regular ident (no -- at the beginning), and then have a single standard wrapper function where the specific function name is the first argument to the wrapper, similar to how paint() works:
I favor defining a -- name directly; I think it looks better and is clear enough. paint(), being specialized for 'display', is well-suited to having a single function name with the author-specified ident being an argument; custom functions, being usable anywhere, are well-suited for their author-chosen names being top-level.
At what level should input argument types and return types be enforced. If they aren't enforced by the parser, what is the fallback behavior?
Enforcing at parser level would be ideal, but it means we have to reparse the stylesheet when a function is registered. We purposely chose not to do that for registered custom properties, so I suspect we'll want to be consistent with that.
In other words, a custom function will be, for most purposes, exactly equivalent to a var()
function; any property using one will be assumed to be valid at parse time, and flip into "invalid at computed-value time" if something is wrong - either a bad return type, or the JS throws, or whatever.
It would be best if we could avoid this, but we'd have to make an explicit decision to go against our previous precedent. I'll def try.
How does this interact with computed values and inheritance?
Same as var() with a registered custom property, basically: filled in as part of the computed-value process.
Could this not use functions and named parameters instead to registerCustomFunction?
This is a really interesting idea; coming up with a named-parameter syntax and exposing that to the JS as an options bag could skip around a lot of the issues with the more limited syntax rules that custom functions are allowed.
There's a bit more detail specific to functions that we want to expose, which are needed for a good --random()
function; you need the ability to specify whether you'll get re-called based on being applied to different properties/elements, in addition to different arguments.
@isiahmeadows Sorry I missed your comment when you made it. So your idea is to skip the Class declaration and just use an options dictionary instead? I don't see any problem with that at a glance, but I was following the pattern of the existing Houdini APIs. Other people could explain better why the class pattern was chosen for the Paint and Layout APIs. That said, your example is fairly close to the custom property API, so maybe that's a better model.
There's a bit more detail specific to functions that we want to expose, which are needed for a good
--random()
function
Ah, yes. I had thought of that, but it didn't make it to my write-up.
Giving the custom function access to the property name shouldn't be too much trouble. There'd need to be another static function that declares the dependency on property name which warns the browser that it can't reuse the returned value across properties. And then pass in the property name as an additional parameter in the JS function.
Similarly, another static function (or a registration option, if we go with the class-free proposal) could request that the inherited value gets passed in to the JS function, while warning that the function needs to be re-calculated when the inherited value changes.
Making the function result dependent on the element is a little trickier, since we can't expose raw DOM objects to the worklet. But maybe each unique element could be represented by some sort of hash value that has no meaning of itself but could be used as a seed to a random function to ensure that you always get the same result for the same element but different results for different elements.
Yeah, thinking on it a bit, I think it would be another set of dependency specifiers, letting you depend on "element identity" or any specific "element attribute".
If you ask for attributes, you get invalidated when those attributes change, and are passed a Map of those attr value/names. (You can probably also register for "*", so you get all attributes; you'd need this to create something attr()
-like, for instance.)
If you ask for "element identity", you get called separately on each element, and get passed, say, a unique integer for each element (with a guarantee that a given element will always give the same integer, and no two elements alive on the page at the same time will give the same integers). That way you can mix the integer into the starting entropy for your --random() function, ensuring you get a different value for each element.
@tabatkins All these "dependency specifiers" are great, but if they don't match what is already implemented by engines, it can be a lot of work to get them implemented. Things like attr
are really specific and only work on content
for before/after
and I would be surprised if the way they work is actually generic enough to hook functions in any property.
So, all in all, while I don't want to be understood as saying that things like random()
aren't worth pursuing, it certainly doesn't feel like it is a v1 requirement to me. Let's keep it simple and iterate.
Another reason the element specifier is problematic is that styles are usually shared across elements to save memory, and if any property value can now depend on the specific element it is applied to, then these optimizations will have to be disabled pretty aggressively.
Plus there is a lot of spec work required to manage expectations as elements get removed from the dom, moved somewhere else, or even transferred into another document. These things seem like a lot of spec details and I'm concerned that this could slow down the main feature by a lot.
As much as I like the idea of eventually being able to polyfill every type of function we might want in CSS, I agree with @FremyCompany that it makes sense to focus on the simpler cases first.
A reasonable division:
Custom Functions Level 1 would cover functions that are context independent & idempotent: you get the same result for the same parameters wherever & whenever it is called.
Maybe extend "parameter" to also include dependencies on other declared properties, since that is already supported in the Paint and Layout APIs (although that does introduce circularity issues that need to be addressed).
Custom Functions Level 2 could address functions that depend on the context, whether that is the inherited value for the property, the name of the property or its allowed types, attributes on the element, or some element-specific identifier.
Maybe extend "parameter" to also include dependencies on other declared properties, since that is already supported in the Paint and Layout APIs (although that does introduce circularity issues that need to be addressed).
No, Paint/Layout have the same styles regardless of what properties they depend on, you just invoke separate painters/layouters for each element. Custom functions would output different styles, tho.
THAT SAID, assuming that custom functions are resolved at variable-resolution time, so they only affect computed values, the entire problem is moot; computed values aren't nearly as shareable as specified values. So there's no problem in having them resolve differently based on the element, any more than there's a problem with em
lengths resolving to different px
lengths in computed style.
I just checked with @bfgeek on the details of Blink's style system, and he confirms this: while specified styles are heavily shared, computed styles are generally only shared when they're "initial"; as soon as you set a property's value to something else, the element will clone off a new computed style object just for itself. So a custom function won't have a noticeable impact here.
OK, thinking through all the implications of Tab's comment, I think I have another item to add to the list of things that won't be tackled until level 2:
calc(100% - 100px)
) or functions that operate on currentColor
.Yeah, that's definitely a v2 feature, since it needs a substantially more complex framework to operate on, expressing that a given value is going to be a length evaluated against some particular other length of an element.
@AmeliaBR Oh yeah that sounds like another fun problem indeed :)
I didn't consider the fact this happened at computation time; in that case it's indeed way more reasonable to perform this per-element in some fashion.
I didn't consider the fact this happened at computation time; in that case it's indeed way more reasonable to perform this per-element in some fashion.
Yeah, custom functions are gonna basically just be JS-backed var()
s. Getting any fancier with them isn't worth it unless we can get really fancy and do things like globally reparsing style when a custom function is registered and we know what its type will end up being. ^_^
LGTM. Note for future self though: we could eventually get rid of the reparse requirement by doing explicit typing for the most common types, something like this:
div {
width: length(--f(x));
}
or going for some form of type inference and fallback values:
div {
border-width: --(f(x) || 0px);
}
(the latter being what I wanted var
to act like initially, so at least I'm kinda consistent with myself)
For type parsing, whatever future solution is chosen, I hope it has a way to apply to variables as well as to functions.
But for our Level 1 Custom Functions, if type checking isn't handled until the function returns a (TypedOM) value, then we don't need a declared returnType
on the class or configuration option object.
Which gets rid of another issue I'd thought of — how to represent functions where the return type is dependent on the input parameter types?
I'm adding this to the next Houdini F2F agenda, to get consensus from the larger group on the idea & the Level 1 limitations to it. I think the main question still to debate is whether to base the API around a class object or around a configuration dictionary.
But for our Level 1 Custom Functions, if type checking isn't handled until the function returns a (TypedOM) value, then we don't need a declared returnType on the class or configuration option object.
Right. It might still be useful to have the option of providing a return type, as a check that allows us to throw if you violate it, but probably we'll just avoid the issue entirely for now.
We have an open issue in CSSWG for having more complex and expressive timing functions. I feel it may be valuable to also have an extensibility point for these as well. Relevant issues: csswg#1012, and csswg#229.
The current idea here is mostly focused on functions that operate on CSS values and take style map as input but I think most of the fundamentals apply to other idempotent pure functions such as easing. So if we decide to do this, I suggest considering to design the API in such as way that it is more generic and usable in both these domain.
@majido I was also thinking about that during yesterday's discussion on easing functions, but it is a much more complex case, because the ”value” returned from the CSS function would need to be another function.
We don't currently have a TypedOM way of representing that easing function. Even web animations just represents them as the string value of the CSS notation. But, it should definitely be added in the future. (It would be nice to be able to use the built-in CSS functions as arbitrary interpolator functions in JS!)
Once there is a TypedOM way to represent an easing function, it's probably a reasonable extension to web animations make it possible to subclass and override the interpolation function with a custom JS f(x)
interpolator. Once that happens, it would also be a natural extension to this proposal: you define a custom CSS function that returns a custom easing function object.
The Houdini Task Force just discussed Custom functions
, and agreed to the following:
RESOLVED: Draft this proposal up as css-functions-api
RESOLVED: TabAtkins and AmeliaBR as editors
flip into "invalid at computed-value time" if something is wrong - either a bad return type, or the JS throws, or whatever.
I'd expect the above situation described by @tabatkins to cause a warning to be emitted to console. but sometimes a function needs to be able to "nope out" elegantly and without triggering a warning.
A feature of Sass that is really useful in custom functions is the ability to return null
to indicate that no return value is desired, causing the declaration it is part of to be invalid/dropped unless handled by another custom function that expects null
as an input. , where-as null
would have no warning if it is declared as an expected return value.
This is excellent. I have some questions!
As far as I know, CSS has no boolean type. But custom functions really need them as an intermediate value to allow custom functions to interoperate. Otherwise developers will end up with some sort of cargo-culted convention of using true
and false
idents but only be able to declare an input as accepting <ident>
which is a much bigger input space.
Can trailing arguments be made optional? Can optional arguments be assigned a default value?
The ability to lazily evaluate input arguments, is required to implement something like conditional execution. E.g. if(cond, if-true-val, if-false-val)
is a function that only evaluates either the second or the third argument based on the value of the first.
I'm curious whether custom functions that receive arguments that other custom functions would receive the output of that function or would receive a production that indicates it is a custom function invocation and could lazily evaluate that invocation. If the former, could the latter be enabled by a different input argument declaration.
So that authors can write generic code that is correctly typed, it seems like there should be some concept of a type generic.
When we put all these ideas together we can write a generic if statement where the conditional checks whether the data being operated on is valid to be used in the if-true
branch and handles that situation gracefully if it's not.
class ConditionalEvaluation {
static get genericTypeArguments() { return ['<T>']; }
static get inputArguments() { return ['<boolean>', CSS.CustomFunction.lazyArgument('<T>'), CSS.CustomFunction.lazyArgument('<T>?')]; }
static get returnType() { return '<T> | <null>'; }
// evaluate the ifTrue condition if the condition is true,
// evaluate the ifFalse condition if it's provided and if the condition is false
// return null, causing the declaration to be dropped otherwise.
conditional([condition, ifTrue, ifFalse], styleMap) {
if(!!condition) {
return CSS.CustomFunction.evaluate(ifTrue);
} else if (ifFalse) {
return CSS.CustomFunction.evaluate(ifFalse);
} else {
return null;
}
}
}
registerCustomFunction("--if", ConditionalEvaluation, "conditional");
A feature of Sass that is really useful in custom functions is the ability to return null to indicate that no return value is desired
A custom function could return an empty CSSUnparsedValue to indicate that it finished correctly, but just didn't output anything.
But custom functions really need them as an intermediate value to allow custom functions to interoperate.
Can you give some example use-cases?
Can trailing arguments be made optional? Can optional arguments be assigned a default value?
Currently no, you have to predeclare your arity. (Like custom paint() functions, you provide an array of grammar strings, which are interpreted as the grammars for comma-separated arguments.) Or declare your grammar to be "*", which opts out of parsing entirely so you can do something more complicated on your own.
This is something we'll fix over time, definitely, and apply it to all the Houdini function things.
The ability to lazily evaluate input arguments, is required to implement something like conditional execution.
You need lazy eval in JS (and I assume Sass) because running code can have side effects. Side-effects aren't possible in this model tho. You can absolutely implement a conditional, but it would be akin to the JS trinary operator.
So that authors can write generic code that is correctly typed, it seems like there should be some concept of a type generic.
Note that, at least for now, custom functions don't have typed output - they're a JS-controlled var()
function, and thus can output anything, and that output is then parsed alongside the rest of the stuff in the property value. The output does have to be a Typed OM value, but that includes CSSUnparsedValue, which is the output type of var()
.
Is there thus still a need for generics?
A custom function could return an empty CSSUnparsedValue to indicate that it finished correctly, but just didn't output anything.
That would have different semantics than what I described. The function would output nothing and the resulting output might still be a legal value for the declaration, whereas a value that contains null would have the entire declaration quietly declared invalid. Both are important use cases.
Can you give some example use-cases?
You're asking why booleans are useful when writing code for separation of different concerns? Like I could write a function that determines if two colors meet some accessibility guidelines for contrast. it would return true or false. Another function (like the --if
function above) can then take that boolean and perform different outputs. The logic for how to handle the color contrast issue might not be the same everywhere so delegating that to the stylesheet author is better than coupling it to the contrast determination function.
Can trailing arguments be made optional?
Currently no, you have to predeclare your arity.
I don't know fully understand what "opts out of parsing entirely so you can do something more complicated on your own." means but it sounds pretty annoying just to be able to omit what should be an optional parameter.
This is something we'll fix over time, definitely, and apply it to all the Houdini function things.
I don't understand why this spec wouldn't drive this change instead of being beholden to it... I suspect it has to do with stuff that seems really clear if you understand how the w3c standards process works but I don't 🤷♂
You need lazy eval in JS (and I assume Sass) because running code can have side effects.
No, this is useful purely from the perspective of functions having constraints on what is a legal input argument. The conditional can verify the value is legal to pass to the function and then only invoke the function if it is.
Overall what I'm trying to understand is what is the mechanism for developers who create custom functions to delegate things like error recovery to the stylesheet author. I've assumed there's no built-in try
-catch
mechanism so that means there needs to be some basic conditionals and branching logic. If CSS isn't going to provide those as a standard library, it will be up to the community to construct a library that can be re-used.
Is there thus still a need for generics?
It would be really great if using functions didn't cause a declaration to stop conforming to normal css declaration resolution thus allowing the cascade for that property to be relied upon instead of falling back to initial
. My understanding was that a return type would help enable that. If so, I think generics are going to be very helpful. If sass is a guide, custom functions will get a lot of use, but I don't see an error recovery story here and I think the cascade should be part of that story without good error recovery, I suspect custom functions will be built with a footgun for authors.
whereas a value that contains null would have the entire declaration quietly declared invalid.
We have something like this in CSS, as the initial value of a custom property, which triggers fallback in the VAR function. I agree that it might be useful to be able to represent that in a function return value.
@chriseppstein
As far as I know, CSS has no boolean type. But custom functions really need them as an intermediate value to allow custom functions to interoperate. Otherwise developers will end up with some sort of cargo-culted convention of using
true
andfalse
idents but only be able to declare an input as accepting<ident>
which is a much bigger input space.
Just to add to that point, it's not like there isn't precedent. Less does this very thing internally IIUC, just making true
and false
just special identifiers after people started using it that way.
That would have different semantics than what I described.
Ah, you didn't describe in enough detail for me to tell which you meant. ^_^ If you just want it to invalidate the property it's found in, throw in the worklet (or I guess return a non-Typed OM value, but throwing will definitely work).
You're asking why booleans are useful when writing code for separation of different concerns?
Yes, CSS doesn't have tests like that, so I was curious. Remember, I have very little experience with Sass actually; also, I responded to this part before I read down to your if()
example. ^_^ Makes sense tho.
I don't know fully understand what "opts out of parsing entirely so you can do something more complicated on your own." means but it sounds pretty annoying just to be able to omit what should be an optional parameter.
It means you just get handed the arguments as a string, and have to parse them on your own. Yes, it is annoying. But see the following point...
I don't understand why this spec wouldn't drive this change instead of being beholden to it...
I'm editor on both specs, so neither's beholden to the other. But I'm also trying to make sure the feature is reasonably implementable quickly, so I'm keeping it hovering around an MVP. There are several additional features I want to add to custom functions that'll also wait for v2 most likely!
But further, optional arguments in CSS functions don't currently have a widespread pattern to follow. The contents of a function are, philosophically, no different from the value of a property, and so generally follow property conventions for optionality on a per-component basis. Coming up with a new, and intended-to-be-consistent, way to handle variable-arity functions isn't something I want to commit to hanging on this proposal's v1. There's a lot of variation between languages on how optional args are handled, after all, and I'm not 100% sure what way would be best for CSS immediately. (Like: named args are probably good, for both readability and writeability?)
No, this is useful purely from the perspective of functions having constraints on what is a legal input argument. The conditional can verify the value is legal to pass to the function and then only invoke the function if it is.
Hm, this is why I was asking for specific use-cases. Can you give me a concrete example of this? I'm pretty sure this case you're describing happens as a side-effect of Sass doing its programming within CSS, and it either doesn't occur in custom functions, or can happen within the JS which already has all the semantics you're talking about.
(I think you're talking about something like --if(--isAngle(var(--val)), sin(var(--val)), var(--defaultSine))
, so you only try to invoke sin()
when the value is a legit type for it, as otherwise evaluating sin(var(--val))
will error when --var is subbed in and automatically make the declaration invalid-at-computed-value-time. If so, then yeah, this doesn't apply, you'd just do that error-checking in JS if you needed to.)
It would be really great if using functions didn't cause a declaration to stop conforming to normal css declaration resolution
Yeah, that would be great, but it's a much longer-term goal, because it's enormously more complicated than just relying on the var()-resolution timing and logic. Happy to chat more on the details of why full "make invalidly-used custom functions invalidate the property they're used in like builtins do" is much harder, but for now suffice to say: omg it's so much harder.
Oh, and responding to an older comment that @isiahmeadows's comment reminded me I'd accidentally skipped over:
using true and false idents but only be able to declare an input as accepting
<ident>
which is a much bigger input space.
You can declare your grammar to be particular idents, so true | false | FILE_NOT_FOUND
works just fine and properly constrains you without you having to do error-checking in the impl of your function.
The contents of a function are, philosophically, no different from the value of a property,
I agree that this is the best way to think about the parameters. But I don't think that means we should ignore the use case of optional parameters or arbitrary-length parameter lists. Instead, we should be considering the syntax and structure in parallel with extending Typed OM to handle the full complexity of CSS values. And yes, that may mean that Level 1 restricts to the simple case.
Yeah, my comment def isn't saying "we're not going to do optional/variadic/named params", just that we haven't done that yet in CSS, so I don't want to block on that while we figure it out. We're definitely going to want something like that, tho.
@tabatkins What is the appropriate venue to propose a specific type of Worklet
be specified?
None, because worklets are tools used by other features, not features in themselves. ^_^
But if you want to talk about potential new Houdini features, open an issue in this repo.
This functions should be imported directly and explicitly from CSS. In other way we receive another global namespace war with conflicts and collisions.
Especially with the recent influx of math functions in css, it would be nice to be able to define simple, recursion-free functions directly in css just to name and parameterize basic calc expressions.
One thing I haven't seen yet in Houdini is a proposal for declaring generic custom functions that modify an input set of CSS tokens to return a new arbitrary CSS value. In other words, custom versions of
calc
,min
, and so on. Evenenv
andvar
functions fit that definition.@TabAtkins has a section on custom functions in his CSS Extensions brainstorm, but it doesn't go as far as an actual proposal.
This proposal was sparked by this thread by Ruth John about custom color modifier functions. Native color modifier functions are a long-requested CSS feature that keeps getting tangled in debates about the best options and syntax and so on. But colors aren't the only area. There are lots of open issues on CSS WG for new funtions, e.g., for a
random()
function or trigonometric functions or conditionals or the long-spec'd/rarely-implementedtoggle()
and so on.So here's a "collection of interesting ideas" for further discussion…
Strawman API:
Defining and registering a custom function would look a lot like the Paint API; this discussion focuses on the unique aspects.
Calculations would happen in a
ValueWorklet
(name to be bikeshod).For now, the ValueWorklet global scope would only need the registration functions and core JS / globals that are always exposed to worklets.
Custom functions would be defined within classes like
The
inputProperties
andinputArguments
getters work the same as in the Paint API, except that I'd expect arguments to be more important than properties (but I've given an example here of a property that you might want to declare once to affect all functions).One issue with
inputProperties
is what to do with cycles, if custom functions can be used in any property. My first instinct is to say "use the inherited or initial value instead", but I haven't figured out the details. If that doesn't work, there may need to be a more explicit way for a custom function to access the inherited value of at least the property where it is being used (so that you could create atoggle()
style function).The
returnType()
getter is new, since we need to define what the output will be for the parser to know if we're using our function in a sensible way. Output is a single CSS Type, defined using the same set of syntax strings as for theinputArguments
method.In my demo, I've created a single class with two different functions that both have the same signature and return type, because why not? That means that the method name would need to be part of the registration function:
Alternatively, we could stick with the Paint API model: each registered function could be required to have its own class (which maybe extends a superclass that defines the input and return type getters), and the calculations would always use a function with a standard name (
calculate
,value
, or something like that).In the CSS, it could be used anywhere the return type is expected:
An alternative would be to register the custom function as a regular ident (no
--
at the beginning), and then have a single standard wrapper function where the specific function name is the first argument to the wrapper, similar to howpaint()
works:f(<ident>, <arguments>#)
The registered function is called whenever either the function arguments or the input properties are changed. It's passed the arguments as an array (in my example code, destructured in the function signature) and then the properties in a map.
Things I haven't thought of yet:
At what level should input argument types and return types be enforced. If they aren't enforced by the parser, what is the fallback behavior?
How does this interact with computed values and inheritance?