tokens-studio / sd-transforms

Custom transforms for Style-Dictionary, to work with Design Tokens that are exported from Tokens Studio
MIT License
185 stars 28 forks source link

Ability to group CSS Variables under multiple selectors #227

Closed nareshbhatia closed 9 months ago

nareshbhatia commented 9 months ago

What feature would you like?

I would like to generate CSS Variables so that they are grouped under 3 selectors:

:root {
  --color-black: #000000;
  --color-white: #ffffff;
  ...
}

.theme-light {
  --color-bg-default: #ffffff;
  ...
}

.theme-dark {
  --color-bg-default: #000000;
  ...
}

However a configuration like the one below, puts everything under :root:

const config = {
  source: [
    'global/global.json',
    'mode/light.json',
    'mode/dark.json',
  ],
  platforms: {
    css: {
      transformGroup: 'tokens-studio',
      buildPath: '../output/',
      files: [
        {
          destination: `vars.css`,
          format: 'css/variables',
        },
      ],
    },
  },
};

I know that file has a option called selector, but is it possible to apply it selectively to source files?

Would you be available to contribute this feature?

jorenbroekema commented 9 months ago

Admittedly this is quite difficult to do without a lot of knowledge about style-dictionary, and using its internal functions. I will provide an example code snippet below to show you how you might be able to achieve it, with some caveats.

Before I show how you might achieve this, I would challenge you a bit. In my opinion, combining all different theme options in a single stylesheet is not ideal from the perspective of performance. You'll have your end user load the entire stylesheet for all your themes even though they are only using/showing one theme, so that's a lot of unnecessary kb's over the wire.

My recommendation is that you output a separate CSS file for each theme possibility, and dynamically load the correct stylesheet based on the end user's theme preference. This makes initial load much faster, with only a small delay upon a live theme switch by the user. Example of this approach, with multi-dimensional theming (so many options)) https://github.com/tokens-studio/lion-example -> live demo

Furthermore, the way Tokens Studio approaches theming right now is that you do a completely separate run of style-dictionary for each theme, so then combining that with putting everything into a single file is a bit hard to accomplish, because you have to manually do those style-dictionary runs, only partially, and extract the tokens and format them into a single document yourself, this requires a fair bit of style-dictionary knowledge and javascript chops to accomplish, but here it goes (assuming one dimensional themes btw, but possible to extend to multi-dimensional theming as well).

Really important to note is that it probably does not work well with outputReferences yet, hence why it's set to false in the snippet below. Expected to work better in style-dictionary v4 though..

sd-transforms gives a few reference warnings for references in the typography tokens in theme.json for fontWeights, but they seem to be false positives and can be ignored? Probably a bug somewhere :\


const { registerTransforms } = require('@tokens-studio/sd-transforms');
const StyleDictionary = require('style-dictionary');
// private API, not recommended atm, probably will change in style-dictionary v4
const createDictionary = require('style-dictionary/lib/utils/createDictionary.js');
const { promises } = require('fs');

registerTransforms(StyleDictionary, { / options here if needed / });

const { formatHelpers } = StyleDictionary; const { fileHeader, formattedVariables } = formatHelpers;

async function run() { const $themes = JSON.parse(await promises.readFile('$themes.json', 'utf-8')); const configs = $themes.map(theme => [theme, ({ source: Object.entries(theme.selectedTokenSets) .filter(([, val]) => val !== 'disabled') .map(([tokenset]) => ${tokenset}.json), platforms: { css: { transformGroup: 'tokens-studio', }, }, })]);

// Now we need to start gathering all the tokens, source tokens and theme specific tokens // so that at the end, we can write everything to a single CSS file under seperate selectors. let fileContent = fileHeader({}); const sourceTokens = []; const themeSpecificTokens = {};

configs.forEach(([theme, cfg]) => { themeSpecificTokens[theme.name] = []; const sd = StyleDictionary.extend(cfg); sd.cleanAllPlatforms(); // optionally, cleanup files first.. const exportedTokens = sd.exportPlatform('css'); const dictionary = createDictionary({ properties: exportedTokens });

dictionary.allTokens.forEach(token => {
  const filePath = token.filePath.replace(/\.json$/g, '');
  if (theme.selectedTokenSets[filePath] === 'source') {
    // source tokens should go into :root selector
    sourceTokens.push(token);
  } else if (theme.selectedTokenSets[filePath] === 'enabled') {
    // theme specific tokens should go into .theme-foo selector
    themeSpecificTokens[theme.name].push(token);
  }
});

});

// Create the CSS variables, reuse stuff from StyleDictionary, but this is a bit difficult right now due to // usesReference and getReferences utilities being hard-coupled/bound to dictionary object rather // than pure reusable functions. This should be made easier in style-dictionary, to reuse them.. maybe in v4! // Should still work if you don't use outputReferences (so, set to false) though.

fileContent += :root {\n; fileContent += formattedVariables({ format: 'css', dictionary: { allTokens: sourceTokens }, outputReferences: false }); fileContent += \n}\n\n;

Object.entries(themeSpecificTokens).forEach(([themeName, tokens]) => { fileContent += .theme-${themeName} {\n; fileContent += formattedVariables({ format: 'css', dictionary: { allTokens: tokens }, outputReferences: false }); fileContent += \n}\n\n; });

await promises.writeFile('output.css' , fileContent, 'utf-8'); }

run();



Can be ran with the following tokenset, for me this gives the correct output.css file:

[tokens-themed.zip](https://github.com/tokens-studio/sd-transforms/files/13413547/tokens-themed.zip)

Tested with:
- @tokens-studio/sd-transforms v0.12.0
- style-dictionary v3.9.0
nareshbhatia commented 9 months ago

Thank you @jorenbroekema for a detailed response.

My recommendation is that you output a separate CSS file for each theme possibility, and dynamically load the correct stylesheet based on the end user's theme preference.

I would much rather do this! I was doing the single CSS approach just because that's what the multi-dimensional theming example did in the README. Perhaps the lion example should also be mentioned in the README with the tradeoffs.

Thanks you, again!

nareshbhatia commented 9 months ago

@jorenbroekema, actually my comment above is confusing. I was under the impression that you were suggesting a separate CSS file per theme dimension, e.g. core.css, light.css and dark.css. But looking at the lion example, it generates a fully standalone file for every dimension combination, e.g. button-business-blue.css, button-business-green.css, etc.

Now this is exactly what the example in the README does too. That code is actually a bit simpler. Is the lion example trying to do anything different that I am missing?

jorenbroekema commented 9 months ago

Is the lion example trying to do anything different that I am missing?

So, the examples in the README indeed create a CSS file per theme combination. If you use single-dimensional theming and not multi, then it's a CSS file per theme (there is no concept of theme combinations in this case). It's in line with the Lion example, but the code in the lion example is a bit more complex because there I also make heavy use of splitting files further using Style-Dictionary filters, to ensure that my component tokens output end up in the component folders in my design system repository. So all the button related tokens go into /button/<theme-combination>.css, all datepicker related tokens would go into /date-picker/<theme-combination>.css etc. This is a little bit more advanced and catered to component tokens specifically, but perhaps it does make sense to document such an example in the README in this repository as well?

nareshbhatia commented 9 months ago

Aha, that makes it crystal clear!

  1. README example I referenced is multi-dimensional theming and creates a CSS file per theme combination
  2. Lion example is single-dimensional theming and creates a CSS file per component/theme combination

My use case matches the first bullet above, so I will follow the README example.

As far as README goes, I think the following content should be added:

Themes: complete example section:

Multi-dimensional Theming section:

Also add a general comment at the top that it is better to create standalone CSS files per theme combination instead of mixing up themes in a single file.

In addition, add a README to the lion example:

jorenbroekema commented 9 months ago
  1. Lion example is single-dimensional theming and creates a CSS file per component/theme combination

No, Lion also uses multi-dimensional theming, it has two dimensions, brand and color.

I had trouble building this app. Seems like the package-lock.json doesn't work. If I try to rebuild it, there were some dependencies conflicts. Would be good to fix that.

Can you share what the issue is that you ran into and which version of NPM and NodeJS you're using?

I agree with your suggestions to improve the README's. If you'd like to contribute it, would be highly appreciated, I'll tag this issue as documentation enhancement in the meantime.

nareshbhatia commented 9 months ago

Hi @jorenbroekema, I will submit a PR to update the README here.

As for the lion example, I am running node 18.16.0 & npm 9.5.1.

Simply running npm ci gives the following error:

npm ERR! code ERESOLVE
npm ERR! ERESOLVE could not resolve
npm ERR!
npm ERR! While resolving: rollup-plugin-import-css@3.3.4
npm ERR! Found: rollup@4.0.2
npm ERR! node_modules/rollup
npm ERR!   dev rollup@"^4.0.2" from the root project
npm ERR!   peerOptional rollup@"^1.20.0||^2.0.0||^3.0.0||^4.0.0" from @rollup/plugin-dynamic-import-vars@2.0.6
npm ERR!   node_modules/@rollup/plugin-dynamic-import-vars
npm ERR!     dev @rollup/plugin-dynamic-import-vars@"^2.0.6" from the root project
npm ERR!   2 more (@rollup/plugin-node-resolve, @rollup/pluginutils)
npm ERR!
npm ERR! Could not resolve dependency:
npm ERR! peer rollup@"^2.x.x || ^3.x.x" from rollup-plugin-import-css@3.3.4
npm ERR! node_modules/rollup-plugin-import-css
npm ERR!   dev rollup-plugin-import-css@"^3.3.4" from the root project
npm ERR!
npm ERR! Conflicting peer dependency: rollup@3.29.4
npm ERR! node_modules/rollup
npm ERR!   peer rollup@"^2.x.x || ^3.x.x" from rollup-plugin-import-css@3.3.4
npm ERR!   node_modules/rollup-plugin-import-css
npm ERR!     dev rollup-plugin-import-css@"^3.3.4" from the root project
npm ERR!
npm ERR! Fix the upstream dependency conflict, or retry
npm ERR! this command with --force or --legacy-peer-deps
npm ERR! to accept an incorrect (and potentially broken) dependency resolution.

However deleting package-lock.json and then running npm install works just fine. I can then also run the other scripts in package.json. So looks like package-lock.json may have some issues with my node/npm versions.

nareshbhatia commented 9 months ago

One more question for you @jorenbroekema:

In the lion example, you extend all components using adjustAdoptedStylesheetsMixinwhich in turn dynamically loads the sheet into the shadowRoot of the component.

Do you have a good example of dynamically loading stylesheets for Tailwind? In this case I assume that the tailwind config remains unchanged but only the CSS Variables need to be switched at the :root level. The closest approach I have seen is in this article where each CSS variable is set into the root object. Is there a better approach?

// src/themes/utils.ts
import { themes } from './index';
...
export const applyTheme = (theme: string): void => {
  const themeObject: IMappedTheme = mapTheme(themes[theme]);
  if (!themeObject) return;
  const root = document.documentElement;
  Object.keys(themeObject).forEach((property) => {
    if (property === 'name') {
      return;
    }
    root.style.setProperty(property, themeObject[property]);
  });
};
jorenbroekema commented 9 months ago

Simply running npm ci gives the following error:

This is fixed now, I pushed a new commit. Rollup went to v4, and one of the plugins still had compatibility only for 1, 2 and 3, they updated this in their latest patch though so after bumping it, the error goes away.

Do you have a good example of dynamically loading stylesheets for Tailwind?

I don't personally use tailwind so I don't really have an example or an answer to your question. As far as I know however, you provide a tailwind config as a JS file and you run the tool to create a CSS file which is then consumed by your app. You could probably make it create a tailwind CSS output file for each theme, and adopt the same strategy as I do in lion-example of dynamically switching the stylesheets when the theme changes.

Note that you would need to check that you only switch the tailwind stylesheets that contain theme-specific stuff, so all the tailwind utility classes that don't change, try not to have those reload when you switch themes, that would be a lot of unnecessary kilobytes over the wire for the end user. Not sure if this is easy to do with tailwind, splitting theme specific stuff from tailwind core stuff.