Skip to content

Commit

Permalink
fix(material/schematics): estimate missing hues in M3 schematic (#29231)
Browse files Browse the repository at this point in the history
We use `@material/material-color-utilities` to generate the palettes in the M3 `ng generate` schematic, but it appears that the utilities don't generate all the neutral hues for some colors which leads to transparent dropdowns (see #29157). We handle this in our built in palettes by estimating the missing hues based on the hues around them.

These changes port the estimation logic to the schematic to ensure that we always generate full themes.

Fixes #29157.

(cherry picked from commit 3da4323)
  • Loading branch information
crisbeto committed Jun 11, 2024
1 parent 6dd1689 commit 0f4d186
Show file tree
Hide file tree
Showing 3 changed files with 169 additions and 2 deletions.
2 changes: 2 additions & 0 deletions src/material/core/theming/_palettes.scss
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
/// The Material Design spec references some neutral hues that are not generated by
/// https://m3.material.io/theme-builder. For now we use this function to estimate the missing hues
/// by blending the nearest hues that are generated.
/// Note: when updating, the corresponding logic in the theme generation schematic should be
/// updated as well. See `src/material/schematics/ng-generate/m3-theme/index.ts#patchMissingHues`
@function _patch-missing-hues($palette) {
$neutral: map.get($palette, neutral);
$palette: map.set($palette, neutral, 4, _estimate-hue($neutral, 4, 0, 10));
Expand Down
43 changes: 43 additions & 0 deletions src/material/schematics/ng-generate/m3-theme/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,49 @@ describe('material-m3-theme-schematic', () => {
expect(generatedCSS).toContain(`--sys-primary: ${primaryColor}`);
expect(generatedCSS).toContain('var(--sys-primary)');
});

it('should estimate missing neutral hues', async () => {
const tree = await runM3ThemeSchematic(runner, {
primaryColor: '#232e62',
secondaryColor: '#cc862a',
tertiaryColor: '#44263e',
neutralColor: '#929093',
themeTypes: 'light',
});

expect(tree.readContent('m3-theme.scss')).toContain(
[
` neutral: (`,
` 0: #000000,`,
` 4: #000527,`,
` 6: #00073a,`,
` 10: #000c61,`,
` 12: #051166,`,
` 17: #121e71,`,
` 20: #1a2678,`,
` 22: #1f2b7d,`,
` 24: #243082,`,
` 25: #273384,`,
` 30: #333f90,`,
` 35: #404b9c,`,
` 40: #4c57a9,`,
` 50: #6570c4,`,
` 60: #7f8ae0,`,
` 70: #9aa5fd,`,
` 80: #bcc2ff,`,
` 87: #d5d7ff,`,
` 90: #dfe0ff,`,
` 92: #e6e6ff,`,
` 94: #edecff,`,
` 95: #f0efff,`,
` 96: #f4f2ff,`,
` 98: #fbf8ff,`,
` 99: #fffbff,`,
` 100: #ffffff,`,
` ),`,
].join('\n'),
);
});
});

