design-tokens / community-group

This is the official DTCG repository for the design tokens specification.
https://tr.designtokens.org
Other
1.54k stars 62 forks source link

Native modes and theming support #210

Open jjcm opened 1 year ago

jjcm commented 1 year ago

We're currently working on native support for tokens internally here at Figma. In our eyes there are two core use cases that stem from customer requests for design tokens:

  1. Token aliasing (i.e. danger-bg -> red-300)
  2. Theming

Currently the spec does not support theming, which at the moment is a blocker for us for full adoption. I'd like to start a thread here on what native mode support would look like for this format. Major shout out to @drwpow for trailblazing some of this with Cobalt-UI, to @jkeiser for turning this into a proper proposal, and to @connorjsmith for comments and critiques.

Here's the proposal we ended up with:

Overview

Modes represent alternative sets of values for a collection of design tokens. For example, one might wish to have a different value for the “background” and “foreground” tokens depending on whether they are in “light” mode or “dark” mode.

This proposal allows the user to define a set of modes that apply to all tokens in the design tokens file, allowing them to have distinct values for each mode.

Herein we’ll use this example:

{
  "$name": "Figma UI Colors",
  "$modes": {
    "light":      {}, // no fallback
    "dark":       {}, // no fallback
    "super-dark": { "$fallback": "dark" }
  },
  "bg": {
    "$type": "color",
    "brand": {
      "$value": "{colors.blue.300}", // light mode falls back to this
      "$modes": {
        "dark": "{colors.blue.500}" // super-dark mode falls back to this
      }
    }
  },
  "fg": {
    "$type": "color",
    "brand": {
      "$modes": {
        "light": "{colors.black}",
        "dark": "{colors.white}",
        "super-dark": "{colors.gray}"
      }
    }
  }
}

In this example, the values for bg and fg for each mode would be:

  light dark super-dark
bg {colors.blue.300} {colors.blue.500} {colors.blue.700}
fg {colors.black} {colors.white} {colors.white}

Defining Modes

A design tokens file may optionally define a set of named modes at the top of the file.

{
  "$name": "Figma UI Colors",
  "$modes": {
    "light": {},
    "dark": {},
    "super-dark": { "$fallback": "dark" }
  },
  // tokens ...
}

The $modes definition is an object at the top level of the design tokens file.

Fallbacks

Each mode may optionally define a $fallback mode, which will be used to determine the value of tokens which do not define a value for the given mode.

Token Values

Design token files may specify different values for each mode, for each token.

    "brand": {
      "$value": "{colors.blue.300}", // light mode falls back to this
      "$modes": {
        "dark": "{colors.blue.500}" // super-dark mode falls back to this
      }
    }

NOTE: this relaxes the requirement that $value is required when modes exist.

Value Resolution

If modes are defined, all tokens must have values for all modes, taking into account fallback and default rules. This means that either $value or $modes (or both) must be defined for all tokens.

The value of a token for mode "m" is as follows:

  1. If the token defines a value for "m" , that value is used for the mode.
  2. Otherwise, if the mode defines a $fallback, the token’s value for the fallback mode is used. The same rules are applied for the fallback mode, so if an explicit value is not defined for the fallback mode, its fallback is used, and so on.
  3. Otherwise, if $value is defined, then that value is used for the mode.
  4. Otherwise, the token is undefined for the mode, which is an error.
mryechkin commented 1 year ago

@equinusocio hard agree on all points 💯

jjcm commented 1 year ago

@equinusocio agree that you can represent a single theme with just aliasing, however approaching it that way does have its issues, with the most major one being no guaranteed interoperability between themes. If you switch from light-theme.css to dark-theme.css, how does it work? How do I as a developer know it will have all of the tokens defined? Is dark-theme.css a set of overrides for light-theme.css, or is it entirely self contained? The issue with ambiguity here is it means people will approach this in two separate ways, which ends up being bad for the standard.

To that point, @c1rrus brings up a great point here:

Personally, I quite like the idea that every DTCG file is self-contained. I think that can make them easier for humans to reason about. It could also make them more portable - e.g. the use-case mentioned above about referencing tokens from another DS's tokens. If you can cherry pick any .tokens.json file safe in the knowledge that it's not going to contain references that might not resolve, then I think that becomes easier.

