Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
289 changes: 190 additions & 99 deletions navigator-html-injectables/src/helpers/color.ts
Original file line number Diff line number Diff line change
@@ -1,114 +1,205 @@
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+))?\)/);
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,
};
}
} 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,
};
} 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,
};
// Lazy canvas/offscreen canvas initialization for color conversion
let canvas: HTMLCanvasElement | OffscreenCanvas | null = null;
let ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null = null;

// Track which pixel to use next for color sampling (0-24 for 5x5 grid)
let currentPixelIndex = 0;

// 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<string, { r: number; g: number; b: number; a: number; } | null>();

const getCanvasContext = (): CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null => {
if (!canvas) {
// Try to use OffscreenCanvas if available
if (typeof OffscreenCanvas !== "undefined") {
canvas = new OffscreenCanvas(5, 5);
ctx = canvas.getContext("2d", {
willReadFrequently: true,
desynchronized: true
});
} else {
// Fall back to regular canvas
const htmlCanvas = document.createElement("canvas");
htmlCanvas.width = 5;
htmlCanvas.height = 5;
canvas = htmlCanvas;
ctx = htmlCanvas.getContext("2d", {
willReadFrequently: true,
desynchronized: true
});
}
}
return { r: 0, g: 0, b: 0, a: 1 };
return ctx;
};

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 isSpecialColorValue = (color: string): boolean => {
if (!color) return true;

const normalizedColor = color.trim().toLowerCase();

// Check for CSS variables
if (normalizedColor.startsWith("var(")) {
return true;
}

// Check for CSS color keywords
const cssKeywords = [
"transparent",
"currentcolor",
"inherit",
"initial",
"revert",
"unset",
"revert-layer"
];

if (cssKeywords.includes(normalizedColor)) {
return true;
}

// Check for gradients
const gradientTypes = [
"linear-gradient",
"radial-gradient",
"conic-gradient",
"repeating-linear-gradient",
"repeating-radial-gradient",
"repeating-conic-gradient"
];

return gradientTypes.some(grad => normalizedColor.includes(grad));
};

export const isLightColor = (color: string): boolean => !isDarkColor(color);

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);
const warnAboutInvalidColor = (color: string, reason: string): void => {
console.warn(
`[Decorator] Could not parse color: "${color}". ${reason} Falling back to ${JSON.stringify(DEFAULT_COLOR)} to compute contrast. Please use a CSS color value that can be computed to RGB(A).`
);
};

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})`
];
export const colorToRgba = (
color: string,
backgroundColor: string | null = null
): { r: number; g: number; b: number; a: number } => {
// Check cache with background key if provided
const cacheKey = backgroundColor ? `${color}|${backgroundColor}` : color;
const cached = colorCache.get(cacheKey);
if (cached !== undefined) {
return cached ?? DEFAULT_COLOR;
}

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;
// Check for special color values
if (isSpecialColorValue(color)) {
warnAboutInvalidColor(color, "Unsupported color format or special value.");
colorCache.set(cacheKey, null);
return DEFAULT_COLOR;
}

const correctedColorAdjusted = adjustColor(correctedColor, -delta);
const newLum = getLuminance(correctedColorAdjusted);
const newContrastRatio = (brightest + 0.05) / (newLum + 0.05);

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})`,
];
const context = getCanvasContext();
if (!context) {
warnAboutInvalidColor(color, "Could not get canvas context.");
colorCache.set(cacheKey, null);
return DEFAULT_COLOR;
}

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})`,
];
try {
// Clear and initialize canvas at the start of each cycle
if (currentPixelIndex === 0) {
context.clearRect(0, 0, 5, 5);
}

// Calculate which pixel to use for this operation
const x = currentPixelIndex % 5;
const y = Math.floor(currentPixelIndex / 5);

// Clear just this pixel to ensure clean state
context.clearRect(x, y, 1, 1);

// Fill background color if provided
if (backgroundColor) {
context.fillStyle = backgroundColor;
context.fillRect(x, y, 1, 1);
}

// Draw the color at the current pixel
context.fillStyle = color;
context.fillRect(x, y, 1, 1);

// Get the pixel data for this specific pixel
const imageData = context.getImageData(x, y, 1, 1);

// Move to next pixel for next call
currentPixelIndex = (currentPixelIndex + 1) % 25;

// Get the pixel data for the pixel we just sampled
const [r, g, b, a] = imageData.data;
Comment on lines +107 to +137
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@chocolatkey Just to double-check, is this what you had in mind?

I’m falling back to canvas if offscreencanvas is undefined above. And using a 5x5 grid.


// If the color is completely transparent, return default
if (a === 0) {
warnAboutInvalidColor(color, "Fully transparent color.");
colorCache.set(cacheKey, null);
return DEFAULT_COLOR;
}

const result = { r, g, b, a: a / 255 };
colorCache.set(cacheKey, result);
return result;
} catch (error) {
warnAboutInvalidColor(color, `Error: ${error instanceof Error ? error.message : String(error)}`);
colorCache.set(cacheKey, null);
return DEFAULT_COLOR;
}
};

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 getLuminance = (color: { r: number; g: number; b: number; a?: number }): number => {
// 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.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;
return luminance;
};

export const checkContrast = (
color1: string | { r: number; g: number; b: number; a?: number },
color2: string | { r: number; g: number; b: number; a?: number }
): number => {
const rgba1 = typeof color1 === "string" ? colorToRgba(color1) : color1;
const rgba2 = typeof color2 === "string" ? colorToRgba(color2) : color2;

const l1 = getLuminance(rgba1);
const l2 = getLuminance(rgba2);

const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
};

export const isDarkColor = (color: string, blendedWith: string | null = null): boolean => {
const blended = colorToRgba(color, blendedWith);
const contrastWithWhite = checkContrast(blended, { r: 255, g: 255, b: 255, a: 1 });
const contrastWithBlack = checkContrast(blended, { r: 0, g: 0, b: 0, a: 1 });
return contrastWithWhite > contrastWithBlack;
};

export const isLightColor = (color: string, blendedWith: string | null = null): boolean => {
return !isDarkColor(color, blendedWith);
};

export const getContrastingTextColor = (color: string, blendedWith: string | null = null): "black" | "white" => {
return isDarkColor(color, blendedWith) ? "white" : "black";
};
Loading