diff --git a/src/editor/default-menu.ts b/src/editor/default-menu.ts index 6f242f264..8f5b32884 100644 --- a/src/editor/default-menu.ts +++ b/src/editor/default-menu.ts @@ -9,9 +9,10 @@ import { complete, removeSuggestion } from 'editor-mathfield/autocomplete'; import { BACKGROUND_COLORS, FOREGROUND_COLORS } from 'core/color'; import { Atom } from 'core/atom-class'; import { VARIANT_REPERTOIRE } from 'core/modes-math'; -import { contrast } from 'ui/colors/utils'; import { _Mathfield } from 'editor-mathfield/mathfield-private'; import { _MenuItemState } from 'ui/menu/menu-item'; +import { contrast } from 'ui/colors/contrast'; +import { asHexColor } from 'ui/colors/css'; // Return a string from the selection, if all the atoms are character boxes // (i.e. not fractions, square roots, etc...) @@ -197,7 +198,7 @@ function getBackgroundColorSubmenu(mf: _Mathfield): MenuItem[] { for (const color of Object.keys(BACKGROUND_COLORS)) { result.push({ class: - (contrast(BACKGROUND_COLORS[color]) === '#000' + (asHexColor(contrast(BACKGROUND_COLORS[color])) === '#000' ? 'dark-contrast' : 'light-contrast') + ' menu-swatch', diff --git a/src/ui/colors/colors.less b/src/ui/colors/colors.less index 2e8c41817..b97b66a9b 100644 --- a/src/ui/colors/colors.less +++ b/src/ui/colors/colors.less @@ -32,138 +32,138 @@ --neutral-800: #424242; --neutral-900: #212121; - --red-25: #fff5f5; - --red-50: #ffefee; - --red-100: #ffd6d5; - --red-200: #ffb3b4; - --red-300: #ff8f91; - --red-400: #ff6f71; - --red-500: #ff4f52; - --red-600: #f93f42; - --red-700: #e33539; - --red-800: #c92c30; - --red-900: #a71f23; - --orange-25: #fff7f2; - --orange-50: #fff8f2; - --orange-100: #ffebd9; - --orange-200: #ffdcb8; - --orange-300: #ffc694; - --orange-400: #ffa96d; - --orange-500: #ff8f3f; - --orange-600: #f57e33; - --orange-700: #d86e2a; - --orange-800: #b95e22; - --orange-900: #8f4217; - --brown-25: #fffaf5; - --brown-50: #f7f2e8; - --brown-100: #e7d8c9; - --brown-200: #d1b7a5; - --brown-300: #b78a76; - --brown-400: #9e604c; - --brown-500: #844027; - --brown-600: #733721; - --brown-700: #622c1d; - --brown-800: #4f2218; - --brown-900: #3f1913; - --yellow-25: #fffdf5; - --yellow-50: #fff9e7; - --yellow-100: #fff2c4; - --yellow-200: #ffe59e; - --yellow-300: #ffd875; - --yellow-400: #ffc53d; - --yellow-500: #ffb900; - --yellow-600: #e6a100; - --yellow-700: #bf8f00; - --yellow-800: #997d00; - --yellow-900: #7d6700; - --lime-25: #f9f9e8; - --lime-50: #f5f9e8; - --lime-100: #eaf2c6; - --lime-200: #d9e6a1; - --lime-300: #c5d377; - --lime-400: #b1c24c; - --lime-500: #92b215; - --lime-600: #7ea612; - --lime-700: #6d8f0f; - --lime-800: #5c6d0b; - --lime-900: #3f4a05; - --green-25: #f2f9e8; - --green-50: #e4f7e7; - --green-100: #bceac4; - --green-200: #90dd9d; - --green-300: #64cf75; - --green-400: #42c458; - --green-500: #21ba3a; - --green-600: #1da736; - --green-700: #199533; - --green-800: #137c2e; - --green-900: #0b5726; - --teal-25: #e8f9f9; - --teal-50: #e3f9f9; - --teal-100: #b9f1f1; - --teal-200: #8be7e7; - --teal-300: #5ddddd; - --teal-400: #3ad6d6; + --red-25: #fff8f7; + --red-50: #fff1ef; + --red-100: #ffeae6; + --red-200: #ffcac1; + --red-300: #ffa495; + --red-400: #ff7865; + --red-500: #f21c0d; + --red-600: #e50018; + --red-700: #d30024; + --red-800: #bd002c; + --red-900: #a1002f; + --orange-25: #fffbf8; + --orange-50: #fff7f1; + --orange-100: #fff3ea; + --orange-200: #ffe1c9; + --orange-300: #ffcca2; + --orange-400: #ffb677; + --orange-500: #fe9310; + --orange-600: #f58700; + --orange-700: #ea7c00; + --orange-800: #dc6d00; + --orange-900: #ca5b00; + --brown-25: #fff8ef; + --brown-50: #fff1df; + --brown-100: #ffe9ce; + --brown-200: #ebcca6; + --brown-300: #cdaf8a; + --brown-400: #af936f; + --brown-500: #856a47; + --brown-600: #7f5e34; + --brown-700: #78511f; + --brown-800: #6e4200; + --brown-900: #593200; + --yellow-25: #fffdf9; + --yellow-50: #fffcf2; + --yellow-100: #fffaec; + --yellow-200: #fff2ce; + --yellow-300: #ffe8ab; + --yellow-400: #ffdf85; + --yellow-500: #ffcf33; + --yellow-600: #f1c000; + --yellow-700: #dfb200; + --yellow-800: #c9a000; + --yellow-900: #ad8a00; + --lime-25: #f4ffee; + --lime-50: #e9ffdd; + --lime-100: #ddffca; + --lime-200: #a8fb6f; + --lime-300: #94e659; + --lime-400: #80d142; + --lime-500: #63b215; + --lime-600: #45a000; + --lime-700: #268e00; + --lime-800: #007417; + --lime-900: #005321; + --green-25: #f5fff5; + --green-50: #ebffea; + --green-100: #e0ffdf; + --green-200: #a7ffa7; + --green-300: #5afa65; + --green-400: #45e953; + --green-500: #17cf36; + --green-600: #00b944; + --green-700: #00a34a; + --green-800: #008749; + --green-900: #00653e; + --teal-25: #f3ffff; + --teal-50: #e6fffe; + --teal-100: #d9fffe; + --teal-200: #8dfffe; + --teal-300: #57f4f4; + --teal-400: #43e5e5; --teal-500: #17cfcf; - --teal-600: #14b7b7; - --teal-700: #129f9f; - --teal-800: #0e7f7f; - --teal-900: #094e4e; - --cyan-25: #e8f9fd; - --cyan-50: #e3f4fd; - --cyan-100: #b8e5f9; - --cyan-200: #89d3f6; - --cyan-300: #5ac1f2; - --cyan-400: #36b4ef; + --teal-600: #00c2c0; + --teal-700: #00b5b1; + --teal-800: #00a49e; + --teal-900: #009087; + --cyan-25: #f7fcff; + --cyan-50: #eff8ff; + --cyan-100: #e7f5ff; + --cyan-200: #c2e6ff; + --cyan-300: #95d5ff; + --cyan-400: #61c4ff; --cyan-500: #13a7ec; - --cyan-600: #1197d3; - --cyan-700: #1088ba; - --cyan-800: #0e7398; - --cyan-900: #0a5366; - --blue-25: #e8f5fd; - --blue-50: #e2f0fd; - --blue-100: #b6d9fb; - --blue-200: #86c0f9; - --blue-300: #56a6f6; - --blue-400: #3193f4; + --cyan-600: #069eda; + --cyan-700: #0095c9; + --cyan-800: #0088b2; + --cyan-900: #0a7897; + --blue-25: #f7faff; + --blue-50: #eef5ff; + --blue-100: #e5f1ff; + --blue-200: #bfdbff; + --blue-300: #92c2ff; + --blue-400: #63a8ff; --blue-500: #0d80f2; - --blue-600: #0c75d8; - --blue-700: #0c6abe; - --blue-800: #0b5c9c; - --blue-900: #094668; - --indigo-25: #f0e8fd; - --indigo-50: #ede7f9; - --indigo-100: #d1c2f0; - --indigo-200: #b399e6; - --indigo-300: #9470db; - --indigo-400: #7d52d4; - --indigo-500: #6300ff; - --indigo-600: #5b2fb6; - --indigo-700: #502b9f; - --indigo-800: #422581; - --indigo-900: #2c1d54; - --purple-25: #f9e8fd; - --purple-50: #f4e3fc; - --purple-100: #e3baf8; - --purple-200: #d18cf3; - --purple-300: #be5eee; - --purple-400: #b03cea; + --blue-600: #0077db; + --blue-700: #006dc4; + --blue-800: #0060a7; + --blue-900: #005086; + --indigo-25: #f8f7ff; + --indigo-50: #f1efff; + --indigo-100: #eae7ff; + --indigo-200: #ccc3ff; + --indigo-300: #ac99ff; + --indigo-400: #916aff; + --indigo-500: #63c; + --indigo-600: #5a21b2; + --indigo-700: #4e0b99; + --indigo-800: #3b0071; + --indigo-900: #220040; + --purple-25: #fbf7ff; + --purple-50: #f8f0ff; + --purple-100: #f4e8ff; + --purple-200: #e4c4ff; + --purple-300: #d49aff; + --purple-400: #c36aff; --purple-500: #a219e6; - --purple-600: #9118cd; - --purple-700: #8116b3; - --purple-800: #6b1492; - --purple-900: #49115f; - --magenta-25: #fde8fd; - --magenta-50: #fde9f3; - --magenta-100: #f9c8e0; - --magenta-200: #f5a3cc; - --magenta-300: #f17eb8; - --magenta-400: #ee63a8; + --purple-600: #9000c4; + --purple-700: #7c009f; + --purple-800: #600073; + --purple-900: #3d0043; + --magenta-25: #fff8fb; + --magenta-50: #fff2f6; + --magenta-100: #ffebf2; + --magenta-200: #ffcddf; + --magenta-300: #ffa8cb; + --magenta-400: #ff7fb7; --magenta-500: #eb4799; - --magenta-600: #d83f88; - --magenta-700: #c53777; - --magenta-800: #ac2d60; - --magenta-900: #861d3d; + --magenta-600: #da3689; + --magenta-700: #c82179; + --magenta-800: #b00065; + --magenta-900: #8a004c; } @media (prefers-color-scheme: dark) { diff --git a/src/ui/colors/contrast.ts b/src/ui/colors/contrast.ts new file mode 100644 index 000000000..0c7cfcacd --- /dev/null +++ b/src/ui/colors/contrast.ts @@ -0,0 +1,98 @@ +import { Color } from './types'; +import { asRgb } from './utils'; + +/** + * Return a more accurate measure of contrast between a foreground color + * and a background color than WCAG2.0. + * + * Range is approximately -108..108 + * + * If result < 0, the foreground is lighter than the background + * + * If abs(result) > 90, suitable for all cases + * If abs(result) < 60, large text + * If abs(result) < 44, spot and non-text + * If abs(result) < 30, minimum contrast for any text + * + * See https://www.myndex.com/APCA/ + */ +export function apca(bgColor: Color, fgColor: Color): number { + // APCA calculations are done in sRGB color space + const bgRgb = asRgb(bgColor); + const fgRgb = asRgb(fgColor); + + // exponents + const normBG = 0.56; + const normTXT = 0.57; + const revTXT = 0.62; + const revBG = 0.65; + + // clamps + const blkThrs = 0.022; + const blkClmp = 1.414; + const loClip = 0.1; + const deltaYmin = 0.0005; + + // scalers + // see https://github.com/w3c/silver/issues/645 + const scaleBoW = 1.14; + const loBoWoffset = 0.027; + const scaleWoB = 1.14; + const loWoBoffset = 0.027; + + function fclamp(Y: number) { + return Y >= blkThrs ? Y : Y + (blkThrs - Y) ** blkClmp; + } + + function linearize(val: number) { + const sign = val < 0 ? -1 : 1; + return sign * Math.pow(Math.abs(val), 2.4); + } + + // Calculates "screen luminance" with non-standard simple gamma EOTF + // weights should be from CSS Color 4, not the ones here which are via Myndex and copied from Lindbloom + const Yfg = fclamp( + linearize(fgRgb.r / 255) * 0.2126729 + + linearize(fgRgb.g / 255) * 0.7151522 + + linearize(fgRgb.b / 255) * 0.072175 + ); + + const Ybg = fclamp( + linearize(bgRgb.r / 255) * 0.2126729 + + linearize(bgRgb.g / 255) * 0.7151522 + + linearize(bgRgb.b / 255) * 0.072175 + ); + + let S: number, C: number, Sapc: number; + + if (Math.abs(Ybg - Yfg) < deltaYmin) C = 0; + else { + if (Ybg > Yfg) { + // dark foreground on light background + S = Ybg ** normBG - Yfg ** normTXT; + C = S * scaleBoW; + } else { + // light foreground on dark background + S = Ybg ** revBG - Yfg ** revTXT; + C = S * scaleWoB; + } + } + if (Math.abs(C) < loClip) Sapc = 0; + else if (C > 0) Sapc = C - loWoBoffset; + else Sapc = C + loBoWoffset; + + return Sapc * 100; +} + +/** Of two foreground colors, return the one with the highest + * contrast ratio with the background + */ +export function contrast(bgColor: Color, dark?: Color, light?: Color): Color { + light ??= '#fff'; + dark ??= '#000'; + + const lightContrast = apca(bgColor, light); + const darkContrast = apca(bgColor, dark); + + return Math.abs(lightContrast) > Math.abs(darkContrast) ? light : dark; +} diff --git a/src/ui/colors/css.ts b/src/ui/colors/css.ts new file mode 100644 index 000000000..cf4e40ad7 --- /dev/null +++ b/src/ui/colors/css.ts @@ -0,0 +1,67 @@ +import { Color, OklabColor, OklchColor } from './types'; +import { clampByte, asRgb } from './utils'; + +export function asHexColor(_: Color): string { + const rgb = asRgb(_); + let hexString = ( + (1 << 24) + + (clampByte(rgb.r) << 16) + + (clampByte(rgb.g) << 8) + + clampByte(rgb.b) + ) + .toString(16) + .slice(1); + + if (rgb.alpha !== undefined && rgb.alpha < 1.0) + hexString += ('00' + Math.round(rgb.alpha * 255).toString(16)).slice(-2); + + // Compress hex from hex-6 or hex-8 to hex-3 or hex-4 if possible + if ( + hexString[0] === hexString[1] && + hexString[2] === hexString[3] && + hexString[4] === hexString[5] && + hexString[6] === hexString[7] + ) { + hexString = + hexString[0] + + hexString[2] + + hexString[4] + + (rgb.alpha !== undefined && rgb.alpha < 1.0 ? hexString[6] : ''); + } + + return '#' + hexString; +} + +/** Generate CSS string for a color */ +export function css(_: Color): string { + if (typeof _ === 'string') return _; + if ('r' in _) return asHexColor(_); + if ('a' in _) { + const lab = _ as OklabColor; + if ('alpha' in lab && typeof lab.alpha === 'number') { + return `lab(${Math.round(lab.L * 1000) / 10}% ${ + Math.round(lab.a * 100) / 100 + } ${Math.round(lab.b * 100) / 100} / ${ + Math.round(lab.alpha * 100) / 100 + }%)`; + } + return `lab(${Math.round(lab.L * 1000) / 10}% ${ + Math.round(lab.a * 100) / 100 + } ${Math.round(lab.b * 100) / 100})`; + } + + // L is a percentage, C is a number < 0.4, H is a number 0..360 + // Alpha could be 0..1 or n% (0..100) + const oklch = _ as OklchColor; + if ('alpha' in oklch && typeof oklch.alpha === 'number') { + return `oklch(${Math.round(oklch.L * 1000) / 10}% ${ + Math.round(oklch.C * 1000) / 1000 + } ${Math.round(oklch.H * 10) / 10} / ${ + Math.round(oklch.alpha * 100) / 100 + }%)`; + } + + return `oklch(${Math.round(oklch.L * 1000) / 10}% ${ + Math.round(oklch.C * 1000) / 1000 + } ${Math.round(oklch.H * 10) / 10})`; +} diff --git a/src/ui/colors/scale.ts b/src/ui/colors/scale.ts new file mode 100644 index 000000000..b07e21f92 --- /dev/null +++ b/src/ui/colors/scale.ts @@ -0,0 +1,82 @@ +import { Color, OklchColor } from './types'; +import { asOklch } from './utils'; + +/** + * Calculate a scale of colors from a base color. + * The base color is the 500 color in the scale. + * The other colors range from -25, -25, -100, -200 to -900. + * 11 colors in total. + */ +export function scale(color: Color): Color[] { + const oklch = asOklch(color); + delete oklch.alpha; + const light = { ...oklch, L: 1.0 }; + const dark = { ...oklch }; + + // Correct the hue for the Abney Effect + // See https://royalsocietypublishing.org/doi/pdf/10.1098/rspa.1909.0085 + // (the human vision system perceives a hue shift as colors + // change in colorimetric purity: mix with black or mix + // with white) + // and the Bezold-Brücke effect (hue shift as intensity increases) + // See https://www.sciencedirect.com/science/article/pii/S0042698999000851 + + // h: c2.h >= 60 && c2.h <= 240 ? c2.h + 30 : c2.h - 30, + + const hAngle = (oklch.H * Math.PI) / 180; + dark.H = oklch.H - 20 * Math.sin(2 * hAngle); + dark.C = oklch.C + 0.08 * Math.sin(hAngle); + if (oklch.H >= 180) dark.L = oklch.L - 0.35; + else dark.L = oklch.L - 0.3 + 0.1 * Math.sin(2 * hAngle); + + return [ + mix(light, oklch, 0.04), // color-25 + mix(light, oklch, 0.08), // color-50 + mix(light, oklch, 0.12), // color-100 + mix(light, oklch, 0.3), // color-200 + mix(light, oklch, 0.5), // color-300 + mix(light, oklch, 0.7), // color-400 + oklch, // color-500 + mix(dark, oklch, 0.85), // color-600 + mix(dark, oklch, 0.7), // color-700 + mix(dark, oklch, 0.5), // color-800 + mix(dark, oklch, 0.25), // color-900 + ]; +} + +function mix(c1: OklchColor, c2: OklchColor, t: number): OklchColor { + return { + L: c1.L * (1 - t) + c2.L * t, + C: c1.C * (1 - t) + c2.C * t, + H: c1.H * (1 - t) + c2.H * t, + }; +} + +// Interesting article about desiging color scales: +// https://uxplanet.org/designing-systematic-colors-b5d2605b15c + +// const colors = { +// red: '#F21C0D', // hue 30 Vivid Red +// orange: '#FE9310', // hue 61 Yellow Orange +// brown: '#856A47', // hue 73 Raw Umber +// yellow: '#FFCF33', // hue 90 Peach Cobbler / Sunglow +// lime: '#63B215', // hue 130 Kelly Green +// green: '#17CF36', // hue 144 Vivid Malachite +// teal: '#17CFCF', // hue 195 Dark Turquoise +// cyan: '#13A7EC', // hue 238 Vivid Cerulean +// blue: '#0D80F2', // hue 255 Tropical Thread / Azure +// indigo: '#6633CC', // hue 291 Strong Violet / Iris +// purple: '#A219E6', // hue 309 Purple X11 +// magenta: '#EB4799', // hue 354 Raspberry Pink +// }; + +// const index = [25, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900]; + +// let result = ''; + +// for (const [name, color] of Object.entries(colors)) { +// const s = scale(color); +// for (const [i, c] of s.entries()) +// result += `--${name}-${index[i]}: ${asHexColor(c)};` + '\n'; +// } +// console.info(result); diff --git a/src/ui/colors/types.ts b/src/ui/colors/types.ts new file mode 100644 index 000000000..e54f66b23 --- /dev/null +++ b/src/ui/colors/types.ts @@ -0,0 +1,25 @@ +// RGB color in the sRGB color space +export type RgbColor = { + r: number; // 0..255 + g: number; // 0..255 + b: number; // 0..255 + alpha?: number; // 0..1 +}; + +// Perceptual uniform color, can represent colors outside the sRGB gamut +export type OklchColor = { + L: number; // perceived lightness 0..1 + C: number; // chroma 0.. 0.37 + H: number; // hue 0..360 + alpha?: number; // 0..1 +}; + +// Perceptual uniform color, can represent colors outside the sRGB gamut +export type OklabColor = { + L: number; // perceived lightness 0..1 + a: number; // green <-> red -0.4..0.4 + b: number; // blue <-> yellow -0.4..0.4 + alpha?: number; // 0..1 +}; + +export type Color = string | RgbColor | OklchColor | OklabColor; diff --git a/src/ui/colors/utils.ts b/src/ui/colors/utils.ts index 0de86ae4c..1e41fc87f 100644 --- a/src/ui/colors/utils.ts +++ b/src/ui/colors/utils.ts @@ -1,26 +1,30 @@ -// RGB color in the sRGB color space -export type RgbColor = { - r: number; // 0..255 - g: number; // 0..255 - b: number; // 0..255 - alpha?: number; // 0..1 -}; - -// Perceptual uniform color, can represent colors outside the sRGB gamut -export type OklchColor = { - L: number; // perceived lightness 0..1 - C: number; // chroma 0.. 0.37 - H: number; // hue 0..360 - alpha?: number; // 0..1 -}; +import { RgbColor, OklchColor, OklabColor } from './types'; + +export function asOklch( + color: string | RgbColor | OklchColor | OklabColor +): OklchColor { + if (typeof color === 'string') { + const parsed = parseHex(color); + if (!parsed) throw new Error(`Invalid color: ${color}`); + return rgbToOklch(parsed); + } + if ('C' in color) return color; + if ('a' in color) return oklabToOklch(color); + return rgbToOklch(color); +} -// Perceptual uniform color, can represent colors outside the sRGB gamut -export type OklabColor = { - L: number; // perceived lightness 0..1 - a: number; // green <-> red -0.4..0.4 - b: number; // blue <-> yellow -0.4..0.4 - alpha?: number; // 0..1 -}; +export function asRgb( + color: string | RgbColor | OklchColor | OklabColor +): RgbColor { + if (typeof color === 'string') { + const parsed = parseHex(color); + if (!parsed) throw new Error(`Invalid color: ${color}`); + return parsed; + } + if ('C' in color) return oklchToRgb(color); + if ('a' in color) return oklabToRgb(color); + return color; +} export function clampByte(v: number): number { if (v < 0) return 0; @@ -52,70 +56,6 @@ export function parseHex(hex: string): RgbColor | undefined { return result; } -export function rgbToHex(_: RgbColor): string { - let hexString = ( - (1 << 24) + - (clampByte(_.r) << 16) + - (clampByte(_.g) << 8) + - clampByte(_.b) - ) - .toString(16) - .slice(1); - - if (_.alpha !== undefined && _.alpha < 1.0) - hexString += ('00' + Math.round(_.alpha * 255).toString(16)).slice(-2); - - // Compress hex from hex-6 or hex-8 to hex-3 or hex-4 if possible - if ( - hexString[0] === hexString[1] && - hexString[2] === hexString[3] && - hexString[4] === hexString[5] && - hexString[6] === hexString[7] - ) { - hexString = - hexString[0] + - hexString[2] + - hexString[4] + - (_.alpha !== undefined && _.alpha < 1.0 ? hexString[6] : ''); - } - - return '#' + hexString; -} - -/** Generate CSS string for a color */ -export function css(_: string | RgbColor | OklabColor | OklchColor): string { - if (typeof _ === 'string') return _; - if ('r' in _) return rgbToHex(_); - if ('a' in _) { - const lab = _ as OklabColor; - if ('alpha' in lab && typeof lab.alpha === 'number') { - return `lab(${Math.round(lab.L * 1000) / 10}% ${ - Math.round(lab.a * 100) / 100 - } ${Math.round(lab.b * 100) / 100} / ${ - Math.round(lab.alpha * 100) / 100 - }%)`; - } - return `lab(${Math.round(lab.L * 1000) / 10}% ${ - Math.round(lab.a * 100) / 100 - } ${Math.round(lab.b * 100) / 100})`; - } - - // L is a percentage, C is a number < 0.4, H is a number 0..360 - // Alpha could be 0..1 or n% (0..100) - const oklch = _ as OklchColor; - if ('alpha' in oklch && typeof oklch.alpha === 'number') { - return `oklch(${Math.round(oklch.L * 1000) / 10}% ${ - Math.round(oklch.C * 1000) / 1000 - } ${Math.round(oklch.H * 10) / 10} / ${ - Math.round(oklch.alpha * 100) / 100 - }%)`; - } - - return `oklch(${Math.round(oklch.L * 1000) / 10}% ${ - Math.round(oklch.C * 1000) / 1000 - } ${Math.round(oklch.H * 10) / 10})`; -} - // oklab and oklch: // https://bottosson.github.io/posts/oklab/ @@ -271,100 +211,3 @@ export function rgbToOklab(_: RgbColor): OklabColor { export function rgbToOklch(_: RgbColor): OklchColor { return oklabToOklch(rgbToOklab(_)); } - -/** Of two foreground colors, return the one with the highest - * contrast ratio with the background - */ -export function contrast( - background: string, - dark?: string, - light?: string -): string { - light ??= '#fff'; - dark ??= '#000'; - - const bgColor = parseHex(background)!; - const lightContrast = apca(bgColor, parseHex(light)!); - const darkContrast = apca(bgColor, parseHex(dark)!); - - return Math.abs(lightContrast) > Math.abs(darkContrast) ? light : dark; -} - -/** - * Return a more accurate measure of contrast between a foreground color - * and a background color than WCAG2.0. - * - * If the result is negative, the foreground is lighter than the - * background - * - * Range about -108..108 - * If abs(result) > 90, suitable for all cases - * If abs(result) < 60, large text - * If abs(result) < 44, spot and non-text - * If abs(result) < 30, minimum contrast for any text - * - * See https://www.myndex.com/APCA/ - */ -export function apca(background: RgbColor, foreground: RgbColor): number { - // exponents - const normBG = 0.56; - const normTXT = 0.57; - const revTXT = 0.62; - const revBG = 0.65; - - // clamps - const blkThrs = 0.022; - const blkClmp = 1.414; - const loClip = 0.1; - const deltaYmin = 0.0005; - - // scalers - // see https://github.com/w3c/silver/issues/645 - const scaleBoW = 1.14; - const loBoWoffset = 0.027; - const scaleWoB = 1.14; - const loWoBoffset = 0.027; - - function fclamp(Y: number) { - return Y >= blkThrs ? Y : Y + (blkThrs - Y) ** blkClmp; - } - - function linearize(val: number) { - const sign = val < 0 ? -1 : 1; - return sign * Math.pow(Math.abs(val), 2.4); - } - - // Calculates "screen luminance" with non-standard simple gamma EOTF - // weights should be from CSS Color 4, not the ones here which are via Myndex and copied from Lindbloom - const Yfg = fclamp( - linearize(foreground.r / 255) * 0.2126729 + - linearize(foreground.g / 255) * 0.7151522 + - linearize(foreground.b / 255) * 0.072175 - ); - - const Ybg = fclamp( - linearize(background.r / 255) * 0.2126729 + - linearize(background.g / 255) * 0.7151522 + - linearize(background.b / 255) * 0.072175 - ); - - let S: number, C: number, Sapc: number; - - if (Math.abs(Ybg - Yfg) < deltaYmin) C = 0; - else { - if (Ybg > Yfg) { - // dark foreground on light background - S = Ybg ** normBG - Yfg ** normTXT; - C = S * scaleBoW; - } else { - // light foreground on dark background - S = Ybg ** revBG - Yfg ** revTXT; - C = S * scaleWoB; - } - } - if (Math.abs(C) < loClip) Sapc = 0; - else if (C > 0) Sapc = C - loWoBoffset; - else Sapc = C + loBoWoffset; - - return Sapc * 100; -}