Image to Theme
Load an image -- it stays in your browser -- and pull a colour theme from it
Running live from https://fun.whitneys.co,
cross-origin and sandboxed.
Put it on your site
<iframe
src="https://fun.whitneys.co/g/image-theme"
width="360"
height="620"
title="Image to Theme"
sandbox="allow-scripts allow-popups-to-escape-sandbox"
allow="clipboard-write"
loading="lazy"
style="border:0"></iframe>
An iframe, not a script tag -- so this gadget cannot touch the page you paste it into, and neither can we. Your settings above are baked into the URL.
Optional: let it resize itself to fit
<script>
addEventListener('message', function (e) {
var d = e.data;
if (!d || d.type !== 'fwf:height') return;
if (typeof d.height !== 'number' || !isFinite(d.height)) return;
document.querySelectorAll('iframe').forEach(function (f) {
// Identity by source window, not by origin -- sandboxed frames report
// their origin as "null". Do not "fix" this into an origin check.
if (f.contentWindow !== e.source) return;
f.style.height = Math.max(40, Math.min(2000, Math.ceil(d.height))) + 'px';
});
});
</script>
The whole thing
Raw fileEvery gadget is one file, and this is it -- all 28.7 kB. Take it, change it, ship it. Just keep the credit.
Show source
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<script type="application/json" id="gadget-manifest">
{
"name": "Image to Theme",
"tagline": "Load an image -- it stays in your browser -- and pull a colour theme from it",
"category": "media",
"tags": ["image", "palette", "theme", "color"],
"order": 9,
"author": "rwhitney",
"background": "dark",
"size": { "w": 360, "h": 620, "resizable": true },
"config": {
"count": { "type": "range", "label": "Palette size", "min": 4, "max": 8, "step": 1, "default": 6 },
"mode": { "type": "select", "label": "Theme", "options": ["dark", "light"], "default": "dark" },
"accent": { "type": "color", "label": "UI accent", "default": "#66D9EF" }
},
"permissions": ["clipboard"]
}
</script>
<style>
:root { --ink:#F8F8F2; --dim:#A59F85; --line:rgb(248 248 242 / .14);
--panel:rgb(248 248 242 / .05); }
html, body {
margin:0; background:transparent; color:var(--ink);
font:14px/1.4 ui-sans-serif, system-ui, sans-serif;
}
#app { padding:14px; box-sizing:border-box; }
h3 { font-size:10px; letter-spacing:.14em; text-transform:uppercase;
color:var(--dim); margin:16px 0 8px; font-weight:700; }
h3:first-child { margin-top:0; }
/* ---- drop zone ------------------------------------------------------- */
#drop {
border:1.5px dashed var(--line); border-radius:12px;
padding:22px 14px; text-align:center; cursor:pointer;
transition:border-color .12s, background .12s;
}
#drop:hover, #drop.over { border-color:var(--accent, #66D9EF);
background:rgb(102 217 239 / .06); }
#drop .big { font-weight:700; margin-bottom:4px; }
#drop .sub { font-size:11px; color:var(--dim); }
#drop .priv { font-size:10.5px; color:var(--accent, #66D9EF); margin-top:8px;
letter-spacing:.03em; }
#file { display:none; }
#thumb { width:100%; max-height:150px; object-fit:contain; border-radius:9px;
margin-top:12px; border:1px solid var(--line); display:none;
background:rgb(0 0 0 / .25); }
#thumb.show { display:block; }
/* ---- palette --------------------------------------------------------- */
#palette { display:flex; gap:5px; }
#palette button {
flex:1; height:52px; border-radius:7px; border:1px solid var(--line);
cursor:pointer; padding:0 2px 5px; position:relative; color:#000; min-width:0;
font:10px/1.2 ui-monospace, Menlo, monospace; font-weight:700;
display:flex; flex-direction:column; align-items:center; justify-content:flex-end; gap:2px;
overflow:hidden;
}
#palette button .pp { font-weight:600; opacity:.75; font-size:9px; letter-spacing:.02em; }
#palette button:hover { transform:translateY(-2px); }
#palette button.copied::after { content:'copied'; position:absolute; inset:0;
display:flex; align-items:center; justify-content:center;
background:rgb(0 0 0 / .55); color:#fff; font-weight:700; }
/* ---- theme preview (a real little UI in the derived theme) ----------- */
.palhead { display:flex; align-items:center; justify-content:space-between;
gap:10px 14px; flex-wrap:wrap; margin:18px 0 9px; }
.palhead h3 { margin:0; }
#controls { display:flex; align-items:center; gap:9px; flex-wrap:wrap; }
.seg { display:inline-flex; border:1px solid var(--line); border-radius:8px;
overflow:hidden; background:var(--panel); }
.seg[hidden] { display:none; }
.seg button { border:0; background:transparent; color:var(--dim); font:inherit;
font-size:11px; font-weight:600; padding:5px 9px; cursor:pointer; }
.seg button + button { border-left:1px solid var(--line); }
.seg button.on { background:var(--accent, #66D9EF); color:#14150F; }
.seg button:not(.on):hover { color:var(--ink); }
#countWrap { display:inline-flex; align-items:center; gap:7px;
font-size:11px; font-weight:600; color:var(--dim); }
#countWrap input[type=range] { width:92px; accent-color:var(--accent, #66D9EF); }
#countWrap b { color:var(--ink); min-width:15px; text-align:center; }
.palnote { margin:7px 0 0; font-size:10.5px; color:var(--dim); }
#preview { border-radius:11px; overflow:hidden; border:1px solid var(--line); }
#preview .bar { padding:10px 12px; display:flex; align-items:center; gap:8px;
font-weight:800; }
#preview .body { padding:12px; }
#preview .body h4 { margin:0 0 5px; font-size:15px; }
#preview .body p { margin:0 0 10px; font-size:12.5px; }
#preview .btn { display:inline-block; padding:6px 14px; border-radius:7px;
font-weight:700; font-size:12px; }
#preview .dot { width:10px; height:10px; border-radius:50%; }
/* ---- roles + css ----------------------------------------------------- */
.roles { display:grid; grid-template-columns:1fr 1fr; gap:6px; }
.role { display:flex; align-items:center; gap:8px; background:var(--panel);
border:1px solid var(--line); border-radius:7px; padding:6px 8px;
cursor:pointer; font:inherit; color:var(--ink); text-align:left; }
.role .sw { width:18px; height:18px; border-radius:4px; border:1px solid rgb(0 0 0 / .4);
flex:none; }
.role .k { font-size:10px; color:var(--dim); text-transform:uppercase; letter-spacing:.05em; }
.role .v { font:11px/1 ui-monospace, Menlo, monospace; }
.cssbox { position:relative; margin-top:8px; }
.cssbox pre { margin:0; background:rgb(0 0 0 / .3); border:1px solid var(--line);
border-radius:8px; padding:10px 11px; overflow-x:auto;
font:11px/1.55 ui-monospace, Menlo, Consolas, monospace; color:var(--ink); }
#copycss { position:absolute; top:7px; right:7px; font:inherit; font-size:11px;
font-weight:600; padding:4px 10px; border-radius:6px;
border:1px solid var(--line); background:var(--panel); color:var(--dim);
cursor:pointer; }
#copycss:hover { color:var(--accent, #66D9EF); border-color:var(--accent, #66D9EF); }
#copycss.copied { color:var(--accent, #66D9EF); }
#results { display:none; }
#results.show { display:block; }
button:focus-visible, #drop:focus-visible { outline:2px solid var(--accent, #66D9EF); outline-offset:2px; }
.sr { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); }
</style>
</head>
<body>
<div id="app">
<div id="drop" tabindex="0" role="button" aria-label="Load an image">
<div class="big">Drop an image or click to load</div>
<div class="sub">PNG, JPG, GIF, WEBP</div>
<div class="priv">🔒 Analysed in your browser -- never uploaded anywhere</div>
<input id="file" type="file" accept="image/*">
</div>
<img id="thumb" alt="Loaded image preview">
<div id="results">
<div class="palhead">
<h3>Palette</h3>
<div id="controls">
<div class="seg" id="mode">
<button type="button" data-mode="image" class="on">From image</button>
<button type="button" data-mode="harmony">Harmony</button>
</div>
<div class="seg" id="scheme" hidden>
<button type="button" data-scheme="complementary" class="on">Compl.</button>
<button type="button" data-scheme="triadic">Triadic</button>
<button type="button" data-scheme="analogous">Analog.</button>
<button type="button" data-scheme="split">Split</button>
</div>
<label id="countWrap">Colors
<input type="range" id="count" min="3" max="12" value="6" aria-label="Number of colours">
<b id="countN">6</b>
</label>
</div>
</div>
<div id="palette"></div>
<p class="palnote" id="palnote">Tap a colour to copy. Pill width = share of the image.</p>
<h3>Derived theme</h3>
<div id="preview">
<div class="bar" id="pvBar"><span class="dot" id="pvDot"></span><span>Your Product</span></div>
<div class="body" id="pvBody">
<h4 id="pvH">A theme, straight from the image</h4>
<p id="pvP">Background, surface, text and accent -- picked to work together.</p>
<span class="btn" id="pvBtn">Get started</span>
</div>
</div>
<h3>Roles</h3>
<div class="roles" id="roles"></div>
<h3>CSS</h3>
<div class="cssbox">
<button id="copycss" type="button">copy</button>
<pre id="css"></pre>
</div>
</div>
<canvas id="cv" width="1" height="1" class="sr" aria-hidden="true"></canvas>
<div class="sr" role="status" aria-live="polite" id="say"></div>
</div>
<script>
(function () {
var cfg = window.gadget ? window.gadget.config : {};
var COUNT = Math.min(12, Math.max(3, cfg.count || 6)); // live, from the slider
var VIEW = 'image'; // 'image' | 'harmony'
var SCHEME = 'complementary'; // harmony scheme
var MODE = cfg.mode === 'light' ? 'light' : 'dark'; // theme light/dark (deriveTheme)
document.documentElement.style.setProperty('--accent', cfg.accent || '#66D9EF');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d', { willReadFrequently: true });
/* =====================================================================
Palette extraction.
NOT median cut. Median cut splits colour space by pixel POPULATION and
returns the mean of each box, so a photo dominated by muddy midtones
(rock, water, foliage) comes back as six muddy midtones -- a vivid sun or
a patch of blue sky, being a small population, gets averaged away.
Instead, two passes over a colour histogram:
1. Distinct-hue coverage -- the colour STORY. Group vivid pixels into
hue families; take the strongest, then only families whose hue is
>=45deg from every hue already taken. That reserves a slot for a real
minority hue (a little blue sky) instead of spending them all on
adjacent warm tones. A noise floor keeps stray speckle out.
2. Lightness/vibrancy range -- add a light tint and a dark shade so the
palette has depth, not one flat brightness.
Each swatch also carries `share`: the fraction of the image nearest to it
-- roughly how much of a design it should carry (drives the pill size).
Verified in Node against real photos before shipping.
===================================================================== */
function dist(a, b) { var d = 0; for (var c = 0; c < 3; c++) { var e = a[c] - b[c]; d += e * e; } return d; }
function rgbToHsl(c) {
var r = c[0] / 255, g = c[1] / 255, b = c[2] / 255;
var mx = Math.max(r, g, b), mn = Math.min(r, g, b), l = (mx + mn) / 2, h, s;
if (mx === mn) { h = 0; s = 0; }
else {
var d = mx - mn;
s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
if (mx === r) h = (g - b) / d + (g < b ? 6 : 0);
else if (mx === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h /= 6;
}
return [h, s, l];
}
// Coarse colour histogram: 5 bits/channel. Bins are tight, so a bin's mean
// is a faithful colour -- no cross-region averaging to mud.
function histogram(pixels) {
var bins = {};
for (var i = 0; i < pixels.length; i++) {
var p = pixels[i];
var key = ((p[0] >> 3) << 10) | ((p[1] >> 3) << 5) | (p[2] >> 3);
var e = bins[key];
if (e) { e.n++; e.r += p[0]; e.g += p[1]; e.b += p[2]; }
else bins[key] = { n: 1, r: p[0], g: p[1], b: p[2] };
}
var out = [];
for (var k in bins) {
var b = bins[k];
var rgb = [Math.round(b.r / b.n), Math.round(b.g / b.n), Math.round(b.b / b.n)];
out.push({ rgb: rgb, n: b.n, hsl: rgbToHsl(rgb) });
}
return out;
}
function hueGap(a, b) { var d = Math.abs(a - b) % 360; return Math.min(d, 360 - d); }
function paletteOf(pixels, count) {
if (!pixels.length) return [];
var bins = histogram(pixels);
if (!bins.length) return [];
var maxN = 0, totN = 0, i, b;
for (i = 0; i < bins.length; i++) { if (bins[i].n > maxN) maxN = bins[i].n; totN += bins[i].n; }
var chosen = [], used = [], hues = [];
function tooClose(rgb) { for (var u = 0; u < used.length; u++) if (dist(rgb, used[u]) < 1000) return true; return false; }
function take(bin, role) {
if (!bin || tooClose(bin.rgb)) return false;
chosen.push({ rgb: bin.rgb, hsl: bin.hsl, role: role });
used.push(bin.rgb);
if (bin.hsl[1] >= 0.2) hues.push(bin.hsl[0] * 360);
return true;
}
// Pass 1 -- distinct-hue coverage. Cap the hue slots at about a third of
// the palette so a large count still leaves room for tints and shades.
var vivid = bins.filter(function (x) { return x.hsl[1] >= 0.22 && x.hsl[2] >= 0.1 && x.hsl[2] <= 0.92; });
var lbias = function (l) { return 0.45 + 0.55 * (1 - Math.min(1, Math.abs(l - 0.5) * 2)); };
var fam = {}, totW = 0;
for (i = 0; i < vivid.length; i++) {
b = vivid[i];
var w = b.n * (0.35 + 0.65 * b.hsl[1]);
totW += w;
var fk = Math.floor(((b.hsl[0] * 360) % 360) / 30);
var f = fam[fk] || (fam[fk] = { w: 0, best: null, bw: -1 });
f.w += w;
var rw = w * lbias(b.hsl[2]); // prefer a mid-light rep, not near-black
if (rw > f.bw) { f.bw = rw; f.best = b; }
}
var fams = [];
for (var fkey in fam) if (fam[fkey].best) fams.push(fam[fkey]);
fams.sort(function (a, b) { return b.w - a.w; });
var FLOOR = 0.012 * totW, MAX_HUE = Math.max(3, Math.round(count / 2.5));
for (i = 0; i < fams.length && chosen.length < MAX_HUE; i++) {
var fh = fams[i].best.hsl[0] * 360;
var apart = hues.every(function (h) { return hueGap(h, fh) >= 45; });
if (fams[i].w >= FLOOR && apart) take(fams[i].best, 'hue');
}
// Pass 2 -- lightness/vibrancy range.
var T = [
{ tl: 0.82, ts: 0.5, lmin: 0.6, lmax: 1.0, smin: 0 },
{ tl: 0.20, ts: 0.5, lmin: 0.05, lmax: 0.4, smin: 0 },
{ tl: 0.55, ts: 1.0, lmin: 0.35, lmax: 0.7, smin: 0.3 },
{ tl: 0.50, ts: 0.15, lmin: 0.3, lmax: 0.7, smin: 0 }
];
for (var ti = 0; ti < T.length && chosen.length < count; ti++) {
var t = T[ti], best = null, bs = -Infinity;
for (i = 0; i < bins.length; i++) {
b = bins[i]; var s = b.hsl[1], l = b.hsl[2];
if (l < t.lmin || l > t.lmax || s < t.smin || tooClose(b.rgb)) continue;
var sc = 3 * (1 - Math.abs(s - t.ts)) + 6 * (1 - Math.abs(l - t.tl)) + 0.5 * (b.n / maxN);
if (sc > bs) { bs = sc; best = b; }
}
take(best, 'fill');
}
// Fill any remaining slots with the most-populous colours still distinct
// from what we have -- this is what lets an arbitrary count keep going.
if (chosen.length < count) {
var byPop = bins.slice().sort(function (a, b) { return b.n - a.n; });
for (i = 0; i < byPop.length && chosen.length < count; i++) take(byPop[i], 'pop');
}
// Share -- assign every bin (by population) to its nearest chosen swatch.
var acc = chosen.map(function () { return 0; });
for (i = 0; i < bins.length; i++) {
b = bins[i]; var bi = 0, bd = Infinity;
for (var j = 0; j < chosen.length; j++) { var dd = dist(b.rgb, chosen[j].rgb); if (dd < bd) { bd = dd; bi = j; } }
acc[bi] += b.n;
}
chosen.forEach(function (c, idx) { c.share = totN ? acc[idx] / totN : 0; });
return chosen.sort(function (a, b) { return b.hsl[2] - a.hsl[2]; });
}
/* ---- colour helpers -------------------------------------------------- */
function hex(c) { return '#' + c.map(function (n) {
return Math.max(0, Math.min(255, n | 0)).toString(16).padStart(2, '0'); }).join(''); }
function lum(c) {
var a = c.map(function (v) { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); });
return 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2];
}
function contrast(a, b) { var la = lum(a), lb = lum(b); return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); }
function sat(c) { var mx = Math.max.apply(null, c), mn = Math.min.apply(null, c); return mx === 0 ? 0 : (mx - mn) / mx; }
function mix(c, t, amt) { return c.map(function (v, i) { return Math.round(v + (t[i] - v) * amt); }); }
function hslToRgb(h, s, l) {
if (s === 0) { var v = Math.round(l * 255); return [v, v, v]; }
var q = l < 0.5 ? l * (1 + s) : l + s - l * s, p = 2 * l - q;
var f = function (t) {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
return [Math.round(f(h + 1 / 3) * 255), Math.round(f(h) * 255), Math.round(f(h - 1 / 3) * 255)];
}
/* ---- derive a usable theme from the palette -------------------------- */
function deriveTheme(palette) {
var byLum = palette.slice().sort(function (a, b) { return lum(a) - lum(b); });
var darkest = byLum[0], lightest = byLum[byLum.length - 1];
// Most colourful swatch becomes the accent; the next distinct hue is accent-2.
var bySat = palette.slice().sort(function (a, b) { return sat(b) - sat(a); });
var accent = bySat[0];
var accent2 = bySat.find(function (c) { return dist(c, accent) > 4000; }) || bySat[1] || accent;
var bgBase, textBase;
if (MODE === 'dark') { bgBase = mix(darkest, [0, 0, 0], 0.45); textBase = lightest; }
else { bgBase = mix(lightest, [255, 255, 255], 0.55); textBase = darkest; }
// Guarantee readable text: nudge to white/near-black if contrast is weak.
var text = contrast(textBase, bgBase) >= 4.5 ? textBase
: (MODE === 'dark' ? [248, 248, 242] : [24, 24, 20]);
var surface = MODE === 'dark' ? mix(bgBase, [255, 255, 255], 0.06)
: mix(bgBase, [0, 0, 0], 0.05);
var muted = mix(text, bgBase, 0.45);
return { bg: bgBase, surface: surface, text: text, muted: muted, accent: accent, accent2: accent2 };
}
/* ---- harmony: a colour-theory scheme anchored on the image --------------
The anchor is a real, prominent image colour (so the scheme still "feels"
like the photo), but the other hues are generated by rotation -- they are
NOT required to appear in the image. Each hue gets a ramp of lightnesses so
the palette has depth; `share` here is a SUGGESTED usage weight (lighter,
less-saturated colours carry more of a design -- the 60-30-10 idea) rather
than an image proportion. */
var HARMONY_OFFSETS = {
complementary: [0, 180],
triadic: [0, 120, 240],
analogous: [-30, 0, 30],
split: [0, 150, 210]
};
function pickAnchor(pal) {
var best = pal[0], bs = -1;
for (var i = 0; i < pal.length; i++) {
var s = pal[i].hsl[1], sh = pal[i].share || 0;
var score = s * (0.4 + 0.6 * sh); // prominent AND colourful
if (score > bs) { bs = score; best = pal[i]; }
}
return best;
}
function harmonyFrom(pal, scheme, count) {
if (!pal.length) return [];
var anchor = pickAnchor(pal);
var h0 = anchor.hsl[0] * 360;
var s0 = Math.min(0.85, Math.max(0.5, anchor.hsl[1] || 0.6));
var offs = HARMONY_OFFSETS[scheme] || HARMONY_OFFSETS.complementary;
var hues = offs.map(function (o) { return ((h0 + o) % 360 + 360) % 360; });
var k = Math.ceil(count / hues.length);
var Ls = [];
if (k === 1) Ls = [0.52];
else for (var i = 0; i < k; i++) Ls.push(0.30 + 0.50 * (i / (k - 1))); // dark -> light
var out = [];
for (var li = 0; li < k && out.length < count; li++) {
for (var hi = 0; hi < hues.length && out.length < count; hi++) {
var L = Ls[li];
var S = Math.min(0.95, Math.max(0.32, s0 * (L > 0.66 ? 0.8 : (L < 0.4 ? 0.9 : 1))));
var h = hues[hi] / 360;
out.push({ rgb: hslToRgb(h, S, L), hsl: [h, S, L], role: hi === 0 ? 'anchor' : 'harmony' });
}
}
out = out.slice(0, count);
var tot = 0;
out.forEach(function (c) { c._w = (1.15 - c.hsl[1]) * (0.55 + 0.45 * c.hsl[2]); tot += c._w; });
out.forEach(function (c) { c.share = tot ? c._w / tot : 1 / out.length; delete c._w; });
return out.sort(function (a, b) { return b.hsl[2] - a.hsl[2]; });
}
/* ---- render ---------------------------------------------------------- */
var ROLES = [['bg', 'Background'], ['surface', 'Surface'], ['text', 'Text'],
['muted', 'Muted'], ['accent', 'Accent'], ['accent2', 'Accent 2']];
function copy(text, onOk) {
if (navigator.clipboard && navigator.clipboard.writeText)
navigator.clipboard.writeText(text).then(onOk, function () {});
}
function render(palette, view) {
var results = document.getElementById('results');
var pal = document.getElementById('palette');
pal.innerHTML = '';
// Pills are sized by share -- how much of the image each colour speaks
// for, i.e. roughly how much of a design it should carry. sqrt-compressed
// so a dominant colour reads bigger without starving the rest, and each
// keeps a floor width so its hex stays readable. The % is shown under it.
palette.forEach(function (c) {
var h = hex(c.rgb);
var pct = Math.round((c.share || 0) * 100);
var b = document.createElement('button');
b.type = 'button';
b.style.background = h;
b.style.flex = (0.15 + Math.sqrt(c.share || 0)).toFixed(3) + ' 1 46px';
b.style.color = lum(c.rgb) > 0.4 ? '#000' : '#fff';
b.title = h + ' -- ' + pct + '% of the image';
b.innerHTML = '<span>' + h.slice(1).toUpperCase() + '</span>' +
'<span class="pp">' + pct + '%</span>';
b.addEventListener('click', function () {
copy(h, function () { b.classList.add('copied'); setTimeout(function () { b.classList.remove('copied'); }, 900); });
});
pal.appendChild(b);
});
var t = deriveTheme(palette.map(function (c) { return c.rgb; }));
// preview
var pv = document.getElementById('preview');
pv.style.background = hex(t.bg);
document.getElementById('pvBar').style.background = hex(t.surface);
document.getElementById('pvBar').style.color = hex(t.text);
document.getElementById('pvDot').style.background = hex(t.accent);
document.getElementById('pvBody').style.color = hex(t.text);
document.getElementById('pvH').style.color = hex(t.text);
document.getElementById('pvP').style.color = hex(t.muted);
var btn = document.getElementById('pvBtn');
btn.style.background = hex(t.accent);
btn.style.color = lum(t.accent) > 0.4 ? '#000' : '#fff';
// roles
var roles = document.getElementById('roles');
roles.innerHTML = '';
ROLES.forEach(function (r) {
var h = hex(t[r[0]]);
var el = document.createElement('button');
el.type = 'button'; el.className = 'role';
el.innerHTML = '<span class="sw" style="background:' + h + '"></span>' +
'<span><span class="k">' + r[1] + '</span><br><span class="v">' + h + '</span></span>';
el.addEventListener('click', function () { copy(h, function () {}); });
roles.appendChild(el);
});
// css export
var css = ':root {\n' + ROLES.map(function (r) {
var name = r[0] === 'accent2' ? 'accent-2' : r[0];
return ' --' + name + ': ' + hex(t[r[0]]) + ';';
}).join('\n') + '\n}';
document.getElementById('css').textContent = css;
var cc = document.getElementById('copycss');
cc.onclick = function () { copy(css, function () { cc.classList.add('copied'); cc.textContent = 'copied'; setTimeout(function () { cc.classList.remove('copied'); cc.textContent = 'copy'; }, 1200); }); };
results.classList.add('show');
var harmony = view === 'harmony';
var note = document.getElementById('palnote');
if (note) note.textContent = harmony
? 'Tap to copy. Pill width = suggested usage (' + SCHEME + ' harmony anchored on the image).'
: 'Tap a colour to copy. Pill width = share of the image.';
document.getElementById('say').textContent =
(harmony ? 'Harmony (' + SCHEME + '): ' : 'Palette from image: ') + palette.length + ' colours.';
}
/* ---- read the canvas and analyse ------------------------------------- */
function analyseCanvas() {
var w = cv.width, h = cv.height;
var data;
try { data = ctx.getImageData(0, 0, w, h).data; }
catch (e) { document.getElementById('say').textContent = 'Could not read image pixels.'; return; }
var px = [];
for (var i = 0; i < data.length; i += 4) {
if (data[i + 3] < 125) continue; // skip transparent
px.push([data[i], data[i + 1], data[i + 2]]);
}
if (!px.length) return;
var extracted = paletteOf(px, COUNT);
if (VIEW === 'harmony' && extracted.length) render(harmonyFrom(extracted, SCHEME, COUNT), 'harmony');
else render(extracted, 'image');
}
// Downscale into the analysis canvas -- small is plenty for a palette and
// keeps median cut fast.
function drawImageToCanvas(img) {
var max = 120;
var scale = Math.min(1, max / Math.max(img.width, img.height));
cv.width = Math.max(1, Math.round(img.width * scale));
cv.height = Math.max(1, Math.round(img.height * scale));
ctx.clearRect(0, 0, cv.width, cv.height);
ctx.drawImage(img, 0, 0, cv.width, cv.height);
}
function loadFile(file) {
if (!file || !/^image\//.test(file.type)) {
document.getElementById('say').textContent = 'Please choose an image file.';
return;
}
// Read as a data: URL, NOT createObjectURL. A blob: URL created in an
// opaque-origin sandbox is blob:null/... which the browser refuses to
// load, and the sandbox CSP only lists data: for images anyway. A data
// URL is same-origin, so it both loads and keeps the canvas readable.
var reader = new FileReader();
reader.onerror = function () {
document.getElementById('say').textContent = 'Could not read that file.';
};
reader.onload = function () {
var dataUrl = reader.result;
var img = new Image();
img.onload = function () {
drawImageToCanvas(img);
var thumb = document.getElementById('thumb');
thumb.src = dataUrl; thumb.classList.add('show');
analyseCanvas();
};
img.onerror = function () {
document.getElementById('say').textContent = 'That file would not decode as an image.';
};
img.src = dataUrl;
};
reader.readAsDataURL(file);
}
/* ---- inputs ---------------------------------------------------------- */
var drop = document.getElementById('drop'), fileIn = document.getElementById('file');
drop.addEventListener('click', function () { fileIn.click(); });
drop.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileIn.click(); } });
fileIn.addEventListener('change', function () { if (fileIn.files[0]) loadFile(fileIn.files[0]); });
['dragenter', 'dragover'].forEach(function (ev) {
drop.addEventListener(ev, function (e) { e.preventDefault(); drop.classList.add('over'); });
});
['dragleave', 'drop'].forEach(function (ev) {
drop.addEventListener(ev, function (e) { e.preventDefault(); drop.classList.remove('over'); });
});
drop.addEventListener('drop', function (e) {
var f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
if (f) loadFile(f);
});
/* ---- controls: mode toggle, scheme, colour count --------------------
Each re-runs analysis on whatever is already on the canvas -- no reload. */
function activate(seg, btn) {
for (var i = 0; i < seg.children.length; i++) seg.children[i].classList.toggle('on', seg.children[i] === btn);
}
var modeSeg = document.getElementById('mode');
var schemeSeg = document.getElementById('scheme');
var countIn = document.getElementById('count');
var countN = document.getElementById('countN');
countIn.value = COUNT; countN.textContent = COUNT; // honour cfg.count
modeSeg.addEventListener('click', function (e) {
var btn = e.target.closest ? e.target.closest('button') : null;
if (!btn || !modeSeg.contains(btn)) return;
VIEW = btn.getAttribute('data-mode');
activate(modeSeg, btn);
schemeSeg.hidden = VIEW !== 'harmony';
analyseCanvas();
});
schemeSeg.addEventListener('click', function (e) {
var btn = e.target.closest ? e.target.closest('button') : null;
if (!btn || !schemeSeg.contains(btn)) return;
SCHEME = btn.getAttribute('data-scheme');
activate(schemeSeg, btn);
analyseCanvas();
});
countIn.addEventListener('input', function () {
COUNT = +countIn.value; countN.textContent = COUNT; analyseCanvas();
});
/* ---- a generated sample so results show immediately ------------------ */
(function sample() {
cv.width = 120; cv.height = 90;
var g = ctx.createLinearGradient(0, 0, 120, 90);
g.addColorStop(0, '#1a1c4b'); g.addColorStop(0.55, '#7b2d8e'); g.addColorStop(1, '#ff7a3d');
ctx.fillStyle = g; ctx.fillRect(0, 0, 120, 90);
function blob(x, y, r, col) {
var rg = ctx.createRadialGradient(x, y, 0, x, y, r);
rg.addColorStop(0, col); rg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = rg; ctx.beginPath(); ctx.arc(x, y, r, 0, 6.2832); ctx.fill();
}
blob(30, 22, 26, '#ffd166'); blob(95, 30, 22, '#06d6a0'); blob(60, 72, 30, '#ef476f');
analyseCanvas();
})();
})();
</script>
</body>
</html>