Skip to content

fix(material/schematics): estimate missing hues in M3 schematic #29231

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic here can be simplified, but I wanted to avoid mutating the original map.

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};
}
Loading