UNPKG

igniteui-theming

Version:

A set of Sass variables, mixins, and functions for generating palettes, typography, and elevations used by Ignite UI components.

301 lines (298 loc) 9.94 kB
import { themingImporter } from "./theming-resolve.js"; import * as sass from "sass-embedded"; //#region src/utils/color.ts /** * Color analysis utilities using Sass-embedded. * Calls the actual Sass luminance() and contrast() functions for accurate validation. */ /** * Luminance threshold for determining light vs dark colors. * Colors with luminance > 0.5 are considered "light". * Colors with luminance <= 0.5 are considered "dark". */ var LUMINANCE_THRESHOLD = .5; /** * Suggested colors for different variants. */ var SUGGESTED_COLORS = { light: { surface: [ "white", "#ffffff", "#f8f9fa", "#fafafa", "#f5f5f5" ], gray: [ "black", "#000000", "#333333", "#212121", "#424242" ] }, dark: { surface: [ "#222222", "#1a1a1a", "#121212", "#181818", "#2d2d2d" ], gray: [ "white", "#ffffff", "#e5e5e5", "#f5f5f5", "#eeeeee" ] } }; /** * Analyze a single color using Sass luminance() function. * * @param color - CSS color value (hex, rgb, hsl, or named color) * @returns Color analysis with luminance and isLight flag * @throws Error if Sass compilation fails or color is invalid */ async function analyzeColor(color) { const sassCode = ` @use 'igniteui-theming/sass/color' as color; $lum: color.luminance(${color}); :root { --luminance: #{$lum}; } `; try { const luminanceMatch = (await sass.compileStringAsync(sassCode, { importers: [themingImporter] })).css.match(/--luminance:\s*([\d.]+)/); if (!luminanceMatch) throw new Error(`Could not parse luminance from Sass output for color: ${color}`); const luminance = Number.parseFloat(luminanceMatch[1]); return { color, luminance, isLight: luminance > LUMINANCE_THRESHOLD }; } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Failed to analyze color "${color}": ${message}`); } } /** * Analyze surface and gray colors together in a single Sass compilation. * This is more efficient than calling analyzeColor twice. * * @param params - Object containing surface and/or gray colors * @returns Combined analysis results */ async function analyzeSurfaceGrayColors(params) { const { surface, gray } = params; if (!surface && !gray) return {}; const sassLines = [`@use 'igniteui-theming/sass/color' as color;`, ""]; if (surface) sassLines.push(`$surface-lum: color.luminance(${surface});`); if (gray) sassLines.push(`$gray-lum: color.luminance(${gray});`); if (surface && gray) sassLines.push(`$contrast: color.contrast(${surface}, ${gray});`); sassLines.push("", ":root {"); if (surface) sassLines.push(" --surface-luminance: #{$surface-lum};"); if (gray) sassLines.push(" --gray-luminance: #{$gray-lum};"); if (surface && gray) sassLines.push(" --contrast-ratio: #{$contrast};"); sassLines.push("}"); const sassCode = sassLines.join("\n"); try { const result = await sass.compileStringAsync(sassCode, { importers: [themingImporter] }); const analysis = {}; if (surface) { const surfaceMatch = result.css.match(/--surface-luminance:\s*([\d.]+)/); if (surfaceMatch) { const luminance = Number.parseFloat(surfaceMatch[1]); analysis.surface = { color: surface, luminance, isLight: luminance > LUMINANCE_THRESHOLD }; } } if (gray) { const grayMatch = result.css.match(/--gray-luminance:\s*([\d.]+)/); if (grayMatch) { const luminance = Number.parseFloat(grayMatch[1]); analysis.gray = { color: gray, luminance, isLight: luminance > LUMINANCE_THRESHOLD }; } } if (surface && gray) { const contrastMatch = result.css.match(/--contrast-ratio:\s*([\d.]+)/); if (contrastMatch) analysis.contrastRatio = Number.parseFloat(contrastMatch[1]); } return analysis; } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Failed to analyze surface/gray colors: ${message}`); } } /** * Check if a color is valid by attempting to analyze it. * * @param color - CSS color value to validate * @returns true if the color is valid, false otherwise */ async function isValidColor(color) { try { await analyzeColor(color); return true; } catch { return false; } } /** * Validate multiple colors in a single Sass compilation for efficiency. * This is much faster than calling isValidColor() for each color individually. * * @param colors - Map of key names to color values * @returns Map of key names to validation results (true = valid, false = invalid) */ async function validateColorsInBatch(colors) { const entries = Object.entries(colors); if (entries.length === 0) return {}; const sassLines = [ `@use 'sass:color';`, `@use 'sass:meta';`, "" ]; for (const [key, colorValue] of entries) { const safeKey = key.replace(/[^a-zA-Z0-9]/g, "-"); sassLines.push(`$${safeKey}-valid: meta.type-of(${colorValue}) == 'color';`); } sassLines.push("", ":root {"); for (const [key] of entries) { const safeKey = key.replace(/[^a-zA-Z0-9]/g, "-"); sassLines.push(` --${safeKey}-valid: #{$${safeKey}-valid};`); } sassLines.push("}"); const sassCode = sassLines.join("\n"); try { const result = await sass.compileStringAsync(sassCode, { importers: [themingImporter] }); const validationResults = {}; for (const [key] of entries) { const safeKey = key.replace(/[^a-zA-Z0-9]/g, "-"); const validMatch = result.css.match(new RegExp(`--${safeKey}-valid:\\s*(true|false)`)); validationResults[key] = validMatch ? validMatch[1] === "true" : false; } return validationResults; } catch { const validationResults = {}; for (const [key, colorValue] of entries) validationResults[key] = await isValidColor(colorValue); return validationResults; } } /** * Check if two hue values are within tolerance of each other, * accounting for the circular nature of hue (0° = 360°). * * @param hue1 - First hue value (0-360) * @param hue2 - Second hue value (0-360) * @param tolerance - Maximum allowed difference in degrees (default: 30) * @returns true if hues are within tolerance */ function huesAreClose(hue1, hue2, tolerance = 30) { const diff = Math.abs(hue1 - hue2); return Math.min(diff, 360 - diff) <= tolerance; } /** * Analyze multiple colors in a single Sass compilation for efficiency. * Returns luminance and hue for each color. * * @param colors - Map of key names to color values * @returns Map of key names to analysis results */ async function analyzeColorsWithHue(colors) { const entries = Object.entries(colors); if (entries.length === 0) return {}; const sassLines = [ `@use 'igniteui-theming/sass/color' as igColor;`, `@use 'sass:color';`, "" ]; for (const [key, color] of entries) { const safeKey = key.replace(/[^a-zA-Z0-9]/g, "-"); sassLines.push(`$${safeKey}-lum: igColor.luminance(${color});`); sassLines.push(`$${safeKey}-hue: color.channel(${color}, "hue", $space: hsl);`); } sassLines.push("", ":root {"); for (const [key] of entries) { const safeKey = key.replace(/[^a-zA-Z0-9]/g, "-"); sassLines.push(` --${safeKey}-luminance: #{$${safeKey}-lum};`); sassLines.push(` --${safeKey}-hue: #{$${safeKey}-hue};`); } sassLines.push("}"); const sassCode = sassLines.join("\n"); try { const result = await sass.compileStringAsync(sassCode, { importers: [themingImporter] }); const analysis = {}; for (const [key] of entries) { const safeKey = key.replace(/[^a-zA-Z0-9]/g, "-"); const lumMatch = result.css.match(new RegExp(`--${safeKey}-luminance:\\s*([\\d.]+)`)); const hueMatch = result.css.match(new RegExp(`--${safeKey}-hue:\\s*([\\d.]+)`)); if (lumMatch && hueMatch) analysis[key] = { luminance: Number.parseFloat(lumMatch[1]), hue: Number.parseFloat(hueMatch[1]) }; } return analysis; } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Failed to analyze colors: ${message}`); } } /** * Luminance thresholds for palette shade generation suitability. * Colors outside this range may produce poor automatic shade generation results. * * Note: This is different from LUMINANCE_THRESHOLD (0.5) which determines * if a color is "light" or "dark" for variant matching. These thresholds * determine if a color can produce a good range of shades when used with * the palette() function. * * Based on color theory research: * - Optimal base color tone: 35-65 L* (CIELAB) * - Too light (L* > 70-75): darker shades compress together * - Too dark (L* < 25-30): lighter shades compress together */ var PALETTE_LUMINANCE_THRESHOLDS = { TOO_DARK: .05, TOO_LIGHT: .45 }; /** * Analyze whether a color is suitable for automatic shade generation. * Colors with extreme luminance (very light or very dark) may produce * poor results when using the palette() function's automatic shade generation. * * @param color - CSS color value (hex, rgb, hsl, or named color) * @returns Analysis result indicating suitability and any issues */ async function analyzeColorForPalette(color) { const analysis = await analyzeColor(color); if (analysis.luminance > PALETTE_LUMINANCE_THRESHOLDS.TOO_LIGHT) return { color, luminance: analysis.luminance, suitable: false, issue: "too-light", description: `Luminance ${analysis.luminance.toFixed(2)} exceeds ${PALETTE_LUMINANCE_THRESHOLDS.TOO_LIGHT} - darker shades (600-900) will appear washed out` }; if (analysis.luminance < PALETTE_LUMINANCE_THRESHOLDS.TOO_DARK) return { color, luminance: analysis.luminance, suitable: false, issue: "too-dark", description: `Luminance ${analysis.luminance.toFixed(2)} is below ${PALETTE_LUMINANCE_THRESHOLDS.TOO_DARK} - lighter shades (50-200) will lack contrast range` }; return { color, luminance: analysis.luminance, suitable: true }; } //#endregion export { LUMINANCE_THRESHOLD, SUGGESTED_COLORS, analyzeColorForPalette, analyzeColorsWithHue, analyzeSurfaceGrayColors, huesAreClose, validateColorsInBatch };