Skip to content

Instantly share code, notes, and snippets.

@baraqkamsani
Last active February 20, 2025 10:24
Show Gist options
  • Save baraqkamsani/b1bfc1e24b3c45c7967606c6b06ae9bd to your computer and use it in GitHub Desktop.
Save baraqkamsani/b1bfc1e24b3c45c7967606c6b06ae9bd to your computer and use it in GitHub Desktop.
Myndex APCA with colorjs color-picker
(function () {
if (window.location.hostname !== 'www.myndex.com') {
alert(''
+ 'Please use this bookmarklet/script on any of the following pages:'
+ '\n • https://www.myndex.com/APCA'
+ '\n • https://www.myndex.com/BPCA'
+ '\n • https://www.myndex.com/SAPC'
);
throw new Error();
}
const
isPageAPCA = window.location.pathname.startsWith('/APCA')
, isPageBPCA = window.location.pathname.startsWith('/BPCA')
, isPageSAPC = window.location.pathname.startsWith('/SAPC')
, MYNDEX_tableSamples = document.getElementById('tableSamples')
, MYNDEX_contrastResultTable = document.getElementById('contrastResultTable')
, MYNDEX_inputTXT = document.getElementById('inputTXT')
, MYNDEX_inputBG = document.getElementById('inputBG')
, scriptId = '_scriptId'
, styleId = '_styleId'
, colorPickerIdTXT = 'colorPickerIdTXT'
, colorPickerIdBG = 'colorPickerIdBG'
, lightnessInputIdTXT = 'lightnessInputIdTXT'
, lightnessInputIdBG = 'lightnessInputIdBG'
// For .cloneNode (faster)
, emptyDiv = document.createElement('div')
, emptyButton = document.createElement('button')
;
if (!MYNDEX_tableSamples
|| !MYNDEX_contrastResultTable
|| !MYNDEX_inputTXT
|| !MYNDEX_inputBG
) throw new Error();
let script = document.getElementById(scriptId);
if (script != null) console.log('Color picker script tag already created.');
else {
script = document.createElement('script');
script.id = scriptId;
script.type = 'module';
script.textContent = /* language=javascript */ ''
+ 'import Color from "https://colorjs.io/dist/color.js";'
+ 'import "https://elements.colorjs.io/src/color-picker/color-picker.js";'
+ 'window.Color = Color;'
;
document.head.appendChild(script);
}
let style = document.getElementById(styleId);
if (style != null) console.log('Color picker style tag already created.');
else {
style = document.createElement('style');
style.id = styleId;
style.textContent = /* language=css */ ''
+ 'html { --slider-height: 1.125rem }'
+ 'color-picker { margin-block: 0; display: block }'
+ 'color-picker::part(swatch){ font-size: 1.75rem; width: 49.5cqw; min-block-size: 3.5rem }'
+ 'color-picker::part(color-space) { font-size: 0.875rem; }'
+ 'color-picker::part(sliders) { font-size: 0.875rem; gap: 0 }'
// width is 1000px because of the following CSS in the page:
// #tableSamples.conformScore { width: 1024px } /* https://www.myndex.com/APCA/ */
// #tableSamples.conformScore { width: 100% } /* https://www.myndex.com/SAPC/ */
+ '.color-picker-wrapper { display: flex; justify-content: space-between; width:1000px; container-type: inline-size; margin-left: 12px }'
+ '.color-picker-wrapper--SAPC{ width:100%; margin-inline: auto }'
+ '.chroma-button { font-size: 1rem; padding: 0.375rem; cursor: pointer; color:#30a; background-color:#def; border: 2px solid #30a }'
+ '.chroma-button-wrapper { display: flex; width:1000px; justify-content: space-around }'
+ '.chroma-button-wrapper--SAPC { margin-block: 0.5rem; width:100%; }'
+ '.lightness-input { font-size: 1rem; padding: 0.375rem; border: 2px solid #104; box-shadow: none; border-radius: 0; width: 25cqw; background-color: #FFD !important; color: #104 !important; }'
+ '.lightness-input::placeholder { color: #104 !important; }'
+ '.lightness-controls { margin-top: 1rem; display: flex; gap: 0.25rem; align-items: center; }'
+ '.lightness-controls-wrapper { display: flex; width:1000px; justify-content: space-around }'
+ '.lightness-controls-wrapper--SAPC { width:100%; }'
;
document.head.appendChild(style);
}
// ----- Utility Functions ---------------------------------------------------
/**
* Format <input id=color> in <color-picker> to preferred coords.
* @param color {import('colorjs.io').default}
* @see https://colorjs.io/docs/output
*/
function colorToStringPreferred(color) {
return color.toString({ format: {
name: 'oklch',
coords: [
'<percentage>', // or <number>
'<percentage>', // or <number>
'<number>' // or <angle>
]
}});
}
/**
* Hotfix for color-slider after calling colorToStringPreferred()
* Without the reset, sliders may stop working.
* @param color {import('colorjs.io').default}
*/
function colorToStringReset(color) {
return color.toString({ format: { name: 'oklch' } });
}
/**
* @param element {HTMLElement}
* @param className {string}
*/
function setClassName(element, className) {
if (isPageAPCA) element.className = `${className} ${className}--APCA`;
if (isPageBPCA) element.className = `${className} ${className}--BPCA`;
if (isPageSAPC) element.className = `${className} ${className}--SAPC`;
}
let
colorPickerTXT = document.getElementById(colorPickerIdTXT)
, colorPickerInputTXT
, colorPickerBG = document.getElementById(colorPickerIdBG)
, colorPickerInputBG
;
if (!!colorPickerTXT && !!colorPickerBG) {
console.log('<color-picker> web components already created.')
throw new Error();
}
// ----- Button and Input Functions ------------------------------------------
/**
* @param colorPickerInput {HTMLInputElement}
* @param allHues {boolean|undefined} undefined sets chroma to 0.
*/
function setChroma(colorPickerInput, allHues = undefined) {
const color = new window.Color(colorPickerInput.value);
if (allHues == null) {
color.c = 0
colorPickerInput.value = colorToStringPreferred(color);
colorPickerInput.dispatchEvent(new Event('input', { bubbles: true }));
colorToStringReset(color);
return
}
const inGamutSRGB = allHues === true
? color => {
for (color.h = 0; color.h <= 360; color.h++)
if (!color.inGamut('SRGB'))
return false;
return true;
}
: color => color.inGamut('SRGB');
// Start from the highest upper-bound chroma.
color.c = allHues === true
? 0.15
: 0.4;
const { h: startingHue } = color;
while (!inGamutSRGB(color))
color.c = Math.round(color.c * 10000 - 1 ) / 10000;
color.h = startingHue;
colorPickerInput.value = colorToStringPreferred(color);
colorPickerInput.dispatchEvent(new Event('input', { bubbles: true }));
colorToStringReset(color);
}
/**
* @param isBackground {boolean}
*/
async function setLightness(isBackground = false) {
const targetInput = isBackground ? colorPickerInputBG : colorPickerInputTXT;
const referenceInput = isBackground ? colorPickerInputTXT : colorPickerInputBG;
// Get initial OKLCH value
const targetColor = new window.Color(targetInput.value);
const referenceColor = new window.Color(referenceInput.value);
const { l: referenceLightness } = referenceColor;
// Backup the original lightness in case we can't reach the targetLc
const { l: originalLightness } = targetColor;
// Set initial lightness and increment
targetColor.l = referenceLightness;
const increment = referenceLightness > 0.5 ? -0.001 : 0.001;
// Get target Lc from input or prompt
const inputId = isBackground ? lightnessInputIdBG : lightnessInputIdTXT;
let targetLc = parseFloat(document.getElementById(inputId).value);
if (isNaN(targetLc)) {
targetLc = parseFloat(prompt(
'Enter target Lc value for '
+ isBackground ? 'background' : 'text'
+ ' (e.g. 87.5):'
));
if (isNaN(targetLc)) {
alert("Please enter a valid number for target Lc");
return;
}
}
const update = async () => await new Promise(resolve => {
const colorString = colorToStringPreferred(targetColor);
targetInput.value = colorString;
targetInput.setAttribute('value', colorString);
targetInput.dispatchEvent(new Event('input', { bubbles: true }));
setTimeout(resolve, 2.5);
})
const regex = /-?\d+\.?\d*/;
for (let i = 0; i < 65535 /* Maximum Iterations */; i++) {
await update();
const contrastText = MYNDEX_contrastResultTable.textContent;
const lcMatch = contrastText.match(regex);
if (!lcMatch) {
console.error("Could not parse contrast value, current text:", contrastText);
continue;
}
const currentLc = Math.abs(parseFloat(lcMatch[0]));
const differenceLc = targetLc - currentLc;
if (differenceLc < 0) {
// Happy path.
colorToStringReset(targetColor);
return;
}
if (targetColor.l > 1.00 || targetColor.l < 0.00) {
// Overshot. This can happen when the reference lightness
// is neither bright nor dark, but the targetLc is very high.
targetColor.l = originalLightness;
await update();
colorToStringReset(targetColor);
const reference = isBackground ? 'text' : 'background';
const DecreaseOrIncrease = targetColor.l > 1.00 ? 'Reduce' : 'Increase';
alert(
'Could not reach Target Lc.\n'
+ DecreaseOrIncrease
+ ' your '
+ reference
+ ' lightness and try again.'
);
return;
}
let l = 0;
if (differenceLc > 50) l += increment * (differenceLc * 5);
else if (differenceLc > 40) l += increment * (differenceLc * 4);
else if (differenceLc > 30) l += increment * (differenceLc * 3);
else if (differenceLc > 20) l += increment * (differenceLc * 2);
else if (differenceLc > 2) l += increment * differenceLc;
else l += increment;
targetColor.l += l;
}
console.error("Maximum iterations reached without finding target contrast");
colorToStringReset(targetColor);
}
// ----- Create Elements -------------------------------------------------------------------------
const createButton = (text, onClick, className = 'chroma-button') => {
/** @type {HTMLButtonElement} */
const button = emptyButton.cloneNode(false);
button.textContent = text;
button.addEventListener('click', onClick);
setClassName(button, className);
return button;
};
const createInput = (id, placeholder, onClick) => {
const input = document.createElement('input');
input.id = id;
input.type = 'number';
input.placeholder = placeholder;
setClassName(input, 'lightness-input');
input.addEventListener('keydown', e => {
if ([ 'Enter' ].includes(e.key)) onClick();
});
return input;
};
const createColorPicker = (id, color, myndexInput) => {
const picker = document.createElement('color-picker');
picker.id = id;
picker.setAttribute('space', 'oklch');
picker.setAttribute('color', color);
picker.shadowRoot
.querySelector('color-swatch')
.shadowRoot
.querySelector('div#swatch')
.remove();
picker.addEventListener('colorchange', (e) => {
try {
const color = new window.Color(e.target.color);
myndexInput.value = color.toString({ format: 'hex' });
myndexInput.dispatchEvent(new Event('blur'));
} catch (err) {
console.error('Color conversion error:', err);
}
});
return picker;
}
setTimeout(() => {
// ----- Color Picker ------------------------
try {
colorPickerTXT = createColorPicker(colorPickerIdTXT, 'oklch(40.0% 49.6% 265.2)', MYNDEX_inputTXT);
colorPickerBG = createColorPicker(colorPickerIdBG, 'oklch(91.74% 6.9% 95.36)', MYNDEX_inputBG);
colorPickerInputTXT = colorPickerTXT.shadowRoot.querySelector('input#color');
colorPickerInputBG = colorPickerBG.shadowRoot.querySelector('input#color');
} catch (e) {
console.error(e);
alert('Please retry this script');
}
const colorPickerWrapper = emptyDiv.cloneNode(false);
setClassName(colorPickerWrapper, 'color-picker-wrapper');
colorPickerWrapper.appendChild(colorPickerTXT);
colorPickerWrapper.appendChild(colorPickerBG);
MYNDEX_tableSamples.parentNode.insertBefore(colorPickerWrapper, MYNDEX_tableSamples);
// ----- Chroma Buttons ----------------------
const css = 'display: flex; flex-wrap: wrap; justify-content: space-evenly; gap: 0.25rem;'
const chromaButtonWrapperInsideTXT = emptyDiv.cloneNode(false);
[
createButton('Max Chroma (Current Hue)', () => setChroma(colorPickerInputTXT, false)),
createButton('Max Chroma (All Hues)', () => setChroma(colorPickerInputTXT, true)),
createButton('Chroma 0', () => setChroma(colorPickerInputTXT)),
]
.forEach(button => chromaButtonWrapperInsideTXT.appendChild(button));
chromaButtonWrapperInsideTXT.setAttribute('style', css);
const chromaButtonWrapperInsideBG = emptyDiv.cloneNode(false);
[
createButton('Max Chroma (Current Hue)', () => setChroma(colorPickerInputBG, false)),
createButton('Max Chroma (All Hues)', () => setChroma(colorPickerInputBG, true)),
createButton('Chroma 0', () => setChroma(colorPickerInputBG)),
]
.forEach(button => chromaButtonWrapperInsideBG.appendChild(button));
chromaButtonWrapperInsideBG.setAttribute('style', css);
const chromaButtonWrapper = emptyDiv.cloneNode(false);
setClassName(chromaButtonWrapper, 'chroma-button-wrapper');
chromaButtonWrapper.appendChild(chromaButtonWrapperInsideTXT);
chromaButtonWrapper.appendChild(chromaButtonWrapperInsideBG);
MYNDEX_tableSamples.parentNode.insertBefore(chromaButtonWrapper, colorPickerWrapper);
// ----- Lightness Controls ------------------
const
lightnessControlsWrapper = emptyDiv.cloneNode(false)
, lightnessControlsTXT = emptyDiv.cloneNode(false)
, lightnessControlsBG = emptyDiv.cloneNode(false)
, lightnessInputTXT = createInput(lightnessInputIdTXT, 'Target Lc', () => setLightness(false))
, lightnessInputBG = createInput(lightnessInputIdBG, 'Target Lc', () => setLightness(true))
, lightnessButtonTXT = createButton('Adjust lightness to Target Lc', () => setLightness(false))
, lightnessButtonBG = createButton('Adjust lightness to Target Lc', () => setLightness(true))
;
setClassName(lightnessControlsWrapper, 'lightness-controls-wrapper');
setClassName(lightnessControlsTXT, 'lightness-controls');
setClassName(lightnessControlsBG, 'lightness-controls');
lightnessControlsTXT.appendChild(lightnessInputTXT);
lightnessControlsTXT.appendChild(lightnessButtonTXT);
lightnessControlsBG.appendChild(lightnessInputBG);
lightnessControlsBG.appendChild(lightnessButtonBG);
lightnessControlsWrapper.appendChild(lightnessControlsTXT);
lightnessControlsWrapper.appendChild(lightnessControlsBG);
MYNDEX_tableSamples.parentNode.insertBefore(lightnessControlsWrapper, chromaButtonWrapper);
}, 500);
})();
@baraqkamsani
Copy link
Author

This script adds the color-picker web component from colorjs into Myndex tools:

image

As of 2025-02-12, you can use it on any of the following pages:

Usage

Browser Console

Copy and paste the script into the browser console.

Bookmarklet

Paste the script into a bookmarklet generator.
I use bookmarkleter with the following options:

  • URL-encode reserved characters
  • Mangle variables and remove dead code to reduce size.

Note:

The produced Lc value is still based on the color-picker's values converted to hex.
In other words, if you have a text oklch(100% 0 0) and background oklch(0% 0 0),
this script converts it to #fff and #000 before updating the input field from Myndex.

Therefore, it's best to stay within the sRGB gamut.
The color-picker will indicate when you are outside of it (PP+):

image

Tip:

If you need finer control over the LCh values,
select the value from its input field and hold Ctrl+Up or Ctrl+Down:

image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment