← All gadgets

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 file

Every 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">&#x1F512; 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>