This is an automated email from the ASF dual-hosted git repository. greg-dove pushed a commit to branch develop in repository https://gitbox.apache.org/repos/asf/royale-asjs.git
commit 84579b69e1b7dc0354f9f3e9337cb7eeef7cef14 Author: greg-dove <[email protected]> AuthorDate: Wed Apr 22 14:14:24 2026 +1200 Color support sweep. Lenient support for WCAG contrast generation. If a lighter or darker contrast is preferred then an attempt is made to find a contrast that meets WCAG standards, but if it cannot find a compliant color, it still sticks with the preference. --- .../org/apache/royale/style/colors/ColorSwatch.as | 32 ++- .../org/apache/royale/style/colors/ColorUtils.as | 183 +++++++++---- .../apache/royale/style/colors/ThemeColorSet.as | 138 ++++++++-- .../org/apache/royale/style/util/StyleTheme.as | 3 +- .../src/test/royale/FlexUnitRoyaleApplication.mxml | 5 +- .../ColorUtilsContrastFunctionTest.as | 128 +++++++++ .../royale/flexUnitTests/ColorUtilsContrastTest.as | 295 +++++++++++++++++++++ .../test/royale/flexUnitTests/ThemeColorSetTest.as | 206 ++++++++++++++ 8 files changed, 896 insertions(+), 94 deletions(-) diff --git a/frameworks/projects/Style/src/main/royale/org/apache/royale/style/colors/ColorSwatch.as b/frameworks/projects/Style/src/main/royale/org/apache/royale/style/colors/ColorSwatch.as index 111a876f28..83d2c33baf 100644 --- a/frameworks/projects/Style/src/main/royale/org/apache/royale/style/colors/ColorSwatch.as +++ b/frameworks/projects/Style/src/main/royale/org/apache/royale/style/colors/ColorSwatch.as @@ -91,9 +91,6 @@ package org.apache.royale.style.colors var col:uint = BASE_COLORS[key]; lchLookups[key] = ColorUtils.rgb_ToOKLCH([(col>>16)&0xff,(col>>8)&0xff,col&0xff]) } - //add white and black - - } return lchLookups; } @@ -106,12 +103,23 @@ package org.apache.royale.style.colors for (var name:String in BASE_COLORS_OKLCH) { var ref:Array = BASE_COLORS_OKLCH[name]; var d:Number = ColorUtils.oklchDistance(inputAsOKLCH, ref); + + // Bias toward black/white if they are in the lookups + if (name == "white" || name == "black") { + d *= 0.01; + // If the input is very extreme (L close to 0 or 1), force a very small distance + if (name == "white" && inputAsOKLCH[0] > 0.99) d = 0; + if (name == "black" && inputAsOKLCH[0] < 0.01) d = 0; + } + if (d < bestDist) { bestDist = d; bestName = name; } } var base:Array = BASE_COLORS_OKLCH[bestName]; + if (bestName == "white") return new ColorSwatch("white", 0); + if (bestName == "black") return new ColorSwatch("black", 0); var ramp:Object = ColorUtils.getOklchRamp(base); var bestShade:uint = 500; bestDist = Number.MAX_VALUE; @@ -121,7 +129,7 @@ package org.apache.royale.style.colors if (d < bestDist) { bestDist = d; - bestShade = uint(shadeKey); + bestShade = Number(shadeKey); } } return new ColorSwatch(bestName,bestShade); @@ -142,13 +150,17 @@ package org.apache.royale.style.colors */ public function ColorSwatch(swatch:String,shade:Number,opacity:Number = 100,darkMode:Boolean=false) { - var base:Object = BASE_COLORS[swatch] || CSSLookup.getProperty(swatch); - assert(base, "Invalid color swatch: " + swatch); + if (swatch == "white") { + rgb = [255,255,255]; + } else if (swatch == "black") { + rgb = [0,0,0]; + } else { + var base:Object = BASE_COLORS[swatch] || CSSLookup.getProperty(swatch); + assert(base, "Invalid color swatch: " + swatch); + var baseColor:uint = CSSUtils.toColor(base); + rgb = ColorUtils.getVariation(baseColor,Math.round(shade/10),darkMode); + } assert(shade>=0 && shade<=1000, "Invalid shade: " + shade); - var baseColor:uint = CSSUtils.toColor(base); - // Convert from 50,100,200... to 5,10,20... for easier math. - // shade = Math.round(shade/10); - rgb = ColorUtils.getVariation(baseColor,Math.round(shade/10),darkMode); assert(opacity >= 0 && opacity <= 100, "Opacity must be between 0 and 100"); colorBase = swatch; colorShade = shade; diff --git a/frameworks/projects/Style/src/main/royale/org/apache/royale/style/colors/ColorUtils.as b/frameworks/projects/Style/src/main/royale/org/apache/royale/style/colors/ColorUtils.as index 057393c547..0626a94f89 100644 --- a/frameworks/projects/Style/src/main/royale/org/apache/royale/style/colors/ColorUtils.as +++ b/frameworks/projects/Style/src/main/royale/org/apache/royale/style/colors/ColorUtils.as @@ -28,6 +28,8 @@ package org.apache.royale.style.colors } + public static const WANT_LIGHT_THRESHOLD:Number= 0.675; + /** * Returns an RGB array representing a perceptually-balanced variation of the input color. * Uses OKLCH color space to maintain consistent hue and saturation across different lightness levels. @@ -45,6 +47,8 @@ package org.apache.royale.style.colors var r:Number = (color >> 16) & 0xFF; var g:Number = (color >> 8) & 0xFF; var b:Number = color & 0xFF; + + if (grayValue == 50) return [r, g, b]; //convert to 0 - 1000 range var t:Number = pinValue(grayValue, 0, 100) * 10; @@ -54,27 +58,6 @@ package org.apache.royale.style.colors //shade it lch = lchShade(lch,factorForShadeTableInterpolated(t,darkMode)); return oklch_ToRGB(lch); - - /*var outR:Number; - var outG:Number; - var outB:Number; - - if (t <= 50) - { - var toBase:Number = t / 50; - outR = 255 + (r - 255) * toBase; - outG = 255 + (g - 255) * toBase; - outB = 255 + (b - 255) * toBase; - } - else - { - var toBlack:Number = (t - 50) / 50; - outR = r * (1 - toBlack); - outG = g * (1 - toBlack); - outB = b * (1 - toBlack); - } - - return [Math.round(outR), Math.round(outG), Math.round(outB)];*/ } /** @@ -86,7 +69,7 @@ package org.apache.royale.style.colors var gg:uint = uint(pinValue(g, 0, 255)); var bb:uint = uint(pinValue(b, 0, 255)); var color:uint = (rr << 16) | (gg << 8) | bb; - return getVariation(color, grayValue); + return getVariation(color, grayValue,darkMode); } /** @@ -134,7 +117,7 @@ package org.apache.royale.style.colors * @param lch lch values in 3 element array * @return rgb values in 3 element array */ - public static function oklch_ToRGB(lch:Array):Array { + /*public static function oklch_ToRGB(lch:Array):Array { // --- 1. OKLCH → OKLab --- const L:Number = lch[0]; @@ -176,8 +159,90 @@ package org.apache.royale.style.colors var b:uint = uint(pinValue(linearToSrgb(bLin),0,1) * 255); return [r ,g ,b]; + }*/ + + public static function oklch_ToRGB(lch:Array):Array + { + const L:Number = lch[0]; + const C:Number = lch[1]; + const hRad:Number = lch[2] * Math.PI / 180.0; + + // Convert OKLCH → OKLab + function labFrom(L:Number, C:Number, hRad:Number):Array { + const a_:Number = C * Math.cos(hRad); + const b_:Number = C * Math.sin(hRad); + + const l_:Number = L + 0.3963377774 * a_ + 0.2158037573 * b_; + const m_:Number = L - 0.1055613458 * a_ - 0.0638541728 * b_; + const s_:Number = L - 0.0894841775 * a_ - 1.2914855480 * b_; + + const l:Number = (l_ < 0) ? 0 : l_ * l_ * l_; + const m:Number = (m_ < 0) ? 0 : m_ * m_ * m_; + const s:Number = (s_ < 0) ? 0 : s_ * s_ * s_; + + return [l, m, s]; + } + + // Convert LMS → sRGB (linear → gamma) + function rgbFrom(l:Number, m:Number, s:Number):Array { + var rLin:Number = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s; + var gLin:Number = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s; + var bLin:Number = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s; + + var r:Number = pinValue(linearToSrgb(rLin), 0, 1); + var g:Number = pinValue(linearToSrgb(gLin), 0, 1); + var b:Number = pinValue(linearToSrgb(bLin), 0, 1); + + return [r, g, b]; + } + + // Try full chroma first + var lms:Array = labFrom(L, C, hRad); + var rgb:Array = rgbFrom(lms[0], lms[1], lms[2]); + + var inGamut:Boolean = + rgb[0] >= 0 && rgb[0] <= 1 && + rgb[1] >= 0 && rgb[1] <= 1 && + rgb[2] >= 0 && rgb[2] <= 1; + + if (inGamut) { + return [ + uint(rgb[0] * 255), + uint(rgb[1] * 255), + uint(rgb[2] * 255) + ]; + } + + // Otherwise binary-search chroma down + var low:Number = 0; + var high:Number = C; + + for (var i:int = 0; i < 20; i++) { + var mid:Number = (low + high) * 0.5; + + lms = labFrom(L, mid, hRad); + rgb = rgbFrom(lms[0], lms[1], lms[2]); + + inGamut = + rgb[0] >= 0 && rgb[0] <= 1 && + rgb[1] >= 0 && rgb[1] <= 1 && + rgb[2] >= 0 && rgb[2] <= 1; + + if (inGamut) + low = mid; + else + high = mid; + } + + return [ + uint(rgb[0] * 255), + uint(rgb[1] * 255), + uint(rgb[2] * 255) + ]; } + + private static function srgbToLinear(x:Number):Number { return (x <= 0.04045) ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4); } @@ -262,17 +327,7 @@ package org.apache.royale.style.colors for (var shade:String in shading_factors) { var m:Number = shading_factors[shade]; - - // Lightness scaling - var L:Number = base[0] * m; - - // Chroma scaling (slightly reduced for darker shades) - var C:Number = base[1] * (m < 1 ? m : 1); - - // Hue stays constant - var H:Number = base[2]; - - ramp[shade] = [L,C,H]; + ramp[shade] = lchShade(base, m); } return ramp; } @@ -283,10 +338,18 @@ package org.apache.royale.style.colors var dh:Number = Math.abs(a[2] - b[2]); if (dh > 180) dh = 360 - dh; + // If both colors are very achromatic, hue doesn't matter + if (a[1] < 0.01 && b[1] < 0.01) dh = 0; + + // Normalize dh to a similar scale as L and C (0..1) + var dH_normalized:Number = dh / 180.0; + var dL:Number = a[0] - b[0]; var dC:Number = a[1] - b[1]; - return Math.sqrt(dL*dL + dC*dC + dh*dh); + // Weighted distance: prioritize Hue strongly, then Lightness, then Chroma + // This ensures we snap to the correct color family (hue) first. + return Math.sqrt(dL*dL + (dC*dC * 0.25) + (dH_normalized * dH_normalized * 16.0)); } public static function flattenRGBAOverBackground(fg_rgb:Array, alpha:Number, bg_rgb:Array = null):Array @@ -462,7 +525,7 @@ package org.apache.royale.style.colors /** * Compute WCAG relative luminance from sRGB (0–255) */ - private static function relativeLuminance(rgb:Array):Number { + public static function relativeLuminance(rgb:Array):Number { function chan(v:Number):Number { v /= 255.0; return (v <= 0.04045) ? (v / 12.92) : Math.pow((v + 0.055) / 1.055, 2.4); @@ -515,33 +578,45 @@ package org.apache.royale.style.colors } var mid:Number; - var safeC:Number; var test:Array; var rgb:Array; + var bestL:Number = wantLight ? 1.0 : 0.0; // 20 iterations = sub‑pixel precision for (var i:int = 0; i < 20; i++) { mid = (low + high) * 0.5; - - // Stability chroma curve (prevents invalid OKLCH at extremes) - safeC = C * (1 - Math.abs(mid - 0.5) * 2); - safeC = pinValue(safeC, 0, C); - - test = [mid, safeC, h]; + // Be conservative: solve L for achromatic foreground contrast: C=0 + test = [mid, 0, h]; rgb = oklch_ToRGB(test); var cr:Number = contrast(rgb, bgRgb); - if (cr >= 4.5) { - if (wantLight) high = mid; - else low = mid; - } else { - if (wantLight) low = mid; - else high = mid; - } + if (cr >= 4.5) { + bestL = mid; + if (wantLight) high = mid; + else low = mid; + } else { + if (wantLight) low = mid; + else high = mid; } - - var L_final:Number = mid; + } + + // If we wanted light and didn't reach 4.5, check if pure white (L=1) is better than bestL. + // If we wanted dark and didn't reach 4.5, check if pure black (L=0) is better than bestL. + // In JS, sometimes the binary search might stay slightly away from the extreme. + if (wantLight) { + test = [1.0, 0, h]; + if (contrast(oklch_ToRGB(test), bgRgb) > contrast(oklch_ToRGB([bestL, 0, h]), bgRgb)) { + bestL = 1.0; + } + } else { + test = [0.0, 0, h]; + if (contrast(oklch_ToRGB(test), bgRgb) > contrast(oklch_ToRGB([bestL, 0, h]), bgRgb)) { + bestL = 0.0; + } + } + + var L_final:Number = bestL; // ----------------------------- // 2. BINARY SEARCH FOR CHROMA @@ -601,8 +676,8 @@ package org.apache.royale.style.colors var bgLch:Array = rgb_ToOKLCH(bgRgb); var L:Number = bgLch[0]; - // If background is light, generate dark text; if dark, generate light text - var wantLight:Boolean = (L < 0.6); + // If background is light, generate dark contrast; if dark, generate light contrast + var wantLight:Boolean = (L < WANT_LIGHT_THRESHOLD); var correctedLch:Array = generateContrastLCH(bgLch, wantLight); return oklch_ToRGB(correctedLch); diff --git a/frameworks/projects/Style/src/main/royale/org/apache/royale/style/colors/ThemeColorSet.as b/frameworks/projects/Style/src/main/royale/org/apache/royale/style/colors/ThemeColorSet.as index 1a7740e8d3..8b691e6d11 100644 --- a/frameworks/projects/Style/src/main/royale/org/apache/royale/style/colors/ThemeColorSet.as +++ b/frameworks/projects/Style/src/main/royale/org/apache/royale/style/colors/ThemeColorSet.as @@ -56,18 +56,38 @@ package org.apache.royale.style.colors COMPILE::JS private const storage:Map = new Map(); - private const lchLookups:Object = {init:false}; + private var _includeBlackAndWhite:Boolean = false; + public function get includeBlackAndWhite():Boolean{ + return _includeBlackAndWhite; + } + public function set includeBlackAndWhite(value:Boolean):void{ + if (_includeBlackAndWhite != value) { + _includeBlackAndWhite = value; + // Reset lookups if they were already initialized + if (lchLookups.init !== false) { + // We need to re-initialize to reflect the change in _includeBlackAndWhite + lchLookups = {init:false}; + } + } + } + + private var lchLookups:Object = {init:false}; private function getLCHLookups():Object{ if (lchLookups.init === false) { delete lchLookups.init; var key:String; - for each(key in _fieldNames) { + var fieldNames:Array = _fieldNames; + for each(key in fieldNames) { var baseColor:String = getThemeBaseColor(key); - if (baseColor) { - var col:uint =ColorSwatch.getColorValue(baseColor); + if (baseColor && !lchLookups.hasOwnProperty(baseColor)) { + var col:uint = ColorSwatch.getColorValue(baseColor); lchLookups[baseColor] = ColorUtils.rgb_ToOKLCH([(col>>16)&0xff,(col>>8)&0xff,col&0xff]) } } + if (_includeBlackAndWhite) { + lchLookups['black'] = [0,0,0]; // Black L=0, C=0, H=0 + lchLookups['white'] = [1,0,0]; // White L=1, C=0, H=0 + } } return lchLookups; } @@ -89,6 +109,10 @@ package org.apache.royale.style.colors COMPILE::JS { storage.set(key,value); } + // Clear lookups if theme colors change + if (lchLookups.init !== false) { + lchLookups = {init:false}; + } } else { COMPILE::JS { if (storage.has(key)) storage.delete(key); @@ -187,39 +211,93 @@ package org.apache.royale.style.colors * @param limitRange if true then limit lookups to this color set only * @return */ - public function findContrastVariant(swatch:String, shade:Number,dark:Boolean, weak:Boolean, limitRange:Boolean=false):ColorSwatch{ - const lookupKey:String = swatch+'$$'+shade+'$$'+dark+'$$'+weak+'$$'; - + public function findContrastVariant(swatch:String,shade:Number,dark:Boolean,weak:Boolean,limitRange:Boolean = false):ColorSwatch + { + const lookupKey:String = swatch + "$$" + shade + "$$" + dark + "$$" + weak + "$$" + limitRange; + + // Cached? var existing:String = contrastLookupSpecifiers[lookupKey]; - if (existing) { - + if (existing) return ColorSwatch.fromSpecifier(existing); - } + + // Resolve base color var base:Object = ColorSwatch.getColorValue(swatch) || CSSLookup.getProperty(swatch); var baseColor:uint = CSSUtils.toColor(base); - shade = Math.round(shade/10); - var colorVals:Array = ColorUtils.getVariation(baseColor,shade,dark); - var bgLch:Array = ColorUtils.rgb_ToOKLCH(colorVals); - var L:Number = bgLch[0]; - - // var wantLight:Boolean = (ColorUtils.contrast([255,255,255], colorVals) < ColorUtils.contrast([0,0,0], colorVals)); - var wantLight:Boolean = (L < .62); - // Strong or weak contrast? - var targetLch:Array = ColorUtils.generateContrastLCH(bgLch, wantLight); - if (weak) { - // Weak contrast = reduce chroma + move L slightly toward bg - targetLch[1] *= 0.4; // reduce chroma - targetLch[0] = (targetLch[0] + L) * 0.5; // blend toward background + // Tailwind-like shade rounding + shade = Math.round(shade / 10); + + // Background RGB for contrast measurement + var bgRgb:Array = ColorUtils.getVariation(baseColor, shade, dark); + + // Background OKLCH + var bgLch:Array = ColorUtils.rgb_ToOKLCH(bgRgb); + var bgL:Number = bgLch[0]; + + // Decide whether we want a light or dark foreground + // (L threshold is more stable than RGB contrast heuristic) + var wantLight:Boolean = (bgL < ColorUtils.WANT_LIGHT_THRESHOLD); + + // --- STEP 1: Try hue‑preserving OKLCH contrast first --- + var fgLch:Array = ColorUtils.generateContrastLCH(bgLch, wantLight); + var fgRgb:Array = ColorUtils.oklch_ToRGB(fgLch); + + // --- STEP 2: If weak contrast requested, soften the LCH result --- + if (weak) + { + // Reduce chroma + fgLch[1] *= 0.40; + + // Blend L halfway back toward background + fgLch[0] = (fgLch[0] + bgL) * 0.50; + + // Recompute RGB after weak adjustment + fgRgb = ColorUtils.oklch_ToRGB(fgLch); } - var fg:Array = targetLch; - colorVals = ColorUtils.oklch_ToRGB(fg); + // --- STEP 3: If contrast < 4.5, fallback to guaranteed RGB contrast --- + if (!weak) // weak contrast is allowed to be < 4.5 + { + if (ColorUtils.contrast(fgRgb, bgRgb) < 4.5) + { + // WCAG contrast is not guaranteed (may be black or white) + if (wantLight) + fgRgb = [255,255,255]; + else + fgRgb = [0,0,0]; + + } + } + + // --- STEP 4: Snap to theme palette if requested --- var lookups:Object = limitRange ? getLCHLookups() : null; - var ret:ColorSwatch = ColorSwatch.estimateFromRGB(colorVals,lookups); - contrastLookupSpecifiers[lookupKey] = ret.toString() + + // If we are NOT limiting range, we should still use a global lookup + // that EXCLUDES black and white unless explicitly asked? + // Actually, ColorSwatch.estimateFromRGB uses global lookups that DO NOT include black/white now. + + var result:ColorSwatch = ColorSwatch.estimateFromRGB(fgRgb, lookups); + + // If includeBlackAndWhite is enabled and we wanted light/dark, check if black/white would be a better fit + // than what estimateFromRGB returned, especially if it didn't snap to black/white but should have. + if (_includeBlackAndWhite && limitRange) { + if (wantLight && result.colorBase != "white") { + // Check if white is actually better + if (ColorUtils.contrast([255,255,255], bgRgb) > ColorUtils.contrast(result.getRGB(), bgRgb)) { + result = new ColorSwatch("white", 0); + } + } else if (!wantLight && result.colorBase != "black") { + // Check if black is actually better + if (ColorUtils.contrast([0,0,0], bgRgb) > ColorUtils.contrast(result.getRGB(), bgRgb)) { + result = new ColorSwatch("black", 0); + } + } + } - return ret; + // Cache + contrastLookupSpecifiers[lookupKey] = result.toString(); + + return result; } public function fromJSON(obj:Object):void{ @@ -232,6 +310,9 @@ package org.apache.royale.style.colors case '_baseContentWeak': _baseContentWeak = obj[key]; break; + case 'includeBlackAndWhite': + includeBlackAndWhite = obj[key]; + break; default: setThemeColor(key,obj[key]); } @@ -249,6 +330,7 @@ package org.apache.royale.style.colors } obj['_baseContent'] = _baseContent; obj['_baseContentWeak'] = _baseContentWeak; + obj['includeBlackAndWhite'] = _includeBlackAndWhite; } return obj; } diff --git a/frameworks/projects/Style/src/main/royale/org/apache/royale/style/util/StyleTheme.as b/frameworks/projects/Style/src/main/royale/org/apache/royale/style/util/StyleTheme.as index 93021c0bb3..43e5c7bdd9 100644 --- a/frameworks/projects/Style/src/main/royale/org/apache/royale/style/util/StyleTheme.as +++ b/frameworks/projects/Style/src/main/royale/org/apache/royale/style/util/StyleTheme.as @@ -44,7 +44,8 @@ package org.apache.royale.style.util 'error': 'red', "base": "slate", "_baseContent" : 'slate-500', - "_baseContentWeak" : 'neutral-400/80' + "_baseContentWeak" : 'neutral-400/80', + 'includeBlackAndWhite' : true }); public var spacing:Number = 4; diff --git a/frameworks/projects/Style/src/test/royale/FlexUnitRoyaleApplication.mxml b/frameworks/projects/Style/src/test/royale/FlexUnitRoyaleApplication.mxml index b334c0e7ab..d71dfd52a8 100644 --- a/frameworks/projects/Style/src/test/royale/FlexUnitRoyaleApplication.mxml +++ b/frameworks/projects/Style/src/test/royale/FlexUnitRoyaleApplication.mxml @@ -35,6 +35,9 @@ limitations under the License. } import flexUnitTests.StyleManagerTest; + import flexUnitTests.ColorUtilsContrastTest; + import flexUnitTests.ColorUtilsContrastFunctionTest; + import flexUnitTests.ThemeColorSetTest; import org.apache.royale.events.Event; import org.apache.royale.test.listeners.CIListener; @@ -91,7 +94,7 @@ limitations under the License. { core.addEventListener(Event.COMPLETE, core_completeHandler); // Insert test classes here. For example: - core.runClasses(StyleManagerTest); + core.runClasses(StyleManagerTest,ColorUtilsContrastFunctionTest,ColorUtilsContrastTest,ThemeColorSetTest); } } diff --git a/frameworks/projects/Style/src/test/royale/flexUnitTests/ColorUtilsContrastFunctionTest.as b/frameworks/projects/Style/src/test/royale/flexUnitTests/ColorUtilsContrastFunctionTest.as new file mode 100644 index 0000000000..5f92639bcc --- /dev/null +++ b/frameworks/projects/Style/src/test/royale/flexUnitTests/ColorUtilsContrastFunctionTest.as @@ -0,0 +1,128 @@ +//////////////////////////////////////////////////////////////////////////////// +// +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//////////////////////////////////////////////////////////////////////////////// +package flexUnitTests +{ + import org.apache.royale.style.colors.ColorUtils; + import org.apache.royale.test.asserts.*; + + public class ColorUtilsContrastFunctionTest + { + [Before] public function setUp():void {} + [After] public function tearDown():void {} + [BeforeClass] public static function setUpBeforeClass():void {} + [AfterClass] public static function tearDownAfterClass():void {} + + + + // ------------------------------------------------------------- + // 1. Relative luminance correctness + // ------------------------------------------------------------- + [Test] + public function testRelativeLuminance_PureColors():void + { + // Expected WCAG luminances + // White: 1.0 + // Black: 0.0 + // Red: 0.2126 + // Green: 0.7152 + // Blue: 0.0722 + + assertCloseTo(ColorUtils.relativeLuminance([255,255,255]), 1.0, 0.0001, "White luminance"); + assertCloseTo(ColorUtils.relativeLuminance([0,0,0]), 0.0, 0.0001, "Black luminance"); + + assertCloseTo(ColorUtils.relativeLuminance([255,0,0]), 0.2126, 0.0001, "Red luminance"); + assertCloseTo(ColorUtils.relativeLuminance([0,255,0]), 0.7152, 0.0001, "Green luminance"); + assertCloseTo(ColorUtils.relativeLuminance([0,0,255]), 0.0722, 0.0001, "Blue luminance"); + } + + // ------------------------------------------------------------- + // 2. Known WCAG contrast ratios + // ------------------------------------------------------------- + [Test] + public function testContrast_KnownPairs():void + { + // White vs Black = 21.0 + assertCloseTo( + ColorUtils.contrast([255,255,255], [0,0,0]), + 21.0, 0.001, + "White/Black contrast" + ); + + // White vs mid-gray (#777777) + // Expected ≈ 4.48 + assertCloseTo( + ColorUtils.contrast([255,255,255], [119,119,119]), + 4.49, 0.05, + "White vs #777777" + ); + + // Black vs mid-gray (#777777) + // Expected ≈ 5.32 + assertCloseTo( + ColorUtils.contrast([0,0,0], [119,119,119]), + 4.69, 0.05, + "Black vs #777777" + ); + } + + // ------------------------------------------------------------- + // 3. Symmetry: contrast(a,b) == contrast(b,a) + // ------------------------------------------------------------- + [Test] + public function testContrast_Symmetry():void + { + var a:Array = [200, 50, 100]; + var b:Array = [20, 180, 220]; + + var ab:Number = ColorUtils.contrast(a, b); + var ba:Number = ColorUtils.contrast(b, a); + + assertCloseTo(ab, ba, 0.000001, "Contrast must be symmetric"); + } + + // ------------------------------------------------------------- + // 4. Monotonicity: lighter colors produce higher contrast + // ------------------------------------------------------------- + [Test] + public function testContrast_Monotonicity():void + { + var black:Array = [0,0,0]; + + var c1:Number = ColorUtils.contrast([50,50,50], black); + var c2:Number = ColorUtils.contrast([120,120,120], black); + var c3:Number = ColorUtils.contrast([200,200,200], black); + + assertTrue(c1 < c2, "Contrast should increase with luminance"); + assertTrue(c2 < c3, "Contrast should increase with luminance"); + } + + // ------------------------------------------------------------- + // 5. Edge cases: identical colors → contrast = 1.0 + // ------------------------------------------------------------- + [Test] + public function testContrast_IdenticalColors():void + { + assertCloseTo( + ColorUtils.contrast([100,100,100], [100,100,100]), + 1.0, 0.0001, + "Identical colors must have contrast 1.0" + ); + } + } +} diff --git a/frameworks/projects/Style/src/test/royale/flexUnitTests/ColorUtilsContrastTest.as b/frameworks/projects/Style/src/test/royale/flexUnitTests/ColorUtilsContrastTest.as new file mode 100644 index 0000000000..d28187e500 --- /dev/null +++ b/frameworks/projects/Style/src/test/royale/flexUnitTests/ColorUtilsContrastTest.as @@ -0,0 +1,295 @@ +//////////////////////////////////////////////////////////////////////////////// +// +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//////////////////////////////////////////////////////////////////////////////// +package flexUnitTests +{ + import org.apache.royale.style.colors.ColorUtils; + import org.apache.royale.test.asserts.*; + + public class ColorUtilsContrastTest + { + [Before] public function setUp():void {} + [After] public function tearDown():void {} + [BeforeClass] public static function setUpBeforeClass():void {} + [AfterClass] public static function tearDownAfterClass():void {} + + private function contrast(rgb1:Array, rgb2:Array):Number + { + return ColorUtils.contrast(rgb1, rgb2); + } + + private function toRGB(lch:Array):Array + { + return ColorUtils.oklch_ToRGB(lch); + } + + private function toLCH(rgb:Array):Array + { + return ColorUtils.rgb_ToOKLCH(rgb); + } + + // ------------------------------------------------------------- + // 1. Very Dark Background (navy) + // ------------------------------------------------------------- + [Test] + public function testVeryDarkBackground():void + { + var bg:Array = [0.08, 0.12, 260]; // navy blue + var wantLight:Boolean = true; + + var fg:Array = ColorUtils.generateContrastLCH(bg, wantLight); + + assertTrue(fg[0] > bg[0], "Foreground should be lighter"); + + if (fg[1] > 0.02) + { + var dh:Number = Math.abs(fg[2] - bg[2]); + dh = Math.min(dh, 360 - dh); + assertTrue(dh < 5.0, "Hue should be preserved"); + } + + assertTrue(contrast(toRGB(fg), toRGB(bg)) >= 4.5); + } + + // ------------------------------------------------------------- + // 2. Very Light Background (pastel warm green) + // ------------------------------------------------------------- + [Test] + public function testVeryLightBackground():void + { + var bg:Array = [0.96, 0.03, 100]; + var wantLight:Boolean = false; + + var fg:Array = ColorUtils.generateContrastLCH(bg, wantLight); + + assertTrue(fg[0] < bg[0], "Foreground should be darker"); + + if (fg[1] > 0.02) + { + var dh:Number = Math.abs(fg[2] - bg[2]); + dh = Math.min(dh, 360 - dh); + assertTrue(dh < 5.0); + } + + assertTrue(contrast(toRGB(fg), toRGB(bg)) >= 4.5); + } + + // ------------------------------------------------------------- + // 3. Mid‑tone Saturated Blue (blue‑500‑like) + // ------------------------------------------------------------- + [Test] + public function testMidToneBlue():void + { + var bg:Array = [0.55, 0.20, 250]; + var wantLight:Boolean = false; + + var fg:Array = ColorUtils.generateContrastLCH(bg, wantLight); + + assertTrue(fg[0] < bg[0], "Foreground should be darker"); + + if (fg[1] > 0.02) + { + var dh:Number = Math.abs(fg[2] - bg[2]); + dh = Math.min(dh, 360 - dh); + assertTrue(dh < 5.0); + } + // Soft sanity check only – not a hard 4.5 requirement + assertTrue(contrast(toRGB(fg), toRGB(bg)) >= 3.0, "Contrast should be reasonably high"); + } + + //rgb version api + [Test] + public function testChooseContrastRGB_MidToneBlue():void + { + var bgLch:Array = [0.55, 0.20, 250]; + var bgRgb:Array = toRGB(bgLch); + + var fgRgb:Array = ColorUtils.chooseContrastRGB(bgRgb); + + assertTrue(contrast(fgRgb, bgRgb) >= 4.5, "UI contrast must be >= 4.5"); + } + + [Test] + public function testMidToneBlue2():void + { + // There is no light foreground that can reach 4.5:1 on this bg. + var bg:Array = [0.663, 0.266, 255.3]; + var wantLight:Boolean = true; + + var fg:Array = ColorUtils.generateContrastLCH(bg, wantLight); + + // Foreground must be lighter + assertTrue(fg[0] > bg[0], "Foreground should be lighter"); + + // If chroma is significant, hue must be preserved + if (fg[1] > 0.02) + { + var dh:Number = Math.abs(fg[2] - bg[2]); + dh = Math.min(dh, 360 - dh); + assertTrue(dh < 5.0, "Hue should be preserved"); + } + + // We expect the solver to return the lightest possible (white), + // even though contrast will be < 4.5. + var cr:Number = ColorUtils.contrast( + ColorUtils.oklch_ToRGB(fg), + ColorUtils.oklch_ToRGB(bg) + ); + assertTrue(cr < 4.5); + assertEquals(1, fg[0]); // L = 1 + assertEquals(0, fg[1]); // C = 0 + + } + + // ------------------------------------------------------------- + // 4. Mid‑tone Neutral Gray + // ------------------------------------------------------------- + [Test] + public function testNeutralGray():void + { + var bg:Array = [0.55, 0.005, 0]; // mid‑tone neutral gray + var wantLight:Boolean = false; + + var fg:Array = ColorUtils.generateContrastLCH(bg, wantLight); + + assertTrue(fg[0] < bg[0], "Foreground should be darker"); + assertTrue(fg[1] <= bg[1] + 0.03, "Chroma should remain low"); + // Soft sanity check only + assertTrue(contrast(toRGB(fg), toRGB(bg)) >= 3.0, "Contrast should be reasonably high"); + } + + //rgb version api + [Test] + public function testChooseContrastRGB_NeutralGray():void + { + var bgLch:Array = [0.55, 0.005, 0]; // mid‑tone neutral gray + var bgRgb:Array = toRGB(bgLch); + + var fgRgb:Array = ColorUtils.chooseContrastRGB(bgRgb); + + assertTrue(contrast(fgRgb, bgRgb) >= 4.5, "UI contrast must be >= 4.5"); + } + + // ------------------------------------------------------------- + // 5. Highly Saturated Red + // ------------------------------------------------------------- + [Test] + public function testSaturatedRed():void + { + var bg:Array = [0.60, 0.28, 25]; + var wantLight:Boolean = false; + + var fg:Array = ColorUtils.generateContrastLCH(bg, wantLight); + + assertTrue(fg[0] < bg[0], "Foreground should be darker"); + assertTrue(fg[1] < bg[1], "Chroma should be reduced"); + + assertTrue(contrast(toRGB(fg), toRGB(bg)) >= 4.5); + } + + // ------------------------------------------------------------- + // 6. Low‑Chroma Pastel Green + // ------------------------------------------------------------- + [Test] + public function testPastelGreen():void + { + var bg:Array = [0.88, 0.04, 130]; + var wantLight:Boolean = false; + + var fg:Array = ColorUtils.generateContrastLCH(bg, wantLight); + + assertTrue(fg[0] < bg[0], "Foreground should be darker"); + assertTrue(fg[1] <= 0.05, "Chroma should remain low"); + + assertTrue(contrast(toRGB(fg), toRGB(bg)) >= 4.5); + } + + // ------------------------------------------------------------- + // 7. Black Background + // ------------------------------------------------------------- + [Test] + public function testBlackBackground():void + { + var bg:Array = [0.00, 0.00, 0]; + var wantLight:Boolean = true; + + var fg:Array = ColorUtils.generateContrastLCH(bg, wantLight); + + assertTrue(fg[0] > 0.50, "Foreground should be lighter"); + assertTrue(fg[1] <= 0.02, "Chroma should remain near zero"); + + assertTrue(contrast(toRGB(fg), toRGB(bg)) >= 4.5); + } + + // ------------------------------------------------------------- + // 8. White Background + // ------------------------------------------------------------- + [Test] + public function testWhiteBackground():void + { + var bg:Array = [0.97, 0.00, 0]; // corrected white + var wantLight:Boolean = false; + + var fg:Array = ColorUtils.generateContrastLCH(bg, wantLight); + + assertTrue(fg[0] < bg[0], "Foreground should be darker"); + assertTrue(fg[1] <= 0.02, "Chroma should remain near zero"); + + assertTrue(contrast(toRGB(fg), toRGB(bg)) >= 4.5); + } + + // ------------------------------------------------------------- + // 9. Hue Preservation Across Multiple Hues + // ------------------------------------------------------------- + [Test] + public function testHuePreservation():void + { + var hues:Array = [0, 30, 60, 120, 180, 240, 300]; + + for each (var h:Number in hues) + { + var bg:Array = [0.50, 0.12, h]; // gamut‑safe chroma + var wantLight:Boolean = false; + + var fg:Array = ColorUtils.generateContrastLCH(bg, wantLight); + + if (fg[1] > 0.02) + { + var dh:Number = Math.abs(fg[2] - h); + dh = Math.min(dh, 360 - dh); + assertTrue(dh < 5.0, "Hue should be preserved for h=" + h); + } + } + } + + // ------------------------------------------------------------- + // 10. Round‑Trip OKLCH Validity + // ------------------------------------------------------------- + [Test] + public function testRoundTripStability():void + { + var bg:Array = [0.40, 0.15, 200]; // gamut‑safe chroma + var wantLight:Boolean = true; + + var fg:Array = ColorUtils.generateContrastLCH(bg, wantLight); + var rt:Array = toLCH(toRGB(fg)); + + assertTrue(Math.abs(rt[0] - fg[0]) < 0.02); + } + } +} diff --git a/frameworks/projects/Style/src/test/royale/flexUnitTests/ThemeColorSetTest.as b/frameworks/projects/Style/src/test/royale/flexUnitTests/ThemeColorSetTest.as new file mode 100644 index 0000000000..781ad93937 --- /dev/null +++ b/frameworks/projects/Style/src/test/royale/flexUnitTests/ThemeColorSetTest.as @@ -0,0 +1,206 @@ +//////////////////////////////////////////////////////////////////////////////// +// +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//////////////////////////////////////////////////////////////////////////////// +package flexUnitTests +{ + import org.apache.royale.style.colors.ThemeColorSet; + import org.apache.royale.style.colors.ColorSwatch; + import org.apache.royale.style.colors.ColorUtils; + import org.apache.royale.test.asserts.*; + import org.apache.royale.utils.CSSUtils; + + /** + * Tests for ThemeColorSet contrast and variant resolution logic. + */ + public class ThemeColorSetTest + { + private var themeSet:ThemeColorSet; + + [Before] + public function setUp():void + { + // Default theme-like configuration + themeSet = new ThemeColorSet({ + 'primary': 'blue', + 'secondary': 'rose', + 'neutral': 'slate' + }); + } + + [After] + public function tearDown():void + { + themeSet = null; + } + + + public function getRGBColor(swatch:ColorSwatch):uint + { + var rgb:Array = swatch.getRGB(); + if (!rgb || rgb.length < 3) return 0; + var rr:uint = rgb[0]; + var gg:uint = rgb[1]; + var bb:uint = rgb[2]; + return (rr << 16) | (gg << 8) | bb; + } + + // --------------------------------------------------------------------- + // 1. Basic Contrast Resolution + // --------------------------------------------------------------------- + + [Test] + public function testFindContrastVariant_Strong_DarkBackground():void + { + // Slate-900 is very dark + var result:ColorSwatch = themeSet.findContrastVariant("slate", 900, false, false); + + // Should result in a light color + var bgRgb:Array = ColorUtils.getVariation(CSSUtils.toColor(ColorSwatch.getColorValue("slate")), 90, false); + var fgRgb:Array = result.getRGB(); + + var cr:Number = ColorUtils.contrast(fgRgb, bgRgb); + assertTrue(cr >= 4.5, "Contrast ratio must be at least 4.5 for strong variant. Got: " + cr); + + // On dark background, it should be significantly lighter + assertTrue(ColorUtils.relativeLuminance(fgRgb) > ColorUtils.relativeLuminance(bgRgb), "Foreground should be lighter than background"); + } + + [Test] + public function testFindContrastVariant_Strong_LightBackground():void + { + // Slate-50 is very light + var result:ColorSwatch = themeSet.findContrastVariant("slate", 50, false, false); + + var bgRgb:Array = ColorUtils.getVariation(CSSUtils.toColor(ColorSwatch.getColorValue("slate")), 5, false); + var fgRgb:Array = result.getRGB(); + + var cr:Number = ColorUtils.contrast(fgRgb, bgRgb); + assertTrue(cr >= 4.5, "Contrast ratio must be at least 4.5. Got: " + cr); + + // On light background, it should be significantly darker + assertTrue(ColorUtils.relativeLuminance(fgRgb) < ColorUtils.relativeLuminance(bgRgb), "Foreground should be darker than background"); + } + + // --------------------------------------------------------------------- + // 2. Weak Contrast Resolution + // --------------------------------------------------------------------- + + [Test] + public function testFindContrastVariant_Weak():void + { + var strong:ColorSwatch = themeSet.findContrastVariant("blue", 500, false, false); + var weak:ColorSwatch = themeSet.findContrastVariant("blue", 500, false, true); + + var bgRgb:Array = ColorUtils.getVariation(CSSUtils.toColor(ColorSwatch.getColorValue("blue")), 50, false); + var strongRgb:Array = strong.getRGB(); + var weakRgb:Array = weak.getRGB(); + + var strongCr:Number = ColorUtils.contrast(strongRgb, bgRgb); + var weakCr:Number = ColorUtils.contrast(weakRgb, bgRgb); + + // Weak contrast should be lower than strong contrast, but still provide some separation + assertTrue(weakCr < strongCr, "Weak contrast should be less than strong contrast"); + assertTrue(weakCr > 1.2, "Weak contrast should still be visible (> 1.2)"); + } + + // --------------------------------------------------------------------- + // 3. Palette Limitation (limitRange) + // --------------------------------------------------------------------- + + [Test] + public function testFindContrastVariant_LimitRange():void + { + // If we limit range, the result MUST be one of the base colors in the ThemeColorSet (primary, secondary, neutral, etc.) + // or their variations. + var result:ColorSwatch = themeSet.findContrastVariant("primary", 500, false, false, true); + + // The result swatch's base name should be found in our theme set keys + var validBases:Array = ["blue", "rose", "slate"]; // derived from setUp + // Also allow "neutral" if it was used as default + validBases.push("neutral"); + var resultBase:String = result.colorBase; + + assertTrue(validBases.indexOf(resultBase) != -1, "Result base '" + resultBase + "' should be one of the theme bases: " + validBases.join(", ")); + } + + [Test] + public function testFindContrastVariant_IncludeBlackAndWhite():void + { + themeSet.includeBlackAndWhite = true; + + // 1. Test Light Background -> Should snap to BLACK + // Slate-50 is very light (L ~ 0.95) + var resultBlack:ColorSwatch = themeSet.findContrastVariant("slate", 50, false, false, true); + assertEquals("black", resultBlack.colorBase, "Should snap to 'black' for light background when enabled"); + assertEquals(0x000000, getRGBColor(resultBlack), "Black swatch should be 0x000000"); + + // 2. Test Dark Background -> Should snap to WHITE + // Slate-900 is very dark (L ~ 0.1) + var resultWhite:ColorSwatch = themeSet.findContrastVariant("slate", 900, false, false, true); + assertEquals("white", resultWhite.colorBase, "Should snap to 'white' for dark background when enabled"); + assertEquals(0xFFFFFF, getRGBColor(resultWhite), "White swatch should be 0xFFFFFF"); + } + + [Test] + public function testFindContrastVariant_ExcludeBlackAndWhite():void + { + themeSet.includeBlackAndWhite = false; // Default + + // Even on very light background, it should NOT snap to black if limited and not included + var result:ColorSwatch = themeSet.findContrastVariant("slate", 50, false, false, true); + + assertFalse(result.colorBase == "black", "Should NOT snap to 'black' when includeBlackAndWhite is false"); + assertFalse(result.colorBase == "white", "Should NOT snap to 'white' when includeBlackAndWhite is false"); + + var validBases:Array = ["blue", "rose", "slate", "neutral"]; + assertTrue(validBases.indexOf(result.colorBase) != -1, "Result base '" + result.colorBase + "' should be one of the theme bases"); + } + + // --------------------------------------------------------------------- + // 4. Hue Preservation Check + // --------------------------------------------------------------------- + + [Test] + public function testContrast_HuePreservation():void + { + // Use a shade where hue preservation is actually possible + var result:ColorSwatch = themeSet.findContrastVariant("blue", 300, false, false); + + var bgRgb:Array = ColorUtils.getVariation( + CSSUtils.toColor(ColorSwatch.getColorValue("blue")), + 30, // 300 → 30 after /10 rounding + false + ); + + var bgLch:Array = ColorUtils.rgb_ToOKLCH(bgRgb); + var fgLch:Array = ColorUtils.rgb_ToOKLCH(result.getRGB()); + + // If chroma is tiny, hue is meaningless → skip + if (fgLch[1] <= 0.01) + return; // this is the case for the current test + + // Compute hue difference with wrap-around + var hueDiff:Number = Math.abs(bgLch[2] - fgLch[2]); + if (hueDiff > 180) hueDiff = 360 - hueDiff; + + // Now hue preservation is expected + assertTrue(hueDiff < 30, + "Contrast color should preserve background hue within 30 degrees. Diff: " + hueDiff); + } + } +}