A self-contained aspect is quite nice for understandability. Not necessary by any means, but it is a nice to have. It means tools can be a bit more proactive around what they expect the file to have. In a multi-file scenario, load order and parsing becomes a bit more tricky. It's still workable for sure, but tricky.

@SorsOps's proposal feels like a bit of a hybrid of both my proposal and a @equinusocio's (simple files with no theming). I'm OK with @SorsOps's proposal, but I will +1 @c1rrus's points about cross-file dependencies and the issue of

A single DTCG file must be allowed to contain references that cannot be resolved within that file

While the spec doesn't have an opinion about this now, if we do pursue something like @SorsOps's approach, it'd be critical to explicitly state this in the spec. That's boils down to the heart of the issue and really my perspective here. Our request isn't "make the spec work for Figma" - this should be a spec that works for everyone, not one specific tool. But equally, we also shouldn't "ignore tools/figma/plugins/third-party implementations", as doing so will fracture the spec into multiple implementations. Themes are an extremely common use case for tokens, and my worry here is the spec not having an opinion will lead to many different approaches to a solution (which we already see today).

At the end of the day I care less about the exact structure/format of how we represent theming. Our requirements are just to have a solution for this, not to have a specific solution. What I don't want to end up having is there being 8 different ways people approach this, or worse still Figma having undue weight here where people align on whatever way we do this in the interim (as it wont have been done with regard to other tools' needs in mind).

dev-nicolaos commented 1 year ago

Reading this thread has been enlightening. I didn't fully appreciate the scope of use cases for design tokens and the huge variety in those use cases scale.

I'm currently in the process of setting up a pipeline to ingest, transform and consume tokens for my company. In the first day of researching/testing tools I saw three different tools use three different ways to represent "modes/themes" (at least two of those approaches have been discussed here). @jjcm's comment sums up my feelings well:

At the end of the day I care less about the exact structure/format of how we represent theming. Our requirements are just to have a solution for this, not to have a specific solution. What I don't want to end up having is there being 8 different ways people approach this,

I know this is a long (and probably exhausting) discussion, but I want to encourage the invested parties to continue pushing towards a consensus. All approaches have drawbacks, but with the amount of thought put in here it seems likely that whatever comes out will be better than if the spec becomes less relevant because its not meeting the needs of the industry and everybody tries to solve it on their own.

equinusocio commented 9 months ago

@equinusocio agree that you can represent a single theme with just aliasing, however approaching it that way does have its issues, with the most major one being no guaranteed interoperability between themes. If you switch from light-theme.css to dark-theme.css, how does it work? How do I as a developer know it will have all of the tokens defined? Is dark-theme.css a set of overrides for light-theme.css, or is it entirely self contained? The issue with ambiguity here is it means people will approach this in two separate ways, which ends up being bad for the standard.

To that point, @c1rrus brings up a great point here:

Personally, I quite like the idea that every DTCG file is self-contained. I think that can make them easier for humans to reason about. It could also make them more portable - e.g. the use-case mentioned above about referencing tokens from another DS's tokens. If you can cherry pick any .tokens.json file safe in the knowledge that it's not going to contain references that might not resolve, then I think that becomes easier.

A self-contained aspect is quite nice for understandability. Not necessary by any means, but it is a nice to have. It means tools can be a bit more proactive around what they expect the file to have. In a multi-file scenario, load order and parsing becomes a bit more tricky. It's still workable for sure, but tricky.

@SorsOps's proposal feels like a bit of a hybrid of both my proposal and a @equinusocio's (simple files with no theming). I'm OK with @SorsOps's proposal, but I will +1 @c1rrus's points about cross-file dependencies and the issue of

A single DTCG file must be allowed to contain references that cannot be resolved within that file

While the spec doesn't have an opinion about this now, if we do pursue something like @SorsOps's approach, it'd be critical to explicitly state this in the spec. That's boils down to the heart of the issue and really my perspective here. Our request isn't "make the spec work for Figma" - this should be a spec that works for everyone, not one specific tool. But equally, we also shouldn't "ignore tools/figma/plugins/third-party implementations", as doing so will fracture the spec into multiple implementations. Themes are an extremely common use case for tokens, and my worry here is the spec not having an opinion will lead to many different approaches to a solution (which we already see today).

