material-theme-builder icon indicating copy to clipboard operation
material-theme-builder copied to clipboard

Make the theme builder open source

Open isochronous opened this issue 1 year ago • 4 comments

What the title said. Here's my situation.

We have 200+ clients, all with their own vanity themes. The themes are currently defined in some .scss files, like so:

$primary-color: #00FF00;
$accent-color:#FF0000;
$tertiary-color: #0000FF;

@include theme-roller($primary-color, $accent-color, $tertiary-color);

Where theme-roller is obviously a mixin that does the actual theme generation with @angular/material mixins.

Well, we'd like to use these .scss files to programmatically generate a material 3 theme for each customer. And, amazingly, as far as I've been able to find, there's currently no way to do this programmatically with the crucial (for our customers) "Exact color match" option. The only tool I know of for generating themes programmatically (the @angular/material "m3-theme" schematic) does not offer said option. And even more amazingly, there's no source code that I can find on how this tool implements that option, so I can't even modify the m3-theme schematic to include it myself.

So, yeah, publishing some source code would be really helpful for those of us who would like to know how this tool is doing what it's doing.

isochronous avatar Oct 24 '24 15:10 isochronous

This should get some more heat.

Harm-Nullix avatar Nov 13 '24 08:11 Harm-Nullix

What the title said. Here's my situation.

We have 200+ clients, all with their own vanity themes. The themes are currently defined in some .scss files, like so:

$primary-color: #00FF00;
$accent-color:#FF0000;
$tertiary-color: #0000FF;

@include theme-roller($primary-color, $accent-color, $tertiary-color);

Where theme-roller is obviously a mixin that does the actual theme generation with @angular/material mixins.

Well, we'd like to use these .scss files to programmatically generate a material 3 theme for each customer. And, amazingly, as far as I've been able to find, there's currently no way to do this programmatically with the crucial (for our customers) "Exact color match" option. The only tool I know of for generating themes programmatically (the @angular/material "m3-theme" schematic) does not offer said option. And even more amazingly, there's no source code that I can find on how this tool implements that option, so I can't even modify the m3-theme schematic to include it myself.

So, yeah, publishing some source code would be really helpful for those of us who would like to know how this tool is doing what it's doing.

It's already open source, actually. Unless I'm misunderstanding your question.

The actual generator algorithm is available at material-foundation/material-color-utilities.

garrett-from-chainlift avatar Nov 17 '24 17:11 garrett-from-chainlift

You can get a basic theme using that package, and yes that is used, but the theme builder does do a lot of logic (it seems) that is different then the basic usage of the package. I myself have been fiddling to reproduce the exact theme for a year without success

Harm-Nullix avatar Nov 19 '24 15:11 Harm-Nullix

You can get a basic theme using that package, and yes that is used, but the theme builder does do a lot of logic (it seems) that is different then the basic usage of the package. I myself have been fiddling to reproduce the exact theme for a year without success

I've encountered a similar problem. To be perfectly honest, I decided to give up on reverse engineering the exact tone values the theme generator pulls, because you're right; there are inconsistencies. For example, the Figma theme builder forbids users from truly setting chroma to 0. If you attempt, it'll reset to ~2 after you close out. And m3 apps produced by Google directly always seem a bit more vibrant than those created from the public tools.

I suspect the actual logic of the theme generator relies on a few overrides and niche rule exceptions that aren't publicized or baked into the publicly-accessible theme builders.

Anyway, you've mentioned "basic usage." Are you referring to calling the themeFromSourceColor or themeFromImage methods? If so, try this solution. It lets you pass in your key colors, generate tones for each from 1 to 99 (100 is always pure white and 0 is pure black so i don't bother including them, and the tones are really all you need because the 'perceptually accurate' tone scale is what really makes the system unique).

(All I know is JS/TS btw so apologies if this isn't helpful for the platform you're building with)

1. I start with an object containing my key colors.

I like to do it with my custom colors already baked in. Here's an example of doing it with state in React, but it can be a normal const variable too.

const [palette, setPalette] = useState({
    primary: '#035eff',
    secondary: '#badcff',
    tertiary: '#00ddfe',
    neutral: '#000107',
    neutralvariant: '#3f4f5b',
    error: '#dd305c',
    warning: '#feb600',
    success: '#0cfecd',
    info: '#175bfc',
  });

2. Then, I use this function to generate tones from 1-99 for every key color and save them as camelCased key:value pairs.

const updateTheme = useCallback(async (palette) => {

    class TonalSwatches extends TonalPalette {
      constructor(hue, chroma) {
        super(hue, chroma);
        var swatch = TonalPalette.fromHueAndChroma(hue, chroma);

        for (let i = 1; i <= 99; i++) {
          this[`_${i}`] = hexFromArgb(swatch.tone(i));
        }
      }
    }

    Object.keys(palette).forEach((key) => {

      var argb = argbFromHex(palette[key]);
      var hct = Hct.fromInt(argb);

      var tones = new TonalSwatches(hct.hue, hct.chroma);

      // map the tones from each color group to a swatch name

      switch (key) {
        case 'neutral':
          setTheme(prevTheme => ({
            ...prevTheme,

            light: {
              ...prevTheme.light,
              background: tones._99,
              onBackground: tones._10,
              surfaceDim: tones._87,
              surface: tones._98,
              surfaceBright: tones._98,
              surfaceContainerLowest: 'white',
              surfaceContainerLow: tones._96,
              surfaceContainer: tones._94,
              surfaceContainerHigh: tones._92,
              surfaceContainerHighest: tones._90,
              onSurface: tones._10,
              inverseSurface: tones._20,
              inverseOnSurface: tones._95
            },
            dark: {
              ...prevTheme.dark,
              background: tones._10,
              onBackground: tones._85, /** Let's just deal with background and onbackground for now. */
              /**
              NOTE: In m3, surface containers from lowest to highest in dark mode go from darkest to brightest. In our system, LiftKit, they go from brightest to darkest.
              This is because the default dark backgrounds and surfaces are a little too black and cause halation effects
              for people with astigmatism, which includes me as well as a sizeable portion of our userbase (people who stare at screens all day) */

              surfaceContainerLowest: tones._12,
              surfaceDim: tones._14,
              surface: tones._15,
              surfaceContainerLow: tones._17,
              surfaceContainer: tones._20,
              surfaceContainerHigh: tones._24,
              surfaceContainerHighest: tones._28,
              surfaceBright: tones._30,
              onSurface: tones._90,
              inverseSurface: tones._98,
              inverseOnSurface: tones._10,

            }
          }));
          break;
        case 'neutralvariant':
          setTheme(prevTheme => ({
            ...prevTheme,

            light: {
              ...prevTheme.light,
              surfaceVariant: tones._80,
              onSurfaceVariant: tones._30,
              outline: tones._90,
              outlineVariant: tones._80,
            },

            dark: {
              ...prevTheme.dark,
              surfaceVariant: tones._20,
              onSurfaceVariant: tones._70,
              outline: tones._50,
              outlineVariant: tones._30,
            }

          }
          ));
          break;
        case 'primary':
          setTheme(prevTheme => ({
            ...prevTheme,

            light: {
              ...prevTheme.light,
              [key]: tones._40,
              [`on${toSentenceCase(key)}`]: tones._98,
              [`${key}Container`]: tones._90,
              [`on${toSentenceCase(key)}Container`]: tones._10,
              [`${(key)}Fixed`]: tones._90,
              [`${(key)}FixedDim`]: tones._80,
              [`on${toSentenceCase(key)}Fixed`]: tones._10,
              [`on${toSentenceCase(key)}FixedVariant`]: tones._30,
              ['inversePrimary']: tones._80
            },
            dark: {
              ...prevTheme.dark,
              [key]: tones._80,
              [`on${toSentenceCase(key)}`]: tones._20,
              [`${key}Container`]: tones._30,
              [`on${toSentenceCase(key)}Container`]: tones._90,
              [`${(key)}Fixed`]: tones._90,
              [`${(key)}FixedDim`]: tones._80,
              [`on${toSentenceCase(key)}Fixed`]: tones._10,
              [`on${toSentenceCase(key)}FixedVariant`]: tones._30,
              ['inversePrimary']: tones._80
            },


          }));
          break;
        case 'secondary':
        case 'tertiary':
          setTheme(prevTheme => ({
            ...prevTheme,
            light: {
              ...prevTheme.light,
              [key]: tones._40,
              [`on${toSentenceCase(key)}`]: tones._98,
              [`${key}Container`]: tones._90,
              [`on${toSentenceCase(key)}Container`]: tones._10,
              [`${(key)}Fixed`]: tones._90,
              [`${(key)}FixedDim`]: tones._80,
              [`on${toSentenceCase(key)}Fixed`]: tones._10,
              [`on${toSentenceCase(key)}FixedVariant`]: tones._30,
            },
            dark: {
              ...prevTheme.dark,
              [key]: tones._80,
              [`on${toSentenceCase(key)}`]: tones._20,
              [`${key}Container`]: tones._30,
              [`on${toSentenceCase(key)}Container`]: tones._90,
              [`${(key)}Fixed`]: tones._90,
              [`${(key)}FixedDim`]: tones._80,
              [`on${toSentenceCase(key)}Fixed`]: tones._10,
              [`on${toSentenceCase(key)}FixedVariant`]: tones._30,
            },
          }));
        default:
          setTheme(prevTheme => ({
            ...prevTheme,
            light: {
              ...prevTheme.light,
              [key]: tones._40,
              [`on${toSentenceCase(key)}`]: tones._98,
              [`${key}Container`]: tones._90,
              [`on${toSentenceCase(key)}Container`]: tones._10,

            },
            dark: {
              ...prevTheme.dark,
              [key]: tones._80,
              [`on${toSentenceCase(key)}`]: tones._20,
              [`${key}Container`]: tones._30,
              [`on${toSentenceCase(key)}Container`]: tones._90,
            },
          }));
      }
    });
  }, []);

3. How this solved the problem for me:

You'll notice in my neutrals, especially, that the values for surfaces do not line up 1:1 with the swatches the theme builder says it uses. That's where you can make tweaks fast, by modifying the tones. Since you have multiple vanity themes, as long as each one's in its own repo, you can fine-tune it on a case-by-case basis. In the example below, I have my dark mode surfaceContainers significantly brighter than the defaults.

Does this help at all?

garrett-from-chainlift avatar Nov 19 '24 15:11 garrett-from-chainlift