igniteui-theming
Version:
A set of Sass variables, mixins, and functions for generating palettes, typography, and elevations used by Ignite UI components.
233 lines (232 loc) • 8.26 kB
JavaScript
import { analyzeColorsWithHue, huesAreClose, validateColorsInBatch } from "../utils/color.js";
import { ALL_COLOR_SHADES, CHROMATIC_COLOR_GROUPS, SHADE_LEVELS } from "../utils/types.js";
import "../knowledge/index.js";
import { formatValidationMessages } from "../utils/result.js";
//#region src/validators/custom-palette.ts
/**
* Validation for custom palette structures.
*
* Uses the unified ValidationResult type from result.ts for consistent
* error/warning handling across the codebase.
*
* Performance optimization: Uses batch color validation to minimize Sass
* compilations. Instead of validating each color individually (which would
* spawn ~100+ Sass processes), we collect all colors and validate them in
* a single Sass compilation.
*/
/**
* Helper to create a field path from color group and optional shade.
*/
function makeFieldPath(colorGroup, shade) {
return shade ? `${colorGroup}.${shade}` : colorGroup;
}
/**
* Collects all colors from a palette input for batch validation.
*/
function collectAllColors(input) {
const colors = [];
const missingShades = [];
for (const group of CHROMATIC_COLOR_GROUPS) {
const definition = input[group];
if (definition) collectFromDefinition(group, definition, ALL_COLOR_SHADES, colors, missingShades);
}
if (input.gray) collectFromDefinition("gray", input.gray, [...SHADE_LEVELS], colors, missingShades);
return {
colors,
missingShades
};
}
/**
* Collects colors from a single color definition.
*/
function collectFromDefinition(groupName, definition, expectedShades, colors, missingShades) {
if (definition.mode === "shades") colors.push({
key: `${groupName}.baseColor`,
color: definition.baseColor,
groupName
});
else {
for (const shade of expectedShades) {
const color = definition.shades[shade];
if (!color) missingShades.push({
field: makeFieldPath(groupName, shade),
message: `Missing required shade: ${shade}`
});
else colors.push({
key: `${groupName}.${shade}`,
color,
groupName,
shade
});
}
if (definition.contrastOverrides) {
for (const [shade, color] of Object.entries(definition.contrastOverrides)) if (expectedShades.includes(shade)) colors.push({
key: `${groupName}.contrast.${shade}`,
color,
groupName,
shade,
isContrast: true
});
}
}
}
/**
* Validates a custom palette input structure.
*
* @param input - The custom palette input to validate
* @param variant - Theme variant for gray shade progression validation (defaults to 'light')
*/
async function validateCustomPalette(input, variant = "light") {
const errors = [];
const warnings = [];
const { colors, missingShades } = collectAllColors(input);
errors.push(...missingShades);
const chromaticShadeSet = ALL_COLOR_SHADES;
const grayShadeSet = SHADE_LEVELS;
for (const group of CHROMATIC_COLOR_GROUPS) {
const definition = input[group];
if (definition?.mode === "explicit" && definition.contrastOverrides) {
for (const shade of Object.keys(definition.contrastOverrides)) if (!chromaticShadeSet.includes(shade)) errors.push({
field: makeFieldPath(group, shade),
message: `Invalid contrast override key: ${shade}. Valid keys are: ${ALL_COLOR_SHADES.join(", ")}`
});
}
}
if (input.gray?.mode === "explicit" && input.gray.contrastOverrides) {
for (const shade of Object.keys(input.gray.contrastOverrides)) if (!grayShadeSet.includes(shade)) errors.push({
field: makeFieldPath("gray", shade),
message: `Invalid contrast override key: ${shade}. Valid keys are: ${SHADE_LEVELS.join(", ")}`
});
}
if (colors.length > 0) {
const colorMap = {};
for (const c of colors) colorMap[c.key] = c.color;
const validationResults = await validateColorsInBatch(colorMap);
for (const c of colors) if (!validationResults[c.key]) if (c.isContrast) errors.push({
field: makeFieldPath(c.groupName, `contrast.${c.shade}`),
message: `Invalid contrast color for shade ${c.shade}: ${c.color}`,
currentValue: c.color
});
else if (c.shade) errors.push({
field: makeFieldPath(c.groupName, c.shade),
message: `Invalid color value for shade ${c.shade}: ${c.color}`,
currentValue: c.color
});
else errors.push({
field: c.groupName,
message: `Invalid base color: ${c.color}`,
currentValue: c.color
});
}
for (const group of CHROMATIC_COLOR_GROUPS) {
const definition = input[group];
if (definition?.mode === "explicit") {
await validateShadeProgression(group, definition.shades, "chromatic", variant, warnings);
await validateMonochromaticHue(group, definition.shades, warnings);
}
}
if (input.gray?.mode === "explicit") await validateShadeProgression("gray", input.gray.shades, "gray", variant, warnings);
return {
isValid: errors.length === 0,
errors,
warnings
};
}
/**
* Format validation result as markdown.
*
* This is a thin wrapper around formatValidationMessages for backward compatibility.
* New code should use formatValidationMessages directly.
*/
function formatCustomPaletteValidation(result) {
if (result.isValid && result.warnings.length === 0) return "";
return formatValidationMessages(result);
}
/**
* Validates that shade progression follows expected luminance direction.
*
* - Chromatic colors: shade 50 should be lighter than shade 900 (always)
* - Gray (light themes): shade 50 should be lighter than shade 900
* - Gray (dark themes): shade 50 should be darker than shade 900 (inverted)
*
* Only checks endpoints (50 vs 900), not full progression.
* Issues warnings, not errors.
*/
async function validateShadeProgression(groupName, shades, colorType, variant, warnings) {
const shade50 = shades["50"];
const shade900 = shades["900"];
if (!shade50 || !shade900) return;
try {
const analysis = await analyzeColorsWithHue({
shade50,
shade900
});
const lum50 = analysis.shade50?.luminance;
const lum900 = analysis.shade900?.luminance;
if (lum50 === void 0 || lum900 === void 0) return;
if (colorType === "gray" && variant === "dark") {
if (lum50 >= lum900) warnings.push({
field: groupName,
message: `For dark themes, gray shade 50 should be darker than shade 900 (inverted progression). Found: 50 (luminance: ${lum50.toFixed(3)}) vs 900 (luminance: ${lum900.toFixed(3)}).`,
severity: "warning"
});
} else if (lum50 <= lum900) {
const context = colorType === "gray" ? "For light themes, gray" : "Chromatic";
warnings.push({
field: groupName,
message: `${context} shade 50 should be lighter than shade 900. Found: 50 (luminance: ${lum50.toFixed(3)}) vs 900 (luminance: ${lum900.toFixed(3)}).`,
severity: "warning"
});
}
} catch (_error) {
if (process.env.DEBUG) {}
}
}
/**
* Validates that a chromatic color family is monochromatic (same hue family).
*
* Checks hues at shades 50, 500, and 900. Warns if hue variation exceeds tolerance.
* Only applies to chromatic colors, not gray.
*/
async function validateMonochromaticHue(groupName, shades, warnings, tolerance = 30) {
const shade50 = shades["50"];
const shade500 = shades["500"];
const shade900 = shades["900"];
if (!shade50 || !shade500 || !shade900) return;
try {
const analysis = await analyzeColorsWithHue({
shade50,
shade500,
shade900
});
const hue50 = analysis.shade50?.hue;
const hue500 = analysis.shade500?.hue;
const hue900 = analysis.shade900?.hue;
if (hue50 === void 0 || hue500 === void 0 || hue900 === void 0) return;
const hues = [
{
shade: "50",
hue: hue50
},
{
shade: "500",
hue: hue500
},
{
shade: "900",
hue: hue900
}
];
const outliers = [];
for (let i = 0; i < hues.length; i++) for (let j = i + 1; j < hues.length; j++) if (!huesAreClose(hues[i].hue, hues[j].hue, tolerance)) outliers.push(`${hues[i].shade} (${Math.round(hues[i].hue)}°) vs ${hues[j].shade} (${Math.round(hues[j].hue)}°)`);
if (outliers.length > 0) warnings.push({
field: groupName,
message: `Color shades may not be monochromatic (hue varies by more than ±${tolerance}°). Differences found: ${outliers.join(", ")}. Consider using colors from the same hue family for visual consistency.`,
severity: "warning"
});
} catch (_error) {
if (process.env.DEBUG) {}
}
}
//#endregion
export { formatCustomPaletteValidation, validateCustomPalette };