diff --git a/src/material/core/theming/_palettes.scss b/src/material/core/theming/_palettes.scss index 20027a6bf741..e08dd2de291a 100644 --- a/src/material/core/theming/_palettes.scss +++ b/src/material/core/theming/_palettes.scss @@ -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)); diff --git a/src/material/schematics/ng-generate/m3-theme/index.spec.ts b/src/material/schematics/ng-generate/m3-theme/index.spec.ts index 604dab59778b..33d23ceaf998 100644 --- a/src/material/schematics/ng-generate/m3-theme/index.spec.ts +++ b/src/material/schematics/ng-generate/m3-theme/index.spec.ts @@ -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() { diff --git a/src/material/schematics/ng-generate/m3-theme/index.ts b/src/material/schematics/ng-generate/m3-theme/index.ts index 52dd85e472b2..39a97aa05273 100644 --- a/src/material/schematics/ng-generate/m3-theme/index.ts +++ b/src/material/schematics/ng-generate/m3-theme/index.ts @@ -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([ + [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. @@ -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),', @@ -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>, +): Map> { + const neutral = palettes.get('neutral'); + + if (!neutral) { + return palettes; + } + + let newNeutral: Map | 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>(); + 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()); + 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}; +}