microsoft / fast

The adaptive interface system for modern web experiences.
https://www.fast.design
Other
9.29k stars 597 forks source link

rfc: New Approach to Design Tokens #6678

Closed EisenbergEffect closed 5 months ago

EisenbergEffect commented 1 year ago

đź’¬ RFC

This RFC proposes an alternative approach to Design Tokens. This would be a breaking change from 1.0 but I think it improves the ergonomics of tokens, simplifies the mechanics, and could yield better performance. I also think this would be easy to integrate with the W3C design tokens spec, server-side code, and build systems.

🔦 Context

The main things I'd like to accomplish are:

The central ideas are as follows:

đź’» Examples

What follows are some usage examples, along with a description of what the code would be doing behind the scenes.

Root Systems

// Create a token system
export const system = tokenSystem({
  background: "black",
  get foreground() {
    return this.background === "black" ? "white" : "black";
  }  
});

// Apply a token system to a DOM node.
system.apply(document.body); // add --background and --foreground custom properties to the body

// Use a token in css.
const styles = css`
  .container {
    background: ${system.tokens.background}; /* Inserts var(--background) */
  }
`;

// Bind a token value in the UI
const template = html`
  The background token value is "${system.values.background}". <!-- Displays "black" -->
  <input type="text" :value=${twoWay(() => system.values.background)}> <!-- Enables updating the background in realtime. -->
`;

// Set the background in code.
system.values.background = "yellow"; // updates --background and --foreground custom properties on the body
console.log(system.values.foreground); // prints "black" to the console

Technical Notes

The tokenSystem function returns a strongly typed TokenSystem<T> where:

The tokenSystem factory:

When the system.apply API is called:

Depending on runtime capabilities...

Static Derived Systems

// Create a statically derived token system
export const derivedSystem = system.derive(x => {
  x.background = "red";

  Object.defineProperty(x, "foreground", {
    get() {
      switch(this.background) {
        case "red":
        case "black":
          return "white";
        default:
          return "black";
      }
    }
  });
});

// Apply a derived token system to a DOM node.
derivedSystem.apply(myElement); // add --background and --foreground custom properties to myElement

// Use a token in css (not dependent on the derived system).
const styles = css`
  .container {
    background: ${system.tokens.background}; /* Inserts var(--background). */
  }
`;

// Bind a derived token value in the UI
const template = html`
  The background token value is "${derivedSystem.values.background}". <!-- Displays "red" -->
  <input type="text" :value=${twoWay(() => derivedSystem.values.background)}> <!-- Enables updating the background in realtime. -->
`;

// Set the background in code.
derivedSystem.values.background = "yellow"; // updates --background and --foreground custom properties on the body
derivedSystem.log(system.values.foreground); // prints "black" to the console

Technical Notes

The system.derive API returns a strongly typed TokenSystem<T> where:

The system.derive API passes a prototype-less object to the callback. The callback cannot read from the object at this time, but can set its overrides.

When the callback returns, the system.derive API will:

When the derivedSystem.apply API is called:

I believe this will handle the majority of use cases. It allows us to avoid DOM walking when the developer knows the hierarchy of systems in the experience. It reduces the complexity to simple observables that are used to build style sheets.

Dynamically Applied Systems

// Create a derived system that is dynamically applied
system.derive(x => x.background = "yellow")
      .apply(myElement, { mode: "dynamic" });

// This could be shortcut like this.
system.dynamic(x => x.background = "yellow", myElement);

Everything from statically derived systems applies here, but some additional functionality is layered on top.

When the child is registered with the parent as a dynamic child...

This last set of steps enables systems to be dynamically added between other systems in the DOM hierarchy.

Open Questions

Additional Advantages

Additional Considerations

Next Steps

If we are interested in this, I'd be happy to build it out. However, I don't have the bandwidth to do the perf testing against the current implementation. I would need to rely on Microsoft's core team to handle that. But if we want to explore this, I can handle the rest.

I think the folks we need initial agreement from are:

If Microsoft isn't interested in changing the implementation, I'm still interested in exploring this. So, I would be interested to hear if adaptive-web-components would be interested independent of that.

KingOfTac commented 1 year ago

Some initial thoughts after reading.

I really like where this is heading. I can't speak for Brian on the adaptive-ui stuff, but this already looks significantly easier to apply tokens to code from something like json or an exported design system from tooling like figma.

On the derive vs override + extend, to me derive seems more like a combination of override + extend. If the explicit separation of overriding tokens and extending a system is needed for something specifically, I think the override + extend APIs would be best. If that separation isn't needed than derive would work since the term works for both cases.

I pretty much agree with all of the additional considerations. If Microsoft isn't interested in changing the current implementation then I think this tech could easily find a home in adaptive-web. One of the things we are trying to accomplish is an easy to extend and customize system and components, and this new token tech is already a lot easier to grok than the current system. Additionally this would fit very well with our goals to enable static configuration of a design system.

I'm curious to hear Brian's thoughts on the implications this has for adaptive-ui, but I think this helps us achieve quite a few of the goals we have for that project.

EisenbergEffect commented 1 year ago

I think I might be able to make override/derive/extend all be the same thing. I probably need to play with that a bit if/when I get into the actual implementation work.

EisenbergEffect commented 1 year ago

Regarding packages, we could also keep adaptive-ui and move the token API there along with all the algos but just remove the specific tokens, leaving it up to libraries like adaptive-web-components to supply a particular token system.

yinonov commented 1 year ago

Creates a style sheet and caches it.

to clarify, does it mean token system could apply the same stylesheet of tokens to multiple elements for the price of 1 (adoptedStyles)? to shadow trees as well?

scoping tokens directly to a set of "hand picked" custom elements would help avoiding conflicts when registered under the same design system / custom elements registry. a scenario where different versions of the same library (e.g. components library integrated in a micro-frontend architecture) could require different tokens stylesheet in the same document. that's great!

considering the presented example, the foreground getter can be already be governed soon (with an extended heuristics) by the CSS color-contrast. in addition to that, background toggling between black and white colors actually implies a theme change, which most likely changes the WHOLE color palette references (to darker / lighter shades). Maybe it's worth analyzing a real world scenario first?

Its tokens property is a mapped type, where each property is mapped to a string that contains the CSS var styntax.

if this wraps the vars to provides a convenient way for devs to set the CSS custom property, it sounds like it adds ergonomics on the expanse of adding convolution.

personally I'd rather avoid adding this redundancy

.container {
  background: var(${system.tokens.background});
}

could this feature be leveraged even if using simple CSS? SCSS?

as token system need to programmatically apply a stylesheet, rendering of dependent custom elements will not be blocked to await tokens to be parsed. this will result in a FOUC

EisenbergEffect commented 1 year ago

In response to your questions:

yinonov commented 1 year ago

FOUC - I'm wondering who's responsibility that would be to apply the suggested practices, library maintainers or its consumers? if it's consumers' it might make that over complicated task of simply loading CSS. as the task of loading css is not trivial, it may vary between projects. btw, for us, it backfired when we tried to enforce a practice of loading CSS by side effecting that stylesheet mount within the library imports.

I know this is a technical limitation but it bugs me that by programming it, we're loosing the CSS ability to select elements ahead of time.

anyway, waiting to see how this turns out...

nicholasrice commented 1 year ago

First, I think this all generally sounds like a good set of changes. In practice, I've seen a few projects where the existing DesignToken is assembled into a structure resembling DesignSystem you're proposing and to me, it makes sense as a good organizational tool for design-system authors.

A few thoughts, in no particular order:

  1. I would love to see if the dynamic portion of the system could be installable, or otherwise not bundled when not used. In general, I think most use-cases aren't going to use contextual value calculation and it would be great if those authors didn't have to pay the bundle tax.
    1. I personally like "override" over "derive". The function’s purpose is to "override" the value defined in the original system. While I think "derive" also works, "derive" to me implies a more ambiguous product of the operation.
    2. In my testing, using events to resolve the parent context was less performant than manual DOM walking. How much impact that has on app performance would obviously depend on the frequency of use, but it would probably be worth implementing that resolution portion with a strategy so that algo can be performance-tuned. Doing so also allows adjusting the implementations for non-DOM scenarios (like design tools) that work on different tree-structures.
    3. Tree-shaking ability could suffer with this model because un-used tokens will still be in the system. That could potentially be addressed by creating sets of smaller systems individual components import and apply to themselves. This also probably isn't a huge deal for most scenarios, but good to consider.
    4. As long as the implementation uses fast-element's observable, it will require certain DOM globals to exist during initial script evaluation (I know document, I think a few others but I don't recall specifics atm). So while the implementation may not use DOM dependencies, a few will still need to be available to run the script. DesignToken suffers from this today. Maybe we can make observable a stand-alone package, or an export path that doesn't access DOM globals?
    5. Authors often like to group tokens into categories such as color, sizes, shadows, etc. Is something this architecture could support? I expect it would, but maybe I'm missing why that would be a challenge?
    6. Can the css custom property emitted have collision avoidance? Are these generated and if so, can we have friendly-names somehow to aid debugging? DesignToken currently suffers from this, and it would be nice to avoid that hazard this iteration.

I'm definitely interested in hearing @bheston and @chrisdholt's thoughts, but to me, this looks like a solid evolution of the infrastructure and is IMO going to be more intuitive for the majority of scenarios.

EisenbergEffect commented 1 year ago
  1. Agree on the hypothesis. I think I would first build this with it all bundled together to see how it works out and then see if I can refactor to extract it. I don't know how much code that is going to be or what the level of effort is there. But I think it should be a "nice to have" goal.
  2. I think "override" describes things better as well.
  3. We could definitely factor out the ping strategy to use different approaches. I think we may be able to do some additional optimization there, such as caching on parent nodes with invalidation so we could do a sort of, short walk of the tree, and then fallback to event if a cached node isn't found. But that can all be in a strategy. I do want this to work in Figma if possible, so I think that's important.
  4. I'm less worried about tree shaking because I think you would build the systems you want with exactly the tokens you want. A system like adaptive web components could certainly group things in certain ways and then provide an API for composing the system. I need to think about the best way to support that but it seems doable. (Also, a tool could create the system for you with exactly what you want based on the W3C token json.)
  5. I'll look into the observable implementation and see what the DOM dependencies are there and how we can make that piece in particular smoother outside of the browser.
  6. I think we can enable token grouping. I think the code that builds the system object could take account of this and connect some things up. I need to think about how the TS types would work. Might need a recursive, conditional mapped type or something.
  7. I imagined the generated css custom properties would be derived from the property name. Maybe there can be a prefix options when you create the systems so it prepends that as well. Would that be enough?

Any objection to me doing some prototyping on this? I might have some time this weekend and could see how far I got and what issues came up.

chrisdholt commented 1 year ago

Performance is top of mind for me these days when it comes to how much we should spend on styling, so my greatest concerns here echo much of what @nicholasrice mentioned above. Most of the concerns above are shared and many of the "asks" would be part of a wish list if I had one (such as collision avoidance). Ultimately though, I think we need to be able to do complex things without requiring the same code or complexity in order to do simple things.

On semantics - I like the concept of override and extend but I'm not sure I could understand them as the same thing. I could see a use case for derive if there were some method which took a dependency on a token and then leveraged that to calculate another, but I'm not sure it would be core - seems more like an implementation detail to me of how one implements the tokens.

I think the biggest considerations to work through are in the additional considerations scenario. I think the token architecture should exist in FAST as it's an evolution of the existing architecture. I think the key consideration is whether the algo bits go with it or if that's a separate consideration. While I think they likely will pair nicely with this, it seems odd to pair them together unless we're implementing a system - meaning, I could see a separate @microsoft/fast-design-token package, but I don't know that it makes sense to include the algo's there and then have a token implementation somewhere else. I could see a world where we get a bit more abstract with the core parts of the algorithms and include those for creating tokens which are algorithmically driven, but the current implementation is that all the things are tokens and so I'm not sure I see that breakdown making as much sense as having the algo's stay with some kind of adaptive UI package. I'm open here, but I wouldn't want to include the current implementation of those as I see them as too prescriptive to a specific system.

As a final note, you probably noted I went with @microsoft/fast-design-token - a key consideration here is that fast-tokens implies that we're exporting a set of tokens which I don't think we would be or would want to. The above name would be clearer to me and would likely cause less confusion when considered alongside packages with similar names which do export actual token implementations and values, such as @material/tokens and @fluentui/tokens.

chrisdholt commented 1 year ago

Any objection to me doing some prototyping on this? I might have some time this weekend and could see how far I got and what issues came up.

None at all, I think it's probably necessary to find any dragons. While not final it could help with a baseline of performance or identify more optimization necessary, etc.

nicholasrice commented 1 year ago

Any objection to me doing some prototyping on this? I might have some time this weekend and could see how far I got and what issues came up.

Nope. I think I've still got the old perf branches laying around, so I'll run some tests when you get something working.

bheston commented 1 year ago

While I find this updated structure interesting, I'm not sure it resolves some of the fundamental issues we've had around working with tokens. As I've been actively implementing about three years' worth of thought on evolving the Adaptive UI systems, I am concerned there might be conflicting patterns here. I'd like to pause and understand your immediate motivation more and coordinate our work if this is an area where you'd like to see progress.

EDIT: Perhaps a lot of what I've been working toward can sit on this new model, but I'm not sure based on the limited examples.

Wall of text warning, probably best to have a discussion or two:

My initial perception of system is that it could serve as a complete base design system definition, like that of Fluent UI. I agree with the need for some sort of structure grouping a set of tokens, which I have also started working on in Adaptive UI. Perhaps it's just the example, but is your thought on derivedSystem that it represents something like an "Office" derivation of the base "Fluent UI"? Or is it what you would do for different sections of an app that have a different background color or overridden token value? Or both?

The current fixed adaptive-ui tokens are not desirable, but I've been working toward a better system there. One motivation is to keep them around for easy migration. That's what my latest PRs in AWC have been for. The second is to create a robust model for a default configuration that maintains accessibility requirements while meeting visual desires. I have this in process but have not pushed the branch or PR yet. The final step of this plan is to convert the fixed tokens to json representations (following the W3c model as much as possible) and compiling that into code during build.

The new model I've been working toward I'm still referring to as "style modules". I think those follow the same goals as your example of pairing background and foreground into a system, but I think the modules are more flexible. For instance, building from my previous paragraph, you would join any tokens into a module that is then applied to any component part. A common set would be background, foreground, and stroke color. Make a set for a filled accent component and another for an outlined neutral component and apply them as desired.

I know you had comments on my AWC style modules PR. I haven't pushed any changes there since, but I have some updates based on what I've said above. We definitely need the ability to generate the tokens at build time and not rely on processing a json representation, so whatever part of that you were considering for this rfc I agree would be beneficial.

I've logically separated the model into three parts:

  1. Tokens (or systems) are created, either through a build step that produces output similar to what we manually maintain today, or by parsing json at runtime for fully dynamic scenarios. Or a combination to speed loading but have full control after load.
  2. The modules sit in the middle, they are the definitions for all visual styling that is applied to component parts. A module for corner radius, colors, type, whatever is needed to organize and convey your design. It's essentially a graph of declared visual choices.
  3. I've been referring to this step as the "style renderers". They convert the definitions of a module into styles. Here's where the binding update might come into play, but for a declarative system to work I believe we must move away from a model where we write bindings into css. That is, we cannot have a component expect that foreground exists.

Style renderers could produce full style sheets like we get through css right now, or there could even be an implementation that uses the new CSS typed object model and avoids parsing css. Finally, these renderers would be able to apply to any components, not just FASTElements.

The performance of the existing token model has been really good, and I'm not sure how a model that continues to use observable binding would be different. In most app scenarios the current derived tokens should not be evaluated more than once anyway, though I have seen some issues around default values that I've been hoping to resolve with the model I described here. Of course, if the tokens were declared as all fixed values, none of that would happen at load.

EisenbergEffect commented 1 year ago

@bheston I'd prefer not to bring all those other topics into this thread. We need to keep this focused on the model for creating tokens independent of adaptive UI.

For tokens, I'm just proposing a simpler runtime implementation that uses our existing reactivity infrastructure better. I think that will improve perf and reduce code/complexity. I also think it will be more ergonomic to work with.

Don't get hung up on foreground and background. I just used that to show simple and computed properties. The system could contain hundreds of interdependent properties if needed, some of which could be simple values and some of which could be computed values based on complex algorithms. It's just the view-model pattern with some special implementation changes in order to map it better to design token scenarios. That pattern can scale to any level of complexity.

A "derived system" could be the Office scenario, yes. But it could also be any runtime system based on another system where you need to change the rules or values. There's no difference in the model or implementation between those cases, which is something I see as a software design advantage.

bheston commented 1 year ago

I mention the Adaptive UI points not to complicate this issue, but because the work is very much related. I understand we can improve the performance and usability of the underlying tokens infrastructure, but it’s not the biggest blocker I see right now as ultimately the goal of what I’ve been working on is to remove the need to manually write this code at all.

It’s important to note that “Adaptive UI” to me refers to everything about styling components, from design definition to element application. It’s not only for “adaptive” algorithms and could be used with a completely static definition (though I believe there are strong reasons this will continue to not meet UX needs).

Strengths

I'm all for improving performance and runtime mechanics. I've had trouble debugging the token infrastructure, so it would be nice if this were both simpler and if there was a way to understand what's going on. In practice on simple experiences there shouldn't be that many "changes" happening in the tokens, but somehow that's easy to make happen.

Having a container for tokens (the system) is imperative. I had to build something for this in the Figma designer and was migrating that up to adaptive-ui.

It's also great that the css property names will be created from the token object property name. That's been a lot of work to keep in sync, and ultimately unnecessary on the code side, aside from marking a token as not rendered for css.

For consideration

Regarding “override”, “extend”, and “derived”; “derived” is already used within the token system to mean a token value is calculated based on other tokens. I interpret that word to imply some sort of computation rather than simply changing values.

