UNPKG

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