const $colorInput = $('#color-input');
const $colorError = $('#color-error');
const $colorSwatch = $('#color-swatch');
const $colorOutHex = $('#color-out-hex');
const $colorOutRgb = $('#color-out-rgb');
const $colorOutHsl = $('#color-out-hsl');
const $colorOutCmyk = $('#color-out-cmyk');
let lastScale = null;
const hexToRgb = (hex) => {
let r = 0, g = 0, b = 0;
if (hex.length === 4) {
r = "0x" + hex[1] + hex[1];
g = "0x" + hex[2] + hex[2];
b = "0x" + hex[3] + hex[3];
} else if (hex.length === 7) {
r = "0x" + hex[1] + hex[2];
g = "0x" + hex[3] + hex[4];
b = "0x" + hex[5] + hex[6];
}
return [Number(r), Number(g), Number(b)];
};
const rgbToHsl = (r, g, b) => {
r /= 255; g /= 255; b /= 255;
let max = Math.max(r, g, b), min = Math.min(r, g, b);
let h, s, l = (max + min) / 2;
if (max === min) {
h = s = 0;
} else {
let d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch(max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
}
return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)];
};
const rgbToCmyk = (r, g, b) => {
let c = 1 - (r / 255);
let m = 1 - (g / 255);
let y = 1 - (b / 255);
let k = Math.min(c, Math.min(m, y));
if (k === 1) {
return [0, 0, 0, 100];
}
c = Math.round((c - k) / (1 - k) * 100);
m = Math.round((m - k) / (1 - k) * 100);
y = Math.round((y - k) / (1 - k) * 100);
k = Math.round(k * 100);
return [c, m, y, k];
};
const hslToRgb = (h, s, l) => {
h = ((h % 360) + 360) % 360;
s /= 100; l /= 100;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs((h / 60) % 2 - 1));
const m = l - c / 2;
let r1, g1, b1;
if (h < 60) [r1, g1, b1] = [c, x, 0];
else if (h < 120) [r1, g1, b1] = [x, c, 0];
else if (h < 180) [r1, g1, b1] = [0, c, x];
else if (h < 240) [r1, g1, b1] = [0, x, c];
else if (h < 300) [r1, g1, b1] = [x, 0, c];
else [r1, g1, b1] = [c, 0, x];
return [
Math.round((r1 + m) * 255),
Math.round((g1 + m) * 255),
Math.round((b1 + m) * 255)
];
};
const rgbToHex = (r, g, b) => '#' + [r, g, b]
.map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0'))
.join('').toUpperCase();
const hslToHex = (h, s, l) => rgbToHex(...hslToRgb(h, s, l));
const SCALE_STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
const generateScale = (h, s) => {
const n = SCALE_STEPS.length;
return SCALE_STEPS.map((_, i) => hslToHex(h, s, Math.round(97 - i * ((97 - 8) / (n - 1)))));
};
const HARMONIES = [
{ name: 'Complementary', offsets: [0, 180] },
{ name: 'Split-Complementary', offsets: [0, 150, 210] },
{ name: 'Triadic', offsets: [0, 120, 240] },
{ name: 'Analogous', offsets: [-30, 0, 30] },
];
const swatchHtml = (hex, labelTop, extraClass) => `
<div class="color-swatch-btn cursor-pointer rounded-lg overflow-hidden border border-gray-800 hover:border-gray-600 transition-colors ${extraClass || ''}" data-copy="${hex}" title="Click to copy ${hex}">
<div style="background:${hex}" class="h-10 w-full"></div>
<div class="px-1 py-1 bg-gray-900 text-[8px] font-mono text-gray-500 text-center truncate">
${labelTop ? `<div class="text-gray-400 font-bold">${labelTop}</div>` : ''}
<div>${hex}</div>
</div>
</div>`;
const renderScale = (h, s) => {
const scale = generateScale(h, s);
$('#color-scale').html(scale.map((hex, i) => swatchHtml(hex, SCALE_STEPS[i])).join(''));
return scale;
};
const renderHarmony = (h, s, l) => {
$('#color-harmony').html(HARMONIES.map(({ name, offsets }) => `
<div>
<div class="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2">${name}</div>
<div class="flex gap-1.5">
${offsets.map(off => swatchHtml(hslToHex(h + off, s, l), '', 'flex-1')).join('')}
</div>
</div>
`).join(''));
};
const buildExport = (format, scale) => {
const entries = SCALE_STEPS.map((step, i) => [step, scale[i]]);
if (format === 'tailwind') {
return 'module.exports = {\n theme: {\n extend: {\n colors: {\n brand: {\n' +
entries.map(([step, hex]) => ` ${step}: '${hex.toLowerCase()}',`).join('\n') +
'\n }\n }\n }\n }\n}';
}
if (format === 'scss') {
return '$palette: (\n' + entries.map(([step, hex]) => ` ${step}: ${hex.toLowerCase()},`).join('\n') + '\n);';
}
return ':root {\n' + entries.map(([step, hex]) => ` --color-${step}: ${hex};`).join('\n') + '\n}';
};
const renderExport = (scale) => {
$('#color-export-output').text(buildExport($('#color-export-format').val(), scale));
};
const clearScaleAndHarmony = () => {
$('#color-scale').html('');
$('#color-harmony').html('');
$('#color-export-output').text('');
};
const clearColorUI = () => {
$colorSwatch.css('background-color', '');
$colorOutHex.text('');
$colorOutRgb.text('');
$colorOutHsl.text('');
$colorOutCmyk.text('');
clearScaleAndHarmony();
lastScale = null;
};
$colorInput.on('input', function() {
let val = $(this).val().trim();
if (!val) {
$colorError.addClass('hidden');
clearColorUI();
return;
}
if (!val.startsWith('#')) {
val = '#' + val;
}
const validHex = /^#([0-9A-F]{3}){1,2}$/i.test(val);
if (!validHex) {
$colorError.removeClass('hidden');
clearColorUI();
} else {
$colorError.addClass('hidden');
const [r, g, b] = hexToRgb(val);
const [h, s, l] = rgbToHsl(r, g, b);
const [c, m, y, k] = rgbToCmyk(r, g, b);
let fullHex = val.toUpperCase();
if (fullHex.length === 4) {
fullHex = '#' + fullHex[1]+fullHex[1]+fullHex[2]+fullHex[2]+fullHex[3]+fullHex[3];
}
$colorSwatch.css('background-color', fullHex);
$colorOutHex.text(fullHex);
$colorOutRgb.text(`rgb(${r}, ${g}, ${b})`);
$colorOutHsl.text(`hsl(${h}, ${s}%, ${l}%)`);
$colorOutCmyk.text(`cmyk(${c}%, ${m}%, ${y}%, ${k}%)`);
lastScale = renderScale(h, s);
renderHarmony(h, s, l);
renderExport(lastScale);
}
});
$('.color-copy-btn').on('click', function() {
const text = $('#' + $(this).data('from')).text();
if (text) window.copyToClipboard(text, $(this));
});
$(document).on('click', '.color-swatch-btn', function() {
window.copyToClipboard($(this).data('copy').toString(), $(this).find('div').last());
});
$('#color-export-format').on('change', () => {
if (lastScale) renderExport(lastScale);
});
$('#color-export-copy-btn').on('click', function() {
const text = $('#color-export-output').text();
if (text) window.copyToClipboard(text, $(this));
});