I don't understand the difference of dynamic mode. Is this so a component can be removed from DOM and added under a different parent, which might have a different system applied?

The current design token infrastructure separates generic tokens from css tokens. Only css tokens are rendered in a style sheet. Right now we control that by not setting the variable name for non-css tokens. Perhaps something as simple as a private member (preceded with an underscore) could signify that. This is particularly useful with derived tokens.

Grouping is useful, as Nick mentions. I’m not sure how it should affect the generated name though. For instance, there might be a number of things grouped under “color” but having all the tokens named “color-whatever” isn’t always desirable. Really, additional metadata accompanying the tokens, for instance, “description”, following the W3C model. I understand this is not core need for the implementation, but it would be nice to not have to bolt this on somehow.

Further, to have a reliable way to determine what type of value a token represents. There are some potential complications here depending on how much reuse of an existing system we can allow. For instance, a token value might be a plain color or a gradient. Both are valid for use as a fill but are different value types. This is a limitation today because a token might be declared as a Swatch and there’s no way to provide that gradient value aside from building a GradientSwatch which feels forced. There have been other cases with simple types as well.

One challenge with the setup today is that it’s very verbose when dealing with “sets”. A “set” is common for something like rest, hover, active, and focus states. Because of the need for css tokens in order to render values, we need to define those individual tokens everywhere. Perhaps this is a usage of the grouping model, where a set is just a group of tokens. Contrary to my example above, this would be a case to impact the generated name, like “my-color-set” plus “-rest”, etc. Here’s an example where I had to stitch these tokens back together. This is further complicated when using adaptive algorithms due to the desire for more parameter tokens as well as a “recipe” token to cache the calculated value.

I ended up teasing this apart, but the static derived example initially implied to me that in order to apply the values from the derivedSystem I also need to bind to the derivedSystem. What made me realize this was incorrect was the note about using the same instance in tokens but that led me to this next thought.

I find some challenges with this model when it comes to extending a system but I think they can be remedied by some changes to the example. If this makes sense, I think this model will work. From the perspective of implementing a single design system, the primary design token export would be just system, which all component styling would refer to. If that system were based on any other systems, those would be the baseSystem and system would be an override or extend of that. This way if the system added any tokens, the component styling could make use of those without having to mix styling from multiple systems, which while possible seems confusing.

Regarding the packaging of design tokens and styling algorithms, I’d like to keep these separate. I would like to see the token work go into a separate package than fast-foundation as it makes the intent of both packages clearer. I see the token work as an infrastructure, and while I see great strengths in using it through adaptive-ui based on my description of that intent above, I don’t see the algorithms as fundamental to the token system.

Replies

@yinonov, what you say about the redundancy of background: var(${system.tokens.background}); is exactly what adaptive-ui is solving right now. The larger theme here is that manually managing css is tedious and problematic. Proper tooling will be much better at managing styling decisions. Keep an eye on what I'm still referring to as "modular styling". The initial work is only the first step, but you can see that also cleans up the imports issue Rob mentioned.

chrisdholt commented 1 year ago

Having a container for tokens (the system) is imperative.

@bheston can you clarify this for me? Does this mean that having a DOM Element as a container for the system (similar to a prior concept of DesignSystemProvider)? One of the biggest strengths IMO is the ability to use a component without unnecessary DOM.

bheston commented 1 year ago

Having a container for tokens (the system) is imperative.

@bheston can you clarify this for me? Does this mean that having a DOM Element as a container for the system (similar to a prior concept of DesignSystemProvider)? One of the biggest strengths IMO is the ability to use a component without unnecessary DOM.

@chrisdholt, I wasn't referring to DOM, I meant system in Rob's example, the output of tokenSystem as the container. At one point I've had something as simple as const tokens: Map<string, CSSDesignToken<any>> = new Map();.

EisenbergEffect commented 1 year ago

I've been working a bit on implementing this here and there. I'm about 80% done. A few notes:

The main thing I need to finish is the core logic of the overrideAt code flow. Then I need to do a bit of refactoring to make some things more modular and overridable, such as enabling different strategies for locating systems in the node hierarchy.

This isn't my main focus now. I'm primarily working on the new template compiler, which I'm super excited about. But I'm working on this a bit here and there. Probably a few more weeks, especially to get some tests in place.

derekdon commented 8 months ago

Following this one with interest. Any updates?

janechu commented 5 months ago

Unfortunately due to our repositioning of the FAST project as of #6955, we will no longer be focusing on DesignTokens, similar work may exist in the Fluent UI project so for anyone interested that may be a good place to look for design system related work. The web components package is built on @microsoft/fast-element.