CMYK Mixer
R = 255 · (1−C) · (1−K)G = 255 · (1−M) · (1−K)B = 255 · (1−Y) · (1−K)
<!-- /cmyk-ryb-mixer.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>CMYK / RYB Mixer (RYB via CMYK Recipes)</title>
<style>
:root{
--bg:#0b0c10;--panel:rgba(255,255,255,.06);--panel2:rgba(255,255,255,.09);
--text:rgba(255,255,255,.92);--muted:rgba(255,255,255,.7);--border:rgba(255,255,255,.12);
--radius:18px;
}
*{box-sizing:border-box}
body{
margin:0;
font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,"Apple Color Emoji","Segoe UI Emoji";
background:
radial-gradient(1200px 700px at 20% 10%, rgba(99,102,241,0.22), transparent 55%),
radial-gradient(900px 650px at 85% 30%, rgba(34,197,94,0.16), transparent 60%),
radial-gradient(900px 700px at 40% 95%, rgba(236,72,153,0.12), transparent 60%),
var(--bg);
color:var(--text);min-height:100vh;
}
header{max-width:1180px;margin:0 auto;padding:26px 18px 8px;display:flex;align-items:baseline;gap:14px;flex-wrap:wrap}
header h1{margin:0;font-size:20px;letter-spacing:.2px}
header .sub{color:var(--muted);font-size:13px}
main{max-width:1180px;margin:0 auto;padding:12px 18px 28px;display:grid;grid-template-columns:1.15fr .85fr;gap:14px}
@media (max-width:980px){main{grid-template-columns:1fr}}
.card{
background:var(--panel);border:1px solid var(--border);border-radius:var(--radius);
padding:16px;box-shadow:0 12px 40px rgba(0,0,0,.35);backdrop-filter:blur(8px)
}
.controls{display:grid;gap:14px}
.row{
display:grid;grid-template-columns:78px 1fr 80px;gap:12px;align-items:center;
padding:12px;background:var(--panel2);border:1px solid var(--border);border-radius:14px
}
.lbl{font-weight:700;letter-spacing:.2px;font-size:13px}
input[type="range"]{width:100%;accent-color:#a3a3a3}
input[type="number"]{
width:100%;padding:8px 10px;border-radius:12px;border:1px solid var(--border);
background:rgba(0,0,0,.25);color:var(--text);outline:none
}
input[type="number"]:focus{border-color:rgba(255,255,255,.25);box-shadow:0 0 0 3px rgba(255,255,255,.08)}
.twoCols{display:grid;grid-template-columns:1fr 1fr;gap:12px}
@media (max-width:680px){.twoCols{grid-template-columns:1fr}}
.mini{
padding:12px;border-radius:14px;border:1px solid var(--border);
background:rgba(0,0,0,.18);display:grid;gap:8px
}
.mini .title{font-size:12px;color:var(--muted);display:flex;align-items:center;justify-content:space-between;gap:10px}
.mini .value{font-size:13px;font-weight:700}
.mini .hint{font-size:12px;color:var(--muted);line-height:1.35}
.actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:2px}
button{
border:1px solid var(--border);background:rgba(255,255,255,.08);color:var(--text);
padding:10px 12px;border-radius:14px;cursor:pointer;font-weight:700;font-size:13px
}
button:hover{background:rgba(255,255,255,.12)}
button:active{transform:translateY(1px)}
.seg{
display:flex;gap:8px;flex-wrap:wrap;align-items:center;justify-content:space-between;
padding:12px;border-radius:14px;border:1px solid var(--border);background:rgba(0,0,0,.18)
}
.seg .left{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.chip{
display:inline-flex;gap:8px;align-items:center;
padding:8px 10px;border-radius:999px;border:1px solid var(--border);
background:rgba(255,255,255,.06);cursor:pointer;font-size:12px;font-weight:800
}
.chip[aria-pressed="true"]{background:rgba(255,255,255,.14);border-color:rgba(255,255,255,.22)}
.previewWrap{display:grid;gap:14px}
.preview{
border-radius:18px;border:1px solid var(--border);min-height:240px;background:#000;
position:relative;overflow:hidden
}
.preview .overlay{position:absolute;inset:0;background:
linear-gradient(135deg, rgba(255,255,255,0.10), transparent 45%),
radial-gradient(700px 300px at 30% 25%, rgba(255,255,255,0.08), transparent 60%);
pointer-events:none
}
.values{display:grid;grid-template-columns:1fr;gap:12px}
.pill{padding:12px;border-radius:14px;border:1px solid var(--border);background:rgba(0,0,0,.18);display:grid;gap:8px}
.pill .k{font-size:12px;color:var(--muted);display:flex;justify-content:space-between;gap:10px}
.pill .v{font-size:14px;font-weight:800;letter-spacing:.2px}
.pill .btns{display:flex;gap:10px;flex-wrap:wrap}
.pill .btns button{padding:8px 10px;border-radius:12px;font-size:12px}
.swatches{border-radius:18px;border:1px solid var(--border);background:rgba(0,0,0,.18);padding:12px;display:grid;gap:10px}
.swatches .head{display:flex;justify-content:space-between;align-items:center;gap:10px}
.swatches .head .t{font-size:12px;color:var(--muted)}
.grid{display:grid;grid-template-columns:repeat(10,1fr);gap:8px}
@media (max-width:980px){.grid{grid-template-columns:repeat(12,1fr)}}
@media (max-width:520px){.grid{grid-template-columns:repeat(10,1fr)}}
.swatch{
aspect-ratio:1/1;border-radius:12px;border:1px solid rgba(255,255,255,.16);
cursor:pointer;overflow:hidden
}
.swatch:hover{outline:2px solid rgba(255,255,255,.22);outline-offset:1px}
.swatch[aria-selected="true"]{outline:2px solid rgba(255,255,255,.40);outline-offset:1px}
.toast{
position:fixed;left:50%;bottom:18px;transform:translateX(-50%);
background:rgba(0,0,0,.65);border:1px solid rgba(255,255,255,.16);
color:var(--text);padding:10px 12px;border-radius:14px;font-size:13px;
opacity:0;pointer-events:none;transition:opacity 180ms ease;backdrop-filter:blur(6px)
}
.toast.show{opacity:1}
footer{max-width:1180px;margin:0 auto;padding:0 18px 26px;color:var(--muted);font-size:12px;line-height:1.45}
code{color:rgba(255,255,255,.88)}
.recipeGrid{display:grid;grid-template-columns:88px repeat(4,1fr);gap:10px;align-items:center}
.recipeGrid .hdr{font-size:11px;color:var(--muted);font-weight:800;letter-spacing:.25px}
.recipeGrid .tag{
font-size:12px;font-weight:900;letter-spacing:.2px;
padding:8px 10px;border-radius:12px;border:1px solid var(--border);background:rgba(255,255,255,.06)
}
.mutedLine{font-size:12px;color:var(--muted);line-height:1.35}
</style>
</head>
<body>
<header>
<h1>CMYK Mixer</h1>
<div class="sub">Direct CMYK sliders or RYB sliders where R/Y/B are defined as CMYK recipes.</div>
</header>
<main>
<section class="card controls" aria-label="Controls">
<div class="seg" aria-label="Mode">
<div class="left">
<div style="font-weight:900;font-size:13px;letter-spacing:.2px">Mode</div>
<button id="modeCmyk" class="chip" type="button" aria-pressed="true">Direct CMYK</button>
<button id="modeRyb" class="chip" type="button" aria-pressed="false">RYB → CMYK recipes</button>
</div>
<div class="mutedLine" id="modeHint"></div>
</div>
<!-- Direct CMYK sliders -->
<div id="panelCmyk" style="display:grid; gap:14px;">
<div class="row">
<div class="lbl">C</div>
<input id="cRange" type="range" min="0" max="100" step="1" value="0" />
<input id="cNum" type="number" min="0" max="100" step="1" value="0" />
</div>
<div class="row">
<div class="lbl">M</div>
<input id="mRange" type="range" min="0" max="100" step="1" value="0" />
<input id="mNum" type="number" min="0" max="100" step="1" value="0" />
</div>
<div class="row">
<div class="lbl">Y</div>
<input id="yRange" type="range" min="0" max="100" step="1" value="0" />
<input id="yNum" type="number" min="0" max="100" step="1" value="0" />
</div>
<div class="row">
<div class="lbl">K</div>
<input id="kRange" type="range" min="0" max="100" step="1" value="0" />
<input id="kNum" type="number" min="0" max="100" step="1" value="0" />
</div>
</div>
<!-- RYB sliders + recipes -->
<div id="panelRyb" style="display:none; gap:14px;">
<div class="row">
<div class="lbl">R amt</div>
<input id="rRange" type="range" min="0" max="100" step="1" value="0" />
<input id="rNum" type="number" min="0" max="100" step="1" value="0" />
</div>
<div class="row">
<div class="lbl">Y amt</div>
<input id="ryRange" type="range" min="0" max="100" step="1" value="0" />
<input id="ryNum" type="number" min="0" max="100" step="1" value="0" />
</div>
<div class="row">
<div class="lbl">B amt</div>
<input id="bRange" type="range" min="0" max="100" step="1" value="0" />
<input id="bNum" type="number" min="0" max="100" step="1" value="0" />
</div>
<div class="mini">
<div class="title">
<span>RYB → CMYK recipes</span>
<button id="recipesResetBtn" type="button" style="padding:8px 10px;border-radius:12px;font-size:12px;">Reset recipes</button>
</div>
<div class="recipeGrid" aria-label="Recipes">
<div></div>
<div class="hdr">C</div><div class="hdr">M</div><div class="hdr">Y</div><div class="hdr">K</div>
<div class="tag">Red</div>
<input id="rC" type="number" min="0" max="100" step="1" value="0">
<input id="rM" type="number" min="0" max="100" step="1" value="100">
<input id="rY" type="number" min="0" max="100" step="1" value="100">
<input id="rK" type="number" min="0" max="100" step="1" value="0">
<div class="tag">Yellow</div>
<input id="yC2" type="number" min="0" max="100" step="1" value="0">
<input id="yM2" type="number" min="0" max="100" step="1" value="0">
<input id="yY2" type="number" min="0" max="100" step="1" value="100">
<input id="yK2" type="number" min="0" max="100" step="1" value="0">
<div class="tag">Blue</div>
<input id="bC2" type="number" min="0" max="100" step="1" value="100">
<input id="bM2" type="number" min="0" max="100" step="1" value="100">
<input id="bY2" type="number" min="0" max="100" step="1" value="0">
<input id="bK2" type="number" min="0" max="100" step="1" value="0">
</div>
<div class="mutedLine" style="margin-top:10px;">
Generated CMYK = (Ramt·RedRecipe + Yamt·YellowRecipe + Bamt·BlueRecipe), per channel, then clamped to 0–100.
</div>
</div>
</div>
<div class="twoCols" aria-label="Tuning">
<div class="mini">
<div class="title">
<span>Ink limit</span>
<label style="display:flex;gap:8px;align-items:center;color:var(--muted);font-size:12px;">
<input id="limitOn" type="checkbox" />
enabled
</label>
</div>
<div class="value"><span id="limitLabel">300%</span></div>
<input id="limitRange" type="range" min="100" max="400" step="1" value="300" />
<div class="hint">If enabled: scales CMYK down when C+M+Y+K exceeds the limit.</div>
</div>
<div class="mini">
<div class="title"><span>Gamma</span><span id="gammaLabel">1.00</span></div>
<input id="gammaRange" type="range" min="0.6" max="2.2" step="0.01" value="1.00" />
<div class="hint">Optional display tweak for the screen preview.</div>
</div>
</div>
<div class="actions">
<button id="resetBtn" type="button">Reset sliders</button>
<button id="randomBtn" type="button">Random sliders</button>
<button id="linkBtn" type="button">Copy Permalink</button>
</div>
<div class="mini">
<div class="title"><span>Conversion used</span></div>
<div class="hint">
<code>R = 255 · (1−C) · (1−K)</code><br/>
<code>G = 255 · (1−M) · (1−K)</code><br/>
<code>B = 255 · (1−Y) · (1−K)</code>
</div>
</div>
</section>
<section class="card previewWrap" aria-label="Preview">
<div id="preview" class="preview" role="img" aria-label="Resulting color preview">
<div class="overlay"></div>
</div>
<div class="values" aria-label="Values">
<div class="pill">
<div class="k"><span>HEX</span><span id="hexSmall" style="color:var(--muted); font-size:12px;"></span></div>
<div class="v" id="hexValue">#000000</div>
<div class="btns"><button id="copyHexBtn" type="button">Copy HEX</button></div>
</div>
<div class="pill">
<div class="k"><span>RGB</span><span id="rgbSmall" style="color:var(--muted); font-size:12px;"></span></div>
<div class="v" id="rgbValue">rgb(0, 0, 0)</div>
<div class="btns"><button id="copyRgbBtn" type="button">Copy RGB</button></div>
</div>
<div class="pill">
<div class="k"><span>CMYK (used)</span><span id="cmykMeta" style="color:var(--muted); font-size:12px;"></span></div>
<div class="v" id="cmykValue">cmyk(0, 0, 0, 0)</div>
<div class="btns">
<button id="copyCmykBtn" type="button">Copy CMYK</button>
<button id="saveSwatchBtn" type="button">Save Swatch</button>
</div>
</div>
<div class="swatches" aria-label="Swatches">
<div class="head">
<div class="t">Swatches (click to recall)</div>
<button id="clearSwatchesBtn" type="button" style="padding:8px 10px;border-radius:12px;font-size:12px;">Clear</button>
</div>
<div id="swatchGrid" class="grid" aria-label="Swatch grid"></div>
</div>
<div class="pill">
<div class="k"><span>Note</span></div>
<div class="mutedLine">This is a screen preview. Real print mixing needs ICC profiles, paper/ink, dot gain, etc.</div>
</div>
</div>
</section>
</main>
<footer>
Tip: Use <code>Copy Permalink</code> to share mode + sliders + recipes via URL hash.
</footer>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script>
/**
* CMYK / RYB Mixer
* - Mode: direct CMYK OR RYB amounts that generate CMYK via user recipes.
* - URL hash persists everything.
* - Swatches persist in localStorage.
*/
const el = (id) => document.getElementById(id);
const toast = el("toast");
const ui = {
modeCmyk: el("modeCmyk"),
modeRyb: el("modeRyb"),
modeHint: el("modeHint"),
panelCmyk: el("panelCmyk"),
panelRyb: el("panelRyb"),
c: { range: el("cRange"), num: el("cNum") },
m: { range: el("mRange"), num: el("mNum") },
y: { range: el("yRange"), num: el("yNum") },
k: { range: el("kRange"), num: el("kNum") },
rAmt: { range: el("rRange"), num: el("rNum") },
yAmt: { range: el("ryRange"), num: el("ryNum") },
bAmt: { range: el("bRange"), num: el("bNum") },
recipes: {
r: { c: el("rC"), m: el("rM"), y: el("rY"), k: el("rK") },
y: { c: el("yC2"), m: el("yM2"), y: el("yY2"), k: el("yK2") },
b: { c: el("bC2"), m: el("bM2"), y: el("bY2"), k: el("bK2") },
},
recipesResetBtn: el("recipesResetBtn"),
limitOn: el("limitOn"),
limitRange: el("limitRange"),
limitLabel: el("limitLabel"),
gammaRange: el("gammaRange"),
gammaLabel: el("gammaLabel"),
resetBtn: el("resetBtn"),
randomBtn: el("randomBtn"),
linkBtn: el("linkBtn"),
preview: el("preview"),
hexValue: el("hexValue"),
rgbValue: el("rgbValue"),
cmykValue: el("cmykValue"),
hexSmall: el("hexSmall"),
rgbSmall: el("rgbSmall"),
cmykMeta: el("cmykMeta"),
copyHexBtn: el("copyHexBtn"),
copyRgbBtn: el("copyRgbBtn"),
copyCmykBtn: el("copyCmykBtn"),
saveSwatchBtn: el("saveSwatchBtn"),
clearSwatchesBtn: el("clearSwatchesBtn"),
swatchGrid: el("swatchGrid"),
};
const STORAGE_KEY = "cmyk_ryb_mixer_swatches_v1";
const MAX_SWATCHES = 36;
function clamp(n, min, max) { return Math.min(max, Math.max(min, n)); }
function roundInt(n) { return Math.round(n); }
function toHexByte(n) {
const v = clamp(roundInt(n), 0, 255);
return v.toString(16).padStart(2, "0").toUpperCase();
}
function rgbToHex(r,g,b) { return `#${toHexByte(r)}${toHexByte(g)}${toHexByte(b)}`; }
function showToast(msg) {
toast.textContent = msg;
toast.classList.add("show");
window.clearTimeout(showToast._t);
showToast._t = window.setTimeout(() => toast.classList.remove("show"), 1200);
}
async function copyText(text) {
try {
await navigator.clipboard.writeText(text);
showToast("Copied");
} catch {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
showToast("Copied");
}
}
function applyGamma(channel01, gamma) {
if (!isFinite(gamma) || gamma <= 0) return channel01;
return Math.pow(clamp(channel01, 0, 1), 1 / gamma);
}
function maybeInkLimit(c, m, y, k, enabled, limitPct) {
if (!enabled) return { c, m, y, k };
const sum = c + m + y + k;
const limit = clamp(limitPct, 100, 400);
if (sum <= limit) return { c, m, y, k };
const scale = limit / sum;
return { c: c * scale, m: m * scale, y: y * scale, k: k * scale };
}
function cmykToRgb(cPct, mPct, yPct, kPct, gamma) {
const c = clamp(cPct, 0, 100) / 100;
const m = clamp(mPct, 0, 100) / 100;
const y = clamp(yPct, 0, 100) / 100;
const k = clamp(kPct, 0, 100) / 100;
let r01 = (1 - c) * (1 - k);
let g01 = (1 - m) * (1 - k);
let b01 = (1 - y) * (1 - k);
r01 = applyGamma(r01, gamma);
g01 = applyGamma(g01, gamma);
b01 = applyGamma(b01, gamma);
return { r: clamp(r01 * 255, 0, 255), g: clamp(g01 * 255, 0, 255), b: clamp(b01 * 255, 0, 255) };
}
function linkInputs(rangeEl, numEl, onChange) {
const syncFromRange = () => { numEl.value = rangeEl.value; onChange(); };
const syncFromNum = () => {
const v = clamp(Number(numEl.value || 0), Number(numEl.min), Number(numEl.max));
numEl.value = String(v);
rangeEl.value = String(v);
onChange();
};
rangeEl.addEventListener("input", syncFromRange);
numEl.addEventListener("input", syncFromNum);
numEl.addEventListener("blur", syncFromNum);
}
function setMode(mode) {
const isCmyk = mode === "cmyk";
ui.modeCmyk.setAttribute("aria-pressed", isCmyk ? "true" : "false");
ui.modeRyb.setAttribute("aria-pressed", isCmyk ? "false" : "true");
ui.panelCmyk.style.display = isCmyk ? "grid" : "none";
ui.panelRyb.style.display = isCmyk ? "none" : "grid";
ui.modeHint.textContent = isCmyk
? "Sliders directly set CMYK."
: "Sliders set R/Y/B amounts; those generate CMYK via your recipes.";
render();
}
function defaultRecipes() {
return {
r: { c: 0, m: 100, y: 100, k: 0 },
y: { c: 0, m: 0, y: 100, k: 0 },
b: { c: 100, m: 100, y: 0, k: 0 },
};
}
function getRecipesFromUI() {
const read = (ch) => ({
c: clamp(Number(ui.recipes[ch].c.value), 0, 100),
m: clamp(Number(ui.recipes[ch].m.value), 0, 100),
y: clamp(Number(ui.recipes[ch].y.value), 0, 100),
k: clamp(Number(ui.recipes[ch].k.value), 0, 100),
});
return { r: read("r"), y: read("y"), b: read("b") };
}
function setRecipesToUI(recipes) {
const write = (ch, v) => {
ui.recipes[ch].c.value = String(roundInt(v.c));
ui.recipes[ch].m.value = String(roundInt(v.m));
ui.recipes[ch].y.value = String(roundInt(v.y));
ui.recipes[ch].k.value = String(roundInt(v.k));
};
write("r", recipes.r);
write("y", recipes.y);
write("b", recipes.b);
}
function generateCmykFromRyb(rAmt, yAmt, bAmt, recipes) {
const r = clamp(rAmt, 0, 100) / 100;
const y = clamp(yAmt, 0, 100) / 100;
const b = clamp(bAmt, 0, 100) / 100;
const out = {
c: r * recipes.r.c + y * recipes.y.c + b * recipes.b.c,
m: r * recipes.r.m + y * recipes.y.m + b * recipes.b.m,
y: r * recipes.r.y + y * recipes.y.y + b * recipes.b.y,
k: r * recipes.r.k + y * recipes.y.k + b * recipes.b.k,
};
return {
c: clamp(out.c, 0, 100),
m: clamp(out.m, 0, 100),
y: clamp(out.y, 0, 100),
k: clamp(out.k, 0, 100),
};
}
function getMode() {
return ui.modeCmyk.getAttribute("aria-pressed") === "true" ? "cmyk" : "ryb";
}
function updateLabels() {
ui.limitLabel.textContent = `${Number(ui.limitRange.value)}%`;
ui.gammaLabel.textContent = Number(ui.gammaRange.value).toFixed(2);
}
function parseHash() {
const raw = (location.hash || "").replace(/^#/, "");
if (!raw) return null;
const params = new URLSearchParams(raw);
const out = {};
for (const [k, v] of params.entries()) out[k] = v;
return out;
}
function writeHash(state) {
const p = new URLSearchParams();
p.set("mode", state.mode);
p.set("c", String(roundInt(state.c)));
p.set("m", String(roundInt(state.m)));
p.set("y", String(roundInt(state.y)));
p.set("k", String(roundInt(state.k)));
p.set("r", String(roundInt(state.r)));
p.set("ry", String(roundInt(state.ry)));
p.set("b", String(roundInt(state.b)));
p.set("limit", String(roundInt(state.limit)));
p.set("limitOn", state.limitOn ? "1" : "0");
p.set("g", Number(state.gamma).toFixed(2));
const rec = state.recipes;
p.set("rr", `${roundInt(rec.r.c)},${roundInt(rec.r.m)},${roundInt(rec.r.y)},${roundInt(rec.r.k)}`);
p.set("yr", `${roundInt(rec.y.c)},${roundInt(rec.y.m)},${roundInt(rec.y.y)},${roundInt(rec.y.k)}`);
p.set("br", `${roundInt(rec.b.c)},${roundInt(rec.b.m)},${roundInt(rec.b.y)},${roundInt(rec.b.k)}`);
const next = `#${p.toString()}`;
if (location.hash !== next) history.replaceState(null, "", next);
}
function readStateFromHashOrDefaults() {
const h = parseHash();
const recDefault = defaultRecipes();
const parseRecipe = (s, fallback) => {
if (!s) return fallback;
const parts = String(s).split(",").map(Number);
if (parts.length !== 4 || parts.some((n) => !isFinite(n))) return fallback;
return {
c: clamp(parts[0], 0, 100),
m: clamp(parts[1], 0, 100),
y: clamp(parts[2], 0, 100),
k: clamp(parts[3], 0, 100),
};
};
if (!h) {
return {
mode: "cmyk",
c: 0, m: 0, y: 0, k: 0,
r: 0, ry: 0, b: 0,
limit: 300, limitOn: false, gamma: 1.0,
recipes: recDefault,
};
}
return {
mode: (h.mode === "ryb" ? "ryb" : "cmyk"),
c: Number(h.c ?? 0), m: Number(h.m ?? 0), y: Number(h.y ?? 0), k: Number(h.k ?? 0),
r: Number(h.r ?? 0), ry: Number(h.ry ?? 0), b: Number(h.b ?? 0),
limit: Number(h.limit ?? 300),
limitOn: (h.limitOn ?? "0") === "1",
gamma: Number(h.g ?? h.gamma ?? 1.0),
recipes: {
r: parseRecipe(h.rr, recDefault.r),
y: parseRecipe(h.yr, recDefault.y),
b: parseRecipe(h.br, recDefault.b),
},
};
}
function setUIFromState(s) {
const mode = (s.mode === "ryb") ? "ryb" : "cmyk";
ui.c.range.value = ui.c.num.value = String(clamp(Number(s.c ?? 0), 0, 100));
ui.m.range.value = ui.m.num.value = String(clamp(Number(s.m ?? 0), 0, 100));
ui.y.range.value = ui.y.num.value = String(clamp(Number(s.y ?? 0), 0, 100));
ui.k.range.value = ui.k.num.value = String(clamp(Number(s.k ?? 0), 0, 100));
ui.rAmt.range.value = ui.rAmt.num.value = String(clamp(Number(s.r ?? 0), 0, 100));
ui.yAmt.range.value = ui.yAmt.num.value = String(clamp(Number(s.ry ?? 0), 0, 100));
ui.bAmt.range.value = ui.bAmt.num.value = String(clamp(Number(s.b ?? 0), 0, 100));
ui.limitOn.checked = !!s.limitOn;
ui.limitRange.value = String(clamp(Number(s.limit ?? 300), 100, 400));
ui.gammaRange.value = String(clamp(Number(s.gamma ?? 1.0), 0.6, 2.2).toFixed(2));
setRecipesToUI(s.recipes ?? defaultRecipes());
if (mode === "cmyk") {
ui.modeCmyk.setAttribute("aria-pressed", "true");
ui.modeRyb.setAttribute("aria-pressed", "false");
ui.panelCmyk.style.display = "grid";
ui.panelRyb.style.display = "none";
ui.modeHint.textContent = "Sliders directly set CMYK.";
} else {
ui.modeCmyk.setAttribute("aria-pressed", "false");
ui.modeRyb.setAttribute("aria-pressed", "true");
ui.panelCmyk.style.display = "none";
ui.panelRyb.style.display = "grid";
ui.modeHint.textContent = "Sliders set R/Y/B amounts; those generate CMYK via your recipes.";
}
updateLabels();
}
function getStateFromUI() {
const mode = getMode();
const recipes = getRecipesFromUI();
return {
mode,
c: Number(ui.c.num.value),
m: Number(ui.m.num.value),
y: Number(ui.y.num.value),
k: Number(ui.k.num.value),
r: Number(ui.rAmt.num.value),
ry: Number(ui.yAmt.num.value),
b: Number(ui.bAmt.num.value),
limitOn: ui.limitOn.checked,
limit: Number(ui.limitRange.value),
gamma: Number(ui.gammaRange.value),
recipes,
};
}
function loadSwatches() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const arr = raw ? JSON.parse(raw) : [];
return Array.isArray(arr) ? arr : [];
} catch {
return [];
}
}
function saveSwatches(swatches) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(swatches.slice(0, MAX_SWATCHES)));
}
function swatchKey(state) {
const rec = state.recipes;
const recKey = `rr=${roundInt(rec.r.c)},${roundInt(rec.r.m)},${roundInt(rec.r.y)},${roundInt(rec.r.k)};` +
`yr=${roundInt(rec.y.c)},${roundInt(rec.y.m)},${roundInt(rec.y.y)},${roundInt(rec.y.k)};` +
`br=${roundInt(rec.b.c)},${roundInt(rec.b.m)},${roundInt(rec.b.y)},${roundInt(rec.b.k)}`;
if (state.mode === "cmyk") {
return `mode=cmyk;c=${roundInt(state.c)},m=${roundInt(state.m)},y=${roundInt(state.y)},k=${roundInt(state.k)};` +
`L${state.limitOn?1:0}:${roundInt(state.limit)};G${Number(state.gamma).toFixed(2)};${recKey}`;
}
return `mode=ryb;r=${roundInt(state.r)},ry=${roundInt(state.ry)},b=${roundInt(state.b)};` +
`L${state.limitOn?1:0}:${roundInt(state.limit)};G${Number(state.gamma).toFixed(2)};${recKey}`;
}
function computeUsedCmyk(state) {
if (state.mode === "cmyk") {
return {
c: clamp(state.c, 0, 100),
m: clamp(state.m, 0, 100),
y: clamp(state.y, 0, 100),
k: clamp(state.k, 0, 100),
meta: "direct",
};
}
const gen = generateCmykFromRyb(state.r, state.ry, state.b, state.recipes);
return { ...gen, meta: "from RYB recipes" };
}
function markActiveSwatch(hex) {
const nodes = [...ui.swatchGrid.querySelectorAll(".swatch")];
for (const n of nodes) n.setAttribute("aria-selected", "false");
const swatches = loadSwatches();
const idx = swatches.findIndex(s => s.hex === hex);
if (idx >= 0 && nodes[idx]) nodes[idx].setAttribute("aria-selected", "true");
}
function renderSwatches() {
ui.swatchGrid.innerHTML = "";
const swatches = loadSwatches();
for (const sw of swatches) {
const div = document.createElement("div");
div.className = "swatch";
div.tabIndex = 0;
div.role = "button";
div.title = `${sw.hex} — ${sw.mode === "cmyk"
? `cmyk(${sw.c},${sw.m},${sw.y},${sw.k})`
: `ryb(${sw.r},${sw.ry},${sw.b}) → cmyk(${sw.usedC},${sw.usedM},${sw.usedY},${sw.usedK})`}`;
div.style.background = sw.hex;
div.addEventListener("click", () => {
setUIFromState(sw.state);
render();
});
div.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); div.click(); }
});
ui.swatchGrid.appendChild(div);
}
markActiveSwatch(ui.hexValue.textContent);
}
function render() {
updateLabels();
const state = getStateFromUI();
const used0 = computeUsedCmyk(state);
const limited = maybeInkLimit(
used0.c, used0.m, used0.y, used0.k,
state.limitOn,
state.limit
);
const rgb = cmykToRgb(limited.c, limited.m, limited.y, limited.k, state.gamma);
const r = roundInt(rgb.r), g = roundInt(rgb.g), b = roundInt(rgb.b);
const hex = rgbToHex(r,g,b);
ui.preview.style.background = `rgb(${r}, ${g}, ${b})`;
ui.hexValue.textContent = hex;
ui.rgbValue.textContent = `rgb(${r}, ${g}, ${b})`;
ui.cmykValue.textContent = `cmyk(${roundInt(limited.c)}, ${roundInt(limited.m)}, ${roundInt(limited.y)}, ${roundInt(limited.k)})`;
ui.cmykMeta.textContent = (state.mode === "cmyk") ? "direct" : used0.meta;
ui.hexSmall.textContent = state.limitOn ? "limited" : "";
ui.rgbSmall.textContent = state.gamma !== 1 ? `γ ${Number(state.gamma).toFixed(2)}` : "";
writeHash({
mode: state.mode,
c: state.c, m: state.m, y: state.y, k: state.k,
r: state.r, ry: state.ry, b: state.b,
limit: state.limit, limitOn: state.limitOn, gamma: state.gamma,
recipes: state.recipes,
});
markActiveSwatch(hex);
}
function addCurrentSwatch() {
const state = getStateFromUI();
const used = computeUsedCmyk(state);
const limited = maybeInkLimit(used.c, used.m, used.y, used.k, state.limitOn, state.limit);
const rgb = cmykToRgb(limited.c, limited.m, limited.y, limited.k, state.gamma);
const hex = rgbToHex(roundInt(rgb.r), roundInt(rgb.g), roundInt(rgb.b));
const swatches = loadSwatches();
const key = swatchKey(state);
const entry = {
key,
hex,
mode: state.mode,
c: roundInt(state.c), m: roundInt(state.m), y: roundInt(state.y), k: roundInt(state.k),
r: roundInt(state.r), ry: roundInt(state.ry), b: roundInt(state.b),
usedC: roundInt(limited.c), usedM: roundInt(limited.m), usedY: roundInt(limited.y), usedK: roundInt(limited.k),
state: JSON.parse(JSON.stringify(state)),
};
const next = [entry, ...swatches.filter(s => s.key !== key)].slice(0, MAX_SWATCHES);
saveSwatches(next);
renderSwatches();
showToast("Swatch saved");
}
function clearSwatches() {
localStorage.removeItem(STORAGE_KEY);
renderSwatches();
showToast("Cleared");
}
function resetSliders() {
const mode = getMode();
if (mode === "cmyk") {
ui.c.range.value = ui.c.num.value = "0";
ui.m.range.value = ui.m.num.value = "0";
ui.y.range.value = ui.y.num.value = "0";
ui.k.range.value = ui.k.num.value = "0";
} else {
ui.rAmt.range.value = ui.rAmt.num.value = "0";
ui.yAmt.range.value = ui.yAmt.num.value = "0";
ui.bAmt.range.value = ui.bAmt.num.value = "0";
}
render();
}
function randomizeSliders() {
const mode = getMode();
const rnd = () => String(Math.floor(Math.random() * 101));
if (mode === "cmyk") {
ui.c.range.value = ui.c.num.value = rnd();
ui.m.range.value = ui.m.num.value = rnd();
ui.y.range.value = ui.y.num.value = rnd();
ui.k.range.value = ui.k.num.value = rnd();
} else {
ui.rAmt.range.value = ui.rAmt.num.value = rnd();
ui.yAmt.range.value = ui.yAmt.num.value = rnd();
ui.bAmt.range.value = ui.bAmt.num.value = rnd();
}
render();
}
function init() {
linkInputs(ui.c.range, ui.c.num, render);
linkInputs(ui.m.range, ui.m.num, render);
linkInputs(ui.y.range, ui.y.num, render);
linkInputs(ui.k.range, ui.k.num, render);
linkInputs(ui.rAmt.range, ui.rAmt.num, render);
linkInputs(ui.yAmt.range, ui.yAmt.num, render);
linkInputs(ui.bAmt.range, ui.bAmt.num, render);
for (const ch of ["r","y","b"]) {
for (const k of ["c","m","y","k"]) {
ui.recipes[ch][k].addEventListener("input", () => {
ui.recipes[ch][k].value = String(clamp(Number(ui.recipes[ch][k].value || 0), 0, 100));
render();
});
ui.recipes[ch][k].addEventListener("blur", () => {
ui.recipes[ch][k].value = String(clamp(Number(ui.recipes[ch][k].value || 0), 0, 100));
render();
});
}
}
ui.modeCmyk.addEventListener("click", () => setMode("cmyk"));
ui.modeRyb.addEventListener("click", () => setMode("ryb"));
ui.limitOn.addEventListener("change", render);
ui.limitRange.addEventListener("input", render);
ui.gammaRange.addEventListener("input", render);
ui.resetBtn.addEventListener("click", resetSliders);
ui.randomBtn.addEventListener("click", randomizeSliders);
ui.linkBtn.addEventListener("click", () => copyText(location.href));
ui.copyHexBtn.addEventListener("click", () => copyText(ui.hexValue.textContent));
ui.copyRgbBtn.addEventListener("click", () => copyText(ui.rgbValue.textContent));
ui.copyCmykBtn.addEventListener("click", () => copyText(ui.cmykValue.textContent));
ui.saveSwatchBtn.addEventListener("click", addCurrentSwatch);
ui.clearSwatchesBtn.addEventListener("click", clearSwatches);
ui.recipesResetBtn.addEventListener("click", () => {
setRecipesToUI(defaultRecipes());
render();
showToast("Recipes reset");
});
window.addEventListener("hashchange", () => {
const s = readStateFromHashOrDefaults();
setUIFromState(s);
render();
});
const s = readStateFromHashOrDefaults();
setUIFromState(s);
renderSwatches();
render();
}
init();
</script>
</body>
</html>