function getTestTheme() {
Expand Down
126 changes: 124 additions & 2 deletions src/material/schematics/ng-generate/m3-theme/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,25 @@ import {
// tonal palettes then get used to create the different color roles (ex.
// on-primary) https://m3.material.io/styles/color/system/how-the-system-works
const HUE_TONES = [0, 10, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100];
// Map of neutral hues to the previous/next hues that
// can be used to estimate them, in case they're missing.
const NEUTRAL_HUES = new Map<number, {prev: number; next: number}>([
[4, {prev: 0, next: 10}],
[6, {prev: 0, next: 10}],
[12, {prev: 10, next: 20}],
[17, {prev: 10, next: 20}],
[22, {prev: 20, next: 25}],
[24, {prev: 20, next: 25}],
[87, {prev: 80, next: 90}],
[92, {prev: 90, next: 95}],
[94, {prev: 90, next: 95}],
[96, {prev: 95, next: 98}],
]);

// Note: Some of the color tokens refer to additional hue tones, but this only
// applies for the neutral color palette (ex. surface container is neutral
// palette's 94 tone). https://m3.material.io/styles/color/static/baseline
const NEUTRAL_HUE_TONES = HUE_TONES.concat([4, 6, 12, 17, 22, 24, 87, 92, 94, 96]);
const NEUTRAL_HUE_TONES = [...HUE_TONES, ...NEUTRAL_HUES.keys()];

/**
* Gets color tonal palettes generated by Material from the provided color.
Expand Down Expand Up @@ -117,7 +132,7 @@ export function generateSCSSTheme(
"@use '@angular/material' as mat;",
'',
'// Note: ' + colorComment,
'$_palettes: ' + getColorPalettesSCSS(colorPalettes),
'$_palettes: ' + getColorPalettesSCSS(patchMissingHues(colorPalettes)),
'',
'$_rest: (',
' secondary: map.get($_palettes, secondary),',
Expand Down Expand Up @@ -192,3 +207,110 @@ export default function (options: Schema): Rule {
createThemeFile(themeScss, tree, options.directory);
};
}

/**
* The hue map produced by `material-color-utilities` may miss some neutral hues depending on
* the provided colors. This function estimates the missing hues based on the generated ones
* to ensure that we always produce a full palette. See #29157.
*
* This is a TypeScript port of the logic in `core/theming/_palettes.scss#_patch-missing-hues`.
*/
function patchMissingHues(
palettes: Map<string, Map<number, string>>,
): Map<string, Map<number, string>> {
const neutral = palettes.get('neutral');

if (!neutral) {
return palettes;
}

let newNeutral: Map<number, string> | null = null;

for (const [hue, {prev, next}] of NEUTRAL_HUES) {
if (!neutral.has(hue) && neutral.has(prev) && neutral.has(next)) {
const weight = (next - hue) / (next - prev);
const result = mixColors(neutral.get(prev)!, neutral.get(next)!, weight);

if (result !== null) {
newNeutral ??= new Map(neutral.entries());
newNeutral.set(hue, result);
}
}
}

if (!newNeutral) {
return palettes;
}

// Create a new map so we don't mutate the one that was passed in.
const newPalettes = new Map<string, Map<number, string>>();
for (const [key, value] of palettes) {
if (key === 'neutral') {
// Maps keep the order of their keys which can make the newly-added
// ones look out of place. Re-sort the the keys in ascending order.
const sortedNeutral = Array.from(newNeutral.keys())
.sort((a, b) => a - b)
.reduce((newHues, key) => {
newHues.set(key, newNeutral.get(key)!);
return newHues;
}, new Map<number, string>());
newPalettes.set(key, sortedNeutral);
} else {
newPalettes.set(key, value);
}
}

return newPalettes;
}

/**
* TypeScript port of the `color.mix` function from Sass, simplified to only deal with hex colors.
* See https://github.com/sass/dart-sass/blob/main/lib/src/functions/color.dart#L803
*
* @param c1 First color to use in the mixture.
* @param c2 Second color to use in the mixture.
* @param weight Proportion of the first color to use in the mixture.
* Should be a number between 0 and 1.
*/
function mixColors(c1: string, c2: string, weight: number): string | null {
const normalizedWeight = weight * 2 - 1;
const weight1 = (normalizedWeight + 1) / 2;
const weight2 = 1 - weight1;
const color1 = parseHexColor(c1);
const color2 = parseHexColor(c2);

if (color1 === null || color2 === null) {
return null;
}

const red = Math.round(color1.red * weight1 + color2.red * weight2);
const green = Math.round(color1.green * weight1 + color2.green * weight2);
const blue = Math.round(color1.blue * weight1 + color2.blue * weight2);
const intToHex = (value: number) => value.toString(16).padStart(2, '0');

return `#${intToHex(red)}${intToHex(green)}${intToHex(blue)}`;
}

/** Parses a hex color to its numeric red, green and blue values. */
function parseHexColor(value: string): {red: number; green: number; blue: number} | null {
if (!/^#(?:[0-9a-fA-F]{3}){1,2}$/.test(value)) {
return null;
}

const hexToInt = (value: string) => parseInt(value, 16);
let red: number;
let green: number;
let blue: number;

if (value.length === 4) {
red = hexToInt(value[1] + value[1]);
green = hexToInt(value[2] + value[2]);
blue = hexToInt(value[3] + value[3]);
} else {
red = hexToInt(value.slice(1, 3));
green = hexToInt(value.slice(3, 5));
blue = hexToInt(value.slice(5, 7));
}

return {red, green, blue};
}

0 comments on commit 0f4d186

Please sign in to comment.