At the end of the day I care less about the exact structure/format of how we represent theming. Our requirements are just to have a solution for this, not to have a specific solution. What I don't want to end up having is there being 8 different ways people approach this, or worse still Figma having undue weight here where people align on whatever way we do this in the interim (as it wont have been done with regard to other tools' needs in mind).

Theme switching should not be part of the spec. How to handle themes really depends on the supported platforms, how tokens are transformed and consumed, if there are themes, etc.

The spec should allow strong syntax and aliasing for raw tokens, the token transformation (which is mandatory if you work with multiple platforms) is up to the authors

In my example, considering the web platform, I have two identical css files (because as the author I wrote the token and I know they are the same, with the same keys but different values). Swapping is not an issue and is not part of the spec anyway.

universse commented 9 months ago

This is my very first community involvement here so hopefully it adds something useful to the discussion.

Just a little background, I'm writing a token management and code generation tool that enables fully themeable design system. The focus is pretty much on the web ecosystem right now and I hope to expand it to other platforms in the future.

Let's first focus on conditional token values within a product. As mentioned by many others, these conditions comprise modes and system settings.

In my proposed format, these conditions are also defined as design tokens. They can then be used to generate PostCSS custom media & custom selectors, SASS mixins, Tailwind variants, TypeScript type definitions etc.

Unlike other design tokens which can be nested arbitrarily in groups, condition tokens are defined in a more structured manner.

Each alternative token value is itself a design token, defined using the keyword $set. They can refer to defined condition tokens using the keyword $condition.

{
  condition: {
    color_scheme: {
      light: {
        $value: '[data-color-scheme="light"]',
      },
      dark: {
        $value: '[data-color-scheme="dark"]',
      },
    },
    contrast: {
      standard: {
        $value: '[data-contrast="standard"]',
      },
      more: {
        $value: '[data-contrast="more"]',
      },
    },
    motion_pref: {
      none: {
        $value: '@media (prefers-reduced-motion: no-preference)',
      },
      reduced: {
        $value: '@media (prefers-reduced-motion: reduce)',
      },
    },
  },
  color: {
    primary: {
      $set: [
        {
          $condition: {
            color_scheme: 'light',
            contrast: 'standard',
          },
          $value: '{color.purple.80}',
        },
        {
          $condition: {
            color_scheme: 'light',
            contrast: 'high',
          },
          $value: '{color.purple.90}',
        },
        {
          $condition: {
            color_scheme: 'dark',
            contrast: 'standard',
          },
          $value: '{color.purple.20}',
        },
        {
          $condition: {
            color_scheme: 'dark',
            contrast: 'high',
          },
          $value: '{color.purple.10}',
        },
      ],
    },
  },
}

Translated to CSS:

:root[data-color-scheme='light'][data-contrast='standard'] {
  --color-primary: var(--purple-80);
}

:root[data-color-scheme='light'][data-contrast='more'] {
  --color-primary: var(--purple-90);
}

:root[data-color-scheme='dark'][data-contrast='standard'] {
  --color-primary: var(--purple-20);
}

:root[data-color-scheme='dark'][data-contrast='more'] {
  --color-primary: var(--purple-10);
}

The proposal can be extended to support component variants, a concept that has been adopted by various CSS libraries. There are parallels between the 2 concepts - conditions are visual variations of a product while component variants are visual variations of a component. One is global, the other local.

app conditions
  - color scheme mode:
    - light
    - dark
  - contrast mode:
    - standard
    - more

button variants
  - intent:
    - primary
    - secondary
  - style:
    - filled
    - outline

Just like a semantic token can have alternative values under different conditions, a component token can have alternative values for different component variants.

In CSS, it looks like this:

.button[data-intent='primary'][data-style='filled'] {
  --button-background-color: var(--color-primary);
  --button-border-color: transparent;
}

.button[data-intent='primary'][data-style='outline'] {
  --button-background-color: transparent;
  --button-border-color: var(--color-primary);
}

.button[data-intent='secondary'][data-style='filled'] {
  --button-background-color: var(--color-secondary);
  --button-border-color: transparent;
}

