CMYK / RYB Mixer (RYB via CMYK Recipes)

CMYK Mixer

Direct CMYK sliders or RYB sliders where R/Y/B are defined as CMYK recipes.
Mode
C
M
Y
K
Ink limit
300%
If enabled: scales CMYK down when C+M+Y+K exceeds the limit.
Gamma1.00
Optional display tweak for the screen preview.
Conversion used
R = 255 · (1−C) · (1−K)
G = 255 · (1−M) · (1−K)
B = 255 · (1−Y) · (1−K)
HEX
#000000
RGB
rgb(0, 0, 0)
CMYK (used)
cmyk(0, 0, 0, 0)
Swatches (click to recall)
Note
This is a screen preview. Real print mixing needs ICC profiles, paper/ink, dot gain, etc.
Tip: Use Copy Permalink to share mode + sliders + recipes via URL hash.

 <!-- /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>