From 820699fa9df80db65134f9946f552c8b139b1277 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Thu, 13 Nov 2025 13:28:41 +0100 Subject: [PATCH 01/10] Remove mix-blend-mode from fallback highlight --- navigator-html-injectables/src/modules/Decorator.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/navigator-html-injectables/src/modules/Decorator.ts b/navigator-html-injectables/src/modules/Decorator.ts index f804aef9..570191a0 100644 --- a/navigator-html-injectables/src/modules/Decorator.ts +++ b/navigator-html-injectables/src/modules/Decorator.ts @@ -4,9 +4,7 @@ import { Module } from "./Module"; import { rangeFromLocator } from "../helpers/locator"; import { ModuleName } from "./ModuleLibrary"; import { Rect, getClientRectsNoOverlap } from "../helpers/rect"; -import { getProperty } from "../helpers/css"; import { ReadiumWindow } from "../helpers/dom"; -import { isDarkColor } from "../helpers/color"; export enum Width { Wrap = "wrap", // Smallest width fitting the CSS border box. @@ -249,18 +247,16 @@ class DecorationGroup { let template = this.wnd.document.createElement("template"); // template.innerHTML = item.decoration.element.trim(); - // TODO more styles logic - - const isDarkMode = getProperty(this.wnd, "--USER__appearance") === "readium-night-on" || - isDarkColor(getProperty(this.wnd, "--USER__backgroundColor")); + // Previously we tried to use CSS mix-blend-mode to guarantee contrast, but it was inconsistent + // with the native highlight, and was not good enough given the background-color can be completely + // arbitrary, and no longer just Readium CSS’ night mode, which was removed in V2 anyway. + // In the future, a set of color helpers will be added to help with this. template.innerHTML = `
Date: Thu, 13 Nov 2025 13:36:26 +0100 Subject: [PATCH 02/10] Rename class of templated div This is more consistent with the ids and classNames we use across the ts-toolkit packages. --- navigator-html-injectables/src/modules/Decorator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/navigator-html-injectables/src/modules/Decorator.ts b/navigator-html-injectables/src/modules/Decorator.ts index 570191a0..50503c05 100644 --- a/navigator-html-injectables/src/modules/Decorator.ts +++ b/navigator-html-injectables/src/modules/Decorator.ts @@ -254,7 +254,7 @@ class DecorationGroup { template.innerHTML = `
Date: Thu, 13 Nov 2025 13:44:40 +0100 Subject: [PATCH 03/10] Add data-readium to templated highlight Consistency with existing Readium-injected features. --- navigator-html-injectables/src/modules/Decorator.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/navigator-html-injectables/src/modules/Decorator.ts b/navigator-html-injectables/src/modules/Decorator.ts index 50503c05..4151925d 100644 --- a/navigator-html-injectables/src/modules/Decorator.ts +++ b/navigator-html-injectables/src/modules/Decorator.ts @@ -254,6 +254,7 @@ class DecorationGroup { template.innerHTML = `
Date: Fri, 14 Nov 2025 10:28:47 +0100 Subject: [PATCH 04/10] Revert "Remove mix-blend-mode from fallback highlight" This reverts commit 820699fa9df80db65134f9946f552c8b139b1277. --- navigator-html-injectables/src/modules/Decorator.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/navigator-html-injectables/src/modules/Decorator.ts b/navigator-html-injectables/src/modules/Decorator.ts index 4151925d..49f6f5cb 100644 --- a/navigator-html-injectables/src/modules/Decorator.ts +++ b/navigator-html-injectables/src/modules/Decorator.ts @@ -4,7 +4,9 @@ import { Module } from "./Module"; import { rangeFromLocator } from "../helpers/locator"; import { ModuleName } from "./ModuleLibrary"; import { Rect, getClientRectsNoOverlap } from "../helpers/rect"; +import { getProperty } from "../helpers/css"; import { ReadiumWindow } from "../helpers/dom"; +import { isDarkColor } from "../helpers/color"; export enum Width { Wrap = "wrap", // Smallest width fitting the CSS border box. @@ -247,10 +249,10 @@ class DecorationGroup { let template = this.wnd.document.createElement("template"); // template.innerHTML = item.decoration.element.trim(); - // Previously we tried to use CSS mix-blend-mode to guarantee contrast, but it was inconsistent - // with the native highlight, and was not good enough given the background-color can be completely - // arbitrary, and no longer just Readium CSS’ night mode, which was removed in V2 anyway. - // In the future, a set of color helpers will be added to help with this. + // TODO more styles logic + + const isDarkMode = getProperty(this.wnd, "--USER__appearance") === "readium-night-on" || + isDarkColor(getProperty(this.wnd, "--USER__backgroundColor")); template.innerHTML = `
Date: Fri, 14 Nov 2025 11:56:10 +0100 Subject: [PATCH 05/10] Update color helpers Adjust sRGB, remove helpers that were not tested or do not handle features properly, check dark/light using actual contrast against black and white --- .../src/helpers/color.ts | 131 ++++++------------ 1 file changed, 44 insertions(+), 87 deletions(-) diff --git a/navigator-html-injectables/src/helpers/color.ts b/navigator-html-injectables/src/helpers/color.ts index 37af7186..4f139c5a 100644 --- a/navigator-html-injectables/src/helpers/color.ts +++ b/navigator-html-injectables/src/helpers/color.ts @@ -1,114 +1,71 @@ export const colorToRgba = (color: string): { r: number; g: number; b: number; a: number; } => { if (color.startsWith("rgb")) { - const rgb = color.match(/rgb\((\d+),\s(\d+),\s(\d+)(?:,\s(\d+))?\)/); + const rgb = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/i); if (rgb) { return { - r: parseInt(rgb[1], 10), - g: parseInt(rgb[2], 10), - b: parseInt(rgb[3], 10), - a: rgb[4] ? parseInt(rgb[4], 10) / 255 : 1, + r: parseInt(rgb[1], 10), // 0-255 + g: parseInt(rgb[2], 10), // 0-255 + b: parseInt(rgb[3], 10), // 0-255 + a: rgb[4] ? parseFloat(rgb[4]) : 1, // 0-1 }; } } else if (color.startsWith("#")) { const hex = color.slice(1); if (hex.length === 3 || hex.length === 4) { return { - r: parseInt(hex[0] + hex[0], 16) / 255, - g: parseInt(hex[1] + hex[1], 16) / 255, - b: parseInt(hex[2] + hex[2], 16) / 255, - a: hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1, + r: parseInt(hex[0] + hex[0], 16), // 0-255 + g: parseInt(hex[1] + hex[1], 16), // 0-255 + b: parseInt(hex[2] + hex[2], 16), // 0-255 + a: hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1, // 0-1 }; } else if (hex.length === 6 || hex.length === 8) { return { - r: parseInt(hex[0] + hex[1], 16) / 255, - g: parseInt(hex[2] + hex[3], 16) / 255, - b: parseInt(hex[4] + hex[5], 16) / 255, - a: hex.length === 8 ? parseInt(hex[6] + hex[7], 16) / 255 : 1, + r: parseInt(hex[0] + hex[1], 16), // 0-255 + g: parseInt(hex[2] + hex[3], 16), // 0-255 + b: parseInt(hex[4] + hex[5], 16), // 0-255 + a: hex.length === 8 ? parseInt(hex[6] + hex[7], 16) / 255 : 1, // 0-1 }; } } - return { r: 0, g: 0, b: 0, a: 1 }; + return { r: 255, g: 255, b: 255, a: 1 }; // Default to white (255, 255, 255, 1) }; -export const getLuminance = (color: { r: number; g: number; b: number; a: number }): number => { - return 0.2126 * color.r * color.a + 0.7152 * color.g * color.a + 0.0722 * color.b * color.a; -} - -export const isDarkColor = (color: string): boolean => { - const rgba = colorToRgba(color); - const luminance = getLuminance(rgba); - return luminance < 128; +const toLinear = (c: number): number => { + const normalized = c / 255; + return normalized <= 0.03928 + ? normalized / 12.92 + : Math.pow((normalized + 0.055) / 1.055, 2.4); }; -export const isLightColor = (color: string): boolean => !isDarkColor(color); +export const getLuminance = (color: { r: number; g: number; b: number; a?: number }): number => { + // Convert sRGB to linear RGB and apply WCAG 2.0 formula + const r = toLinear(color.r); + const g = toLinear(color.g); + const b = toLinear(color.b); -export const checkContrast = (color1: string, color2: string): number => { - const rgba1 = colorToRgba(color1); - const rgba2 = colorToRgba(color2); - const lum1 = getLuminance(rgba1); - const lum2 = getLuminance(rgba2); - const brightest = Math.max(lum1, lum2); - const darkest = Math.min(lum1, lum2); - return (brightest + 0.05) / (darkest + 0.05); -}; + // WCAG 2.0 relative luminance formula (returns 0-1) + const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; -export const ensureContrast = (color1: string, color2: string, contrast: number = 4.5): string[] => { - const c1 = colorToRgba(color1); - const c2 = colorToRgba(color2); - - const lum1 = getLuminance(c1); - const lum2 = getLuminance(c2); - const [darkest, brightest] = lum1 < lum2 ? [lum1, lum2] : [lum2, lum1]; - - const contrastRatio = (brightest + 0.05) / (darkest + 0.05); - if (contrastRatio >= contrast) { - return [ - `rgba(${c1.r}, ${c1.g}, ${c1.b}, ${c1.a})`, - `rgba(${c2.r}, ${c2.g}, ${c2.b}, ${c2.a})` - ]; - } + // Apply alpha if provided (0-1 range) + return color.a !== undefined ? luminance * color.a : luminance; +}; - const adjustColor = (color: { r: number; g: number; b: number; a: number }, delta: number) => ({ - r: Math.max(0, Math.min(255, color.r + delta)), - g: Math.max(0, Math.min(255, color.g + delta)), - b: Math.max(0, Math.min(255, color.b + delta)), - a: color.a - }); - - const delta = ((contrast - contrastRatio) * 255) / (contrastRatio + 0.05); - let correctedColor: { r: number; g: number; b: number; a: number }; - let otherColor: { r: number; g: number; b: number; a: number }; - if (lum1 < lum2) { - correctedColor = c1; - otherColor = c2; - } else { - correctedColor = c2; - otherColor = c1; - } +export const checkContrast = (color1: string, color2: string): number => { + const luminance1 = getLuminance(colorToRgba(color1)); + const luminance2 = getLuminance(colorToRgba(color2)); - const correctedColorAdjusted = adjustColor(correctedColor, -delta); - const newLum = getLuminance(correctedColorAdjusted); - const newContrastRatio = (brightest + 0.05) / (newLum + 0.05); + // Ensure luminance1 is the lighter color + const l1 = Math.max(luminance1, luminance2); + const l2 = Math.min(luminance1, luminance2); - if (newContrastRatio < contrast) { - const updatedDelta = ((contrast - newContrastRatio) * 255) / (newContrastRatio + 0.05); - const otherColorAdjusted = adjustColor(otherColor, updatedDelta); - return [ - lum1 < lum2 - ? `rgba(${correctedColorAdjusted.r}, ${correctedColorAdjusted.g}, ${correctedColorAdjusted.b}, ${correctedColorAdjusted.a})` - : `rgba(${otherColorAdjusted.r}, ${otherColorAdjusted.g}, ${otherColorAdjusted.b}, ${otherColorAdjusted.a})`, - lum1 < lum2 - ? `rgba(${otherColorAdjusted.r}, ${otherColorAdjusted.g}, ${otherColorAdjusted.b}, ${otherColorAdjusted.a})` - : `rgba(${correctedColorAdjusted.r}, ${correctedColorAdjusted.g}, ${correctedColorAdjusted.b}, ${correctedColorAdjusted.a})`, - ]; - } + // WCAG 2.0 contrast ratio formula + return (l1 + 0.05) / (l2 + 0.05); +}; - return [ - lum1 < lum2 - ? `rgba(${correctedColorAdjusted.r}, ${correctedColorAdjusted.g}, ${correctedColorAdjusted.b}, ${correctedColorAdjusted.a})` - : `rgba(${otherColor.r}, ${otherColor.g}, ${otherColor.b}, ${otherColor.a})`, - lum1 < lum2 - ? `rgba(${otherColor.r}, ${otherColor.g}, ${otherColor.b}, ${otherColor.a})` - : `rgba(${correctedColorAdjusted.r}, ${correctedColorAdjusted.g}, ${correctedColorAdjusted.b}, ${correctedColorAdjusted.a})`, - ]; +export const isDarkColor = (color: string): boolean => { + const contrastWithWhite = checkContrast(color, "#FFFFFF"); + const contrastWithBlack = checkContrast(color, "#000000"); + return contrastWithWhite > contrastWithBlack; }; + +export const isLightColor = (color: string): boolean => !isDarkColor(color); \ No newline at end of file From 2241662af3125c4bb54c30d47d2adea5ebbaa98c Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Fri, 14 Nov 2025 12:04:32 +0100 Subject: [PATCH 06/10] Add check for computed style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In case we do not find a backgroundColor or we cannot convert it to rgba (e.g. color names, unsupported format…) --- navigator-html-injectables/src/modules/Decorator.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/navigator-html-injectables/src/modules/Decorator.ts b/navigator-html-injectables/src/modules/Decorator.ts index 49f6f5cb..e09c888e 100644 --- a/navigator-html-injectables/src/modules/Decorator.ts +++ b/navigator-html-injectables/src/modules/Decorator.ts @@ -252,7 +252,8 @@ class DecorationGroup { // TODO more styles logic const isDarkMode = getProperty(this.wnd, "--USER__appearance") === "readium-night-on" || - isDarkColor(getProperty(this.wnd, "--USER__backgroundColor")); + isDarkColor(getProperty(this.wnd, "--USER__backgroundColor")) || + isDarkColor(this.wnd.getComputedStyle(this.wnd.document.documentElement).getPropertyValue("background-color")); template.innerHTML = `
Date: Sun, 16 Nov 2025 17:30:26 +0100 Subject: [PATCH 07/10] Observe background change for fallback highlights This makes things at least a little more consistent with the Highlight API behaviour, although you get some blending. And images are not handled that well. --- .../src/modules/Decorator.ts | 60 ++++++++++++++++++- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/navigator-html-injectables/src/modules/Decorator.ts b/navigator-html-injectables/src/modules/Decorator.ts index e09c888e..88e814c6 100644 --- a/navigator-html-injectables/src/modules/Decorator.ts +++ b/navigator-html-injectables/src/modules/Decorator.ts @@ -251,9 +251,7 @@ class DecorationGroup { // template.innerHTML = item.decoration.element.trim(); // TODO more styles logic - const isDarkMode = getProperty(this.wnd, "--USER__appearance") === "readium-night-on" || - isDarkColor(getProperty(this.wnd, "--USER__backgroundColor")) || - isDarkColor(this.wnd.getComputedStyle(this.wnd.document.documentElement).getPropertyValue("background-color")); + const isDarkMode = this.getCurrentDarkMode(); template.innerHTML = `
{ + (highlight as HTMLElement).style.setProperty("mix-blend-mode", isDarkMode ? "exclusion" : "multiply", "important"); + }); + } + + private extractCustomProperty(style: string | null, propertyName: string): string | null { + if (!style) return null; + + const match = style.match(new RegExp(`${propertyName}:\\s*([^;]+)`)); + return match ? match[1].trim() : null; + } + private handleResize() { this.wnd.clearTimeout(this.resizeFrame); this.resizeFrame = this.wnd.setTimeout(() => { @@ -444,6 +465,38 @@ export class Decorator extends Module { wnd.addEventListener("orientationchange", this.handleResizer); wnd.addEventListener("resize", this.handleResizer); + // Set up MutationObserver to watch for CSS custom property changes + this.backgroundObserver = new MutationObserver((mutations) => { + const shouldUpdate = mutations.some(mutation => { + if (mutation.type === "attributes" && mutation.attributeName === "style") { + const element = mutation.target as Element; + const oldStyle = mutation.oldValue; + const newStyle = element.getAttribute("style"); + + // Check if the relevant CSS custom properties actually changed + const oldAppearance = this.extractCustomProperty(oldStyle, "--USER__appearance"); + const newAppearance = this.extractCustomProperty(newStyle, "--USER__appearance"); + const oldBgColor = this.extractCustomProperty(oldStyle, "--USER__backgroundColor"); + const newBgColor = this.extractCustomProperty(newStyle, "--USER__backgroundColor"); + + return oldAppearance !== newAppearance || + oldBgColor !== newBgColor; + } + return false; + }); + + if (shouldUpdate) { + this.updateAllBlendModes(); + } + }); + + this.backgroundObserver.observe(wnd.document.documentElement, { + attributes: true, + attributeFilter: ["style"], + attributeOldValue: true, + subtree: true + }); + comms.log("Decorator Mounted"); return true; } @@ -454,6 +507,7 @@ export class Decorator extends Module { comms.unregisterAll(Decorator.moduleName); this.resizeObserver.disconnect(); + this.backgroundObserver.disconnect(); this.cleanup(); comms.log("Decorator Unmounted"); From 267aff543f0c0d36b9bebb2084df8d27441842a6 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Sun, 16 Nov 2025 21:40:18 +0100 Subject: [PATCH 08/10] Enforce contrast color for highlight And cover non HEX and RGB colors in conversion helper --- navigator-html-injectables/src/helpers/color.ts | 17 ++++++++++++++++- .../src/modules/Decorator.ts | 10 ++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/navigator-html-injectables/src/helpers/color.ts b/navigator-html-injectables/src/helpers/color.ts index 4f139c5a..7c602056 100644 --- a/navigator-html-injectables/src/helpers/color.ts +++ b/navigator-html-injectables/src/helpers/color.ts @@ -1,4 +1,15 @@ export const colorToRgba = (color: string): { r: number; g: number; b: number; a: number; } => { + // Handle colors by using Canvas API's color conversion + if (!color.startsWith("#") && !color.startsWith("rgb")) { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.fillStyle = color; + const computedColor = ctx.fillStyle; + color = computedColor; + } + } + if (color.startsWith("rgb")) { const rgb = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/i); if (rgb) { @@ -68,4 +79,8 @@ export const isDarkColor = (color: string): boolean => { return contrastWithWhite > contrastWithBlack; }; -export const isLightColor = (color: string): boolean => !isDarkColor(color); \ No newline at end of file +export const isLightColor = (color: string): boolean => !isDarkColor(color); + +export const getContrastingTextColor = (backgroundColor: string): "black" | "white" => { + return isDarkColor(backgroundColor) ? "white" : "black"; +}; \ No newline at end of file diff --git a/navigator-html-injectables/src/modules/Decorator.ts b/navigator-html-injectables/src/modules/Decorator.ts index 88e814c6..bac75332 100644 --- a/navigator-html-injectables/src/modules/Decorator.ts +++ b/navigator-html-injectables/src/modules/Decorator.ts @@ -6,7 +6,9 @@ import { ModuleName } from "./ModuleLibrary"; import { Rect, getClientRectsNoOverlap } from "../helpers/rect"; import { getProperty } from "../helpers/css"; import { ReadiumWindow } from "../helpers/dom"; -import { isDarkColor } from "../helpers/color"; +import { isDarkColor, getContrastingTextColor } from "../helpers/color"; + +const DEFAULT_HIGHLIGHT_COLOR = "#FFFF00"; // Yellow in HEX export enum Width { Wrap = "wrap", // Smallest width fitting the CSS border box. @@ -183,8 +185,8 @@ class DecorationGroup { // TODO add caching layer ("vdom") to this so we aren't completely replacing the CSS every time stylesheet.innerHTML = ` ::highlight(${this.id}) { - color: black; - background-color: ${item.decoration?.style?.tint ?? "yellow"}; + color: ${getContrastingTextColor(item.decoration?.style?.tint ?? DEFAULT_HIGHLIGHT_COLOR)}; + background-color: ${item.decoration?.style?.tint ?? DEFAULT_HIGHLIGHT_COLOR}; }`; } @@ -258,7 +260,7 @@ class DecorationGroup { data-readium="true" class="readium-highlight" style="${[ - `background-color: ${item.decoration?.style?.tint ?? "yellow"} !important`, + `background-color: ${item.decoration?.style?.tint ?? DEFAULT_HIGHLIGHT_COLOR} !important`, //"opacity: 0.3 !important", `mix-blend-mode: ${isDarkMode ? "exclusion" : "multiply"} !important`, "opacity: 1 !important", From 23683cdb90ca852f9eacac43e399e9e6dfa44838 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Mon, 17 Nov 2025 10:50:41 +0100 Subject: [PATCH 09/10] Update color conversion Lazy instantiate canvas, keep a cache of parsed colors, warn about non-parseable ones. --- .../src/helpers/color.ts | 117 ++++++++++++++---- 1 file changed, 90 insertions(+), 27 deletions(-) diff --git a/navigator-html-injectables/src/helpers/color.ts b/navigator-html-injectables/src/helpers/color.ts index 7c602056..c753b9cf 100644 --- a/navigator-html-injectables/src/helpers/color.ts +++ b/navigator-html-injectables/src/helpers/color.ts @@ -1,44 +1,106 @@ +// Lazy canvas initialization +let canvas: HTMLCanvasElement | null = null; +let ctx: CanvasRenderingContext2D | null = null; + +// Default color for failed conversions +const DEFAULT_COLOR = { r: 255, g: 255, b: 255, a: 1 }; + +// Cache for computed color conversions +const colorCache = new Map(); + +const getCanvasContext = () => { + if (!canvas) { + canvas = document.createElement("canvas"); + ctx = canvas.getContext("2d"); + } + return ctx; +}; + export const colorToRgba = (color: string): { r: number; g: number; b: number; a: number; } => { - // Handle colors by using Canvas API's color conversion - if (!color.startsWith("#") && !color.startsWith("rgb")) { - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - if (ctx) { - ctx.fillStyle = color; - const computedColor = ctx.fillStyle; - color = computedColor; + // Check cache first + const cached = colorCache.get(color); + if (cached !== undefined) { + if (cached === null) { + return DEFAULT_COLOR; // Return default white for previously failed colors } + return cached; } - if (color.startsWith("rgb")) { - const rgb = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/i); - if (rgb) { - return { - r: parseInt(rgb[1], 10), // 0-255 - g: parseInt(rgb[2], 10), // 0-255 - b: parseInt(rgb[3], 10), // 0-255 - a: rgb[4] ? parseFloat(rgb[4]) : 1, // 0-1 + // Use Canvas API to convert any CSS color to “standardized” format + const context = getCanvasContext(); + let computedColor = color; + + if (context) { + context.fillStyle = color; + computedColor = context.fillStyle; + } + + // Parse the computed color value from canvas + if (computedColor.startsWith("rgb")) { + // Regex that handles both comma and space separators, slash for alpha + const rgba = computedColor.match(/rgba?\(([\d.]+%?)[,\s]+([\d.]+%?)[,\s]+([\d.]+%?)(?:[\/,]\s*([\d.]+%?))?\)/); + + if (rgba) { + const parseValue = (val: string): number => { + if (val.endsWith("%")) { + return Math.round(parseFloat(val) * 2.55); // Convert percentage to 0-255 + } + return parseFloat(val); }; + + const parseAlpha = (val: string): number => { + if (val.endsWith("%")) { + return parseFloat(val) / 100; // Convert percentage to 0-1 + } + return parseFloat(val); + }; + + const result = { + r: parseValue(rgba[1]), // 0-255 + g: parseValue(rgba[2]), // 0-255 + b: parseValue(rgba[3]), // 0-255 + a: rgba[4] ? parseAlpha(rgba[4]) : 1, // 0-1 + }; + + // Cache the result for future use + colorCache.set(color, result); + return result; } - } else if (color.startsWith("#")) { - const hex = color.slice(1); + } else if (computedColor.startsWith("#")) { + const hex = computedColor.slice(1); + let result; + if (hex.length === 3 || hex.length === 4) { - return { + result = { r: parseInt(hex[0] + hex[0], 16), // 0-255 g: parseInt(hex[1] + hex[1], 16), // 0-255 b: parseInt(hex[2] + hex[2], 16), // 0-255 a: hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1, // 0-1 }; } else if (hex.length === 6 || hex.length === 8) { - return { + result = { r: parseInt(hex[0] + hex[1], 16), // 0-255 g: parseInt(hex[2] + hex[3], 16), // 0-255 b: parseInt(hex[4] + hex[5], 16), // 0-255 a: hex.length === 8 ? parseInt(hex[6] + hex[7], 16) / 255 : 1, // 0-1 }; + } else { + // Invalid hex length, cache null and return default + colorCache.set(color, null); + return DEFAULT_COLOR; } + + // Cache the result for future use + colorCache.set(color, result); + return result; } - return { r: 255, g: 255, b: 255, a: 1 }; // Default to white (255, 255, 255, 1) + + // If we couldn't parse the color, warn and return default + console.warn(`Could not parse color format: ${color}. Falling back to ${DEFAULT_COLOR} to check contrast. Please make sure your color value can be computed to HEX or RGB(A) format.`); + + // Cache null to avoid repeated warnings + entire conversion process + colorCache.set(color, null); + return DEFAULT_COLOR; }; const toLinear = (c: number): number => { @@ -49,16 +111,17 @@ const toLinear = (c: number): number => { }; export const getLuminance = (color: { r: number; g: number; b: number; a?: number }): number => { - // Convert sRGB to linear RGB and apply WCAG 2.0 formula + // Convert sRGB to linear RGB and apply WCAG 2.2 formula const r = toLinear(color.r); const g = toLinear(color.g); const b = toLinear(color.b); - // WCAG 2.0 relative luminance formula (returns 0-1) + // WCAG 2.2 relative luminance formula (returns 0-1) + // Note: Alpha is ignored for contrast calculations. WCAG 2.2 only defines contrast for opaque colors, + // and semi-transparent colors have a range of possible contrast ratios depending on background. + // For text readability decisions, we use the base color as the most conservative approach. const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; - - // Apply alpha if provided (0-1 range) - return color.a !== undefined ? luminance * color.a : luminance; + return luminance; }; export const checkContrast = (color1: string, color2: string): number => { @@ -69,7 +132,7 @@ export const checkContrast = (color1: string, color2: string): number => { const l1 = Math.max(luminance1, luminance2); const l2 = Math.min(luminance1, luminance2); - // WCAG 2.0 contrast ratio formula + // WCAG 2.2 contrast ratio formula return (l1 + 0.05) / (l2 + 0.05); }; From de7176dfd22e5dcaea8d1eee6b7e3bbd49f0d035 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Mon, 17 Nov 2025 11:31:27 +0100 Subject: [PATCH 10/10] Clarify warning for Decorator contrast check --- navigator-html-injectables/src/helpers/color.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/navigator-html-injectables/src/helpers/color.ts b/navigator-html-injectables/src/helpers/color.ts index c753b9cf..dbaa1d6a 100644 --- a/navigator-html-injectables/src/helpers/color.ts +++ b/navigator-html-injectables/src/helpers/color.ts @@ -96,7 +96,8 @@ export const colorToRgba = (color: string): { r: number; g: number; b: number; a } // If we couldn't parse the color, warn and return default - console.warn(`Could not parse color format: ${color}. Falling back to ${DEFAULT_COLOR} to check contrast. Please make sure your color value can be computed to HEX or RGB(A) format.`); + // Decorator-specific ATM + console.warn(`Decorator: could not parse color format: ${color}. Falling back to ${DEFAULT_COLOR} to check contrast. Please make sure your color value can be computed to HEX or RGB(A) format.`); // Cache null to avoid repeated warnings + entire conversion process colorCache.set(color, null);