.button[data-intent='secondary'][data-style='outline'] {
  --button-background-color: transparent;
  --button-border-color: var(--color-secondary);
}

Similar to conditions, component variants are defined as design tokens. A component token can have multiple values defined using the keyword $variant.

{
  // component tokens are specified under "component" group
  component: {
    button: {
      // define variants
      $variant: {
        intent: {
          primary: {
            $value: '[data-intent="primary"]',
          },
          secondary: {
            $value: '[data-intent="secondary"]',
          },
        },
        style: {
          filled: {
            $value: '[data-style="filled"]',
          },
          outline: {
            $value: '[data-style="outline"]',
          },
        },
      },
      background_color: {
        $set: [
          {
            $variant: { intent: 'primary', style: 'filled' },
            $value: '{color.primary}',
          },

          // ... other variations

          // combining with $condition
          {
            $condition: { contrast_pref: 'forced' }, // resolves to "@media (forced-colors: active)"
            $variant: { intent: 'primary', style: 'filled' },
            $value: 'ButtonText',
          },
        ],
      },
    },
  },
}

Adopting condition and variant thus creates more opportunities for design system automation. In the context of websites and web applications, since a component's visual styling can be represented almost entirely by CSS custom properties generated from design tokens, the component's core CSS can remain stable across products/frameworks.

/* stable, fully themeable component CSS */
.button {
  background-color: var(--button-background-color);
  border-color: var(--button-border-color);
}

/* generated from design tokens */
.button[data-intent='primary'][data-style='filled'] {
  --button-background-color: var(--color-primary);
  --button-border-color: transparent;
}

.button[data-intent='primary'][data-style='outline'] {
  --button-background-color: transparent;
  --button-border-color: var(--color-primary);
}

.button[data-intent='secondary'][data-style='filled'] {
  --button-background-color: var(--color-secondary);
  --button-border-color: transparent;
}

.button[data-intent='secondary'][data-style='outline'] {
  --button-background-color: transparent;
  --button-border-color: var(--color-secondary);
}

@media (forced-colors: active) {
  .button[data-intent='primary'][data-style='filled'] {
    --button-background-color: ButtonText;
  }
}

Theming across multiple products on multiple platforms can be achieved simply by combining different token sources.

Brand A web:
  - core-tokens
  - web-tokens
  - brand-a-tokens
  - brand-a-web-tokens

Brand B web:
  - core-tokens
  - web-tokens
  - brand-b-tokens
  - brand-b-web-tokens

As the major focus of the spec is platform independence, I understand it is controversial to have platform-specific concepts in the design token format. For a code generation tool, I argue this is necessary as it allows using platform capabilities to the fullest. Overlooking platform-specific concepts, design tokens cannot fully represent all the design decisions within the system. Those instead need to be handled via tooling and source code, thereby reducing some degree of visibility and control.

Last but not least, since the tool is fairly new, I would very much appreciate it if you could try it out and share your feedback. Thank you very much.

jorenbroekema commented 7 months ago

Theme switching should not be part of the spec

Fully agree with @equinusocio with emphasis on "switching", at least I assume that's what the emphasis should be on. How you switch between themes is heavily dependent on the output platform, and I feel like I should point out why with an example.

Let's imagine you have a button with light and dark mode, you might have the option to choose between two ways of outputting the CSS to accompany the way you switch themes in your site:

This example is the one I see most developers think of first:

:root {
  --button-padding: 8px;
  --button-bg-color: #000;
}

:root[mode="dark"] {
  --button-bg-color: #FFF;
}
// or alternatively:
@media (prefers-color-scheme: dark) {
  :root {
    --button-bg-color: #FFF;
  }
}

That combines both modes into a single stylesheet, and following this pattern you would be including all theming options into 1 stylesheet. This is not ideal for performance reasons, your end users are downloading redundant kilobytes of CSS for rules that don't apply, because you can't both be on light and dark mode simultaneously. In the Web world, where initial load is super important for bounce rates (users leaving if sites load longer than 2 seconds etc.), the more ideal approach is to create different stylesheets:

button.css:

:root {
  --button-padding: 8px;
}

button-light.css:

:root {
  --button-bg-color: #000;
}

button-dark.css:

:root {
  --button-bg-color: #FFF;
}

Assume that the amount of CSS rules would be way more than 1, I'm just keeping the example simple, but the amount of KBs you save goes up fast with the amount of theme-specific CSS rules and theme variations you have in your output.

You'll have some utility that will allow theme switching to happen and for the stylesheets to be swapped out at runtime, here's a demo of a run-time themable button which applies this approach. Here's the source code for the stylesheet switcher on theme toggle.

What this means is that the initial amount of KBs is far lower, because you're only loading the CSS relevant for the current chosen combination of themes/modes. Then, upon changing a theming dimension, you load the CSS needed for it on-demand. This minor delay when switching is the tradeoff versus a big delay on initial load, in Web context that is usually very much worth it considering that Web users tend to download your "app" on the fly, often coming from a search engine, and initial load matters a lot for whether they leave your site prematurely.

Now imagine Android or iOS apps, these are downloaded from the app stores, and the downloading is a conscious choice by the user where waiting a couple of seconds doesn't deter them from then using your app. Every variation of the app based on the theming dimensions is downloaded at once, making the switching between themes very cheap. This changes the "initial load" versus "switching delay" tradeoff in favor of the former, it's the opposite when you compare it to Web context. Putting all the themes outputs in the same file (e.g. a single button.swift or button.xml file) probably makes more sense for these mobile platforms, at least when you come at it from this particular performance/UX angle.

Hence a platform-agnostic design token spec should not have an opinion on the theme switching itself. I hope I've managed to make a good argument on why that is, why approaches to theme-switching is heavily platform-dependent.

Edit

As @nesquarx points out after this post below: yes, the theming classification itself, 'what token changes how for what theme' should definitely be something the design tokens spec addresses, I fully agree, just wanted to clarify that to prevent confusion

nesquarx commented 7 months ago

The mechanism and distribution of switching should definitely not be the purview of the tokens, but the theming classification itself, 'what token changes how for what theme', definitely should be the purview of tokens, and specifically - I would prefer a solution better than multiple token files and leaving the theming control on the filename than the tokens themselves.

On Mon, 19 Feb 2024 at 17:52, Joren Broekema @.***> wrote:

Theme switching should not be part of the spec

Fully agree with @equinusocio https://github.com/equinusocio with emphasis on "switching", at least I assume that's what the emphasis should be on. How you switch between themes is heavily dependent on the output platform, and I feel like I should point out why with an example.

Let's imagine you have a button with light and dark mode, you might have the option to choose between two ways of outputting the CSS to accompany the way you switch themes in your site:

This example is the one I see most developers think of first:

:root { --button-padding: 8px; --button-bg-color: #000; } :root[mode="dark"] { --button-bg-color: #FFF; }// or @.*** (prefers-color-scheme: dark) { :root { --button-bg-color: #FFF; } }

That combines both modes into a single stylesheet, and following this pattern you would be including all theming options into 1 stylesheet. This is not ideal for performance reasons, your end users are downloading redundant kilobytes of CSS for rules that don't apply, because you can't both be on light and dark mode simultaneously. In the Web world, where initial load is super important for bounce rates (users leaving if sites load longer than 2 seconds etc.), the more ideal approach is to create different stylesheets:

button.css:

:root { --button-padding: 8px; }

button-light.css:

:root { --button-bg-color: #000; }

button-dark.css:

:root { --button-bg-color: #FFF; }

Assume that the amount of CSS rules would be way more than 1, I'm just keeping the example simple, but the amount of KBs you save goes up fast with the amount of theme-specific CSS rules and theme variations you have in your output.

You'll have some utility that will allow theme switching to happen and for the stylesheets to be swapped out at runtime, here's a demo of a run-time themable button https://lion-example.netlify.app/ which applies this approach. Here's the source code https://github.com/tokens-studio/lion-example/blob/main/adjustAdoptedStylesheetsMixin.js for the stylesheet switcher on theme toggle.

What this means is that the initial amount of KBs is far lower, because you're only loading the CSS relevant for the current chosen combination of themes/modes. Then, upon changing a theming dimension, you load the CSS needed for it on-demand. This minor delay when switching is the tradeoff versus a big delay on initial load, in Web context that is usually very much worth it considering that Web users tend to download your "app" on the fly, often coming from a search engine, and initial load matters a lot for whether they leave your site prematurely.

Now imagine Android or iOS apps, these are downloaded from the app stores, and the downloading is a conscious choice by the user where waiting a couple of seconds doesn't deter them from then using your app. Every variation of the app based on the theming dimensions is downloaded at once, making the switching between themes very cheap. This changes the "initial load" versus "switching delay" tradeoff in favor of the former, it's the opposite when you compare it to Web context. Putting all the themes outputs in the same file (e.g. a single button.swift or button.xml file) probably makes more sense for these mobile platforms, at least when you come at it from this particular performance/UX angle.

Hence a platform-agnostic design token spec should not have an opinion on the theme switching itself. I hope I've managed to make a good argument on why that is, why approaches to theme-switching is heavily platform-dependent.

— Reply to this email directly, view it on GitHub https://github.com/design-tokens/community-group/issues/210#issuecomment-1952337677, or unsubscribe https://github.com/notifications/unsubscribe-auth/AEKS36HOTXOBDX2LPLFVL2TYUM7WZAVCNFSM6AAAAAAWEORCF2VHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMYTSNJSGMZTONRXG4 . You are receiving this because you were mentioned.Message ID: @.***>

equinusocio commented 6 months ago

the theming classification itself, 'what token changes how for what theme', definitely should be the purview of tokens.

Any token that violates the core principles above is considered a "theming token", whose value may change across platform implementations (so it can't be considered part of the source of truth). The first step would be to define what is a constant token (respecting all the principles) and what is a "theming token" which is more flexible.

We should consider that constant/raw design tokens are mandatory in a design system, theming tokens aren't.

Btw this discussion seems dead...

danosek commented 5 months ago

Just for inspiration - I'm using my own system (for now), and this is how I deal with themes.

Non-themeable tokens

"blue-10": {
    "name": "blue-10",
    "humanName": "Blue 10",
    "css": "--blue-10",
    "value": "oklch(0.95 0.02 250)",
    "modifier": "10",
    "def": "0.95 0.04 250",
    "colorSpaces": {
        "oklch": "oklch(0.95 0.02 250)",
        "hex": "#e5f0fc",
        "rgb": "rgb(230 240 250)",
        "hsl": "hsl(210 78% 94%)"
    }
},
"blue-100": {
    "name": "blue-100",
    "humanName": "Blue 100",
    "css": "--blue-100",
    "value": "oklch(0.3 0.07 250)",
    "modifier": "100",
    "def": "0.3 0.07 250",
    "colorSpaces": {
        "oklch": "oklch(0.3 0.07 250)",
        "hex": "#0d2f4f",
        "rgb": "rgb(13 47 79)",
        "hsl": "hsl(210 72% 18%)"
    }
},

Themeable token

"minimal": {
    "name": "surface-blue-minimal",
    "humanName": "Surface Blue Minimal",
    "css": "--surface-blue-minimal",
    "theme": {
        "light": {
            "value": "blue-10",
            "css": "--blue-10",
        },
        "dark": {
            "value": "blue-100",
            "css": "--blue-100",
        }
    },
},

For CSS I then generate something like this (we are using JS to manipulate themes).

:root {
    --blue-10: oklch(0.95 0.02 250);
    --blue-20: oklch(0.89 0.06 250);
    --blue-30: oklch(0.84 0.09 250);
    --blue-40: oklch(0.8 0.11 250);
    --blue-50: oklch(0.56 0.18 250);
    --blue-60: oklch(0.55 0.2 250);
    --blue-70: oklch(0.54 0.19 250);
    --blue-80: oklch(0.48 0.14 250);
    --blue-90: oklch(0.4 0.11 250);
    --blue-100: oklch(0.3 0.07 250);
}

:root,
[data-theme$="light"]:not(:root),
:is(:root[data-theme$="dark"] [data-theme="inverted"]) {
    --surface-blue-minimal: var(--blue-10);
}

:root[data-theme$="dark"],
[data-theme$="dark"]:not(:root),
:is(:root[data-theme$="light"] [data-theme="inverted"]) {
    --surface-blue-minimal: var(--blue-100);
}