$sloprun.dev

Pixel Icon Painter

demo sandbox: allow-scripts · csp: default-src 'none'

sandboxed and isolated in your browser · never enter a real password in a demo

▶ 10 ran · ✓ 0 worked
i ran it — no login needed:
share: preview embed ↗
post it anywhere:
email the card:

0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously

A browser tab gives an icon a 16 × 16 grid and nothing more, so every pixel is a decision — two of them decide whether a face is smiling. This is a full editor for that square: paint, erase, flood fill and an eyedropper, a mirror toggle that draws the other half for you, sixty steps of undo, a palette you can push past with a colour picker, and a PNG export from 16 up to 256 px with smoothing switched off, all shown at actual size inside a pretend browser window. The legibility meter is the honest part — it counts what share of your drawing clears 3:1 contrast against a light tab strip and against a dark one, which is the thing most icons quietly fail: a near-black mark scores 100% on white, 0% on black, and vanishes for half your visitors. Try l–r under mirror and draw down one side, then switch to fill and drop a dark colour into the background — the dark-tab meter collapses while the icon in the tab mock-up changes on every stroke.

Embed this demo — put a runnable demo in your blog post

Paste this where you write. It renders a live, runnable demo inline anywhere raw HTML / iframes are allowed — Ghost, WordPress, Notion, Discourse, your own site.

<iframe src="https://sloprun.dev/embed/favicon-painter" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Pixel Icon Painter — a runnable demo on sloprun.dev"></iframe>
preview ↗

On Medium and dev.to the plain link becomes a rich preview card that links back here — they don't run third-party iframes, so paste the URL there and the reader clicks through to run it. https://sloprun.dev/p/favicon-painter

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1296 lines 51.8 KB index.html
demos/favicon-painter/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pixel Icon Painter — sloprun.dev</title>
<style>
/* sloprun design tokens — inline this block into every demo (self-contained rule).
   Identity: instrument-panel. Machine facts in mono; human words in sans.
   Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
  --bg: #F6F6F3;          /* neutral paper, not cream */
  --surface: #FFFFFF;
  --ink: #1A1D21;
  --muted: #5A6068;
  --line: #E3E2DC;
  --accent: #C05600;      /* phosphor amber, darkened for light bg */
  --accent-soft: #FFF3E6;
  --run: #1A7F37;         /* earned green */
  --run-soft: #E7F4EA;
  --danger: #C93C37;
  --radius: 10px;
  --font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
    --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
    --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
  }
}
:root[data-theme="dark"] {
  --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
  --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
  --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }

/* ---------- local tokens ---------- */
:root { --field: #FBFBF9; --shade: rgba(0,0,0,.05); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { --field: #171A20; --shade: rgba(0,0,0,.28); }
}
:root[data-theme="dark"] { --field: #171A20; --shade: rgba(0,0,0,.28); }

*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body { margin: 0; padding: 18px 16px 26px; line-height: 1.5; font-size: 15px; overflow-x: hidden; }
.wrap { max-width: 1140px; margin: 0 auto; }

:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* ---------- header ---------- */
header { margin-bottom: 16px; }
.kicker {
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .09em;
  text-transform: uppercase; color: var(--muted); margin: 0 0 6px;
}
h1 { font-size: clamp(21px, 4.8vw, 28px); line-height: 1.18; margin: 0 0 6px; letter-spacing: -.015em; }
.lede { margin: 0; color: var(--muted); max-width: 68ch; font-size: clamp(13.5px, 3.4vw, 15px); }

/* ---------- layout ---------- */
.grid { display: grid; gap: 14px; grid-template-columns: minmax(0,1fr); align-items: start; }
@media (min-width: 900px) { .grid { grid-template-columns: minmax(0,1.02fr) minmax(0,.98fr); } }
.col { display: grid; gap: 14px; align-content: start; }

.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px; }
.card > h2 {
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .09em;
  text-transform: uppercase; color: var(--muted); margin: 0 0 10px; font-weight: 600;
  display: flex; justify-content: space-between; gap: 10px; align-items: baseline;
}
.card > h2 .sub { font-weight: 400; text-transform: none; letter-spacing: .02em; }
.hint { font-size: 12.5px; color: var(--muted); margin: 10px 0 0; }
.row { display: flex; flex-wrap: wrap; gap: 7px; align-items: center; }
.spacer { flex: 1 1 auto; min-width: 0; }

/* ---------- buttons ---------- */
.btn {
  font: inherit; font-size: 13px; font-family: var(--font-sans);
  background: var(--surface); color: var(--ink);
  border: 1px solid var(--line); border-radius: 8px;
  padding: 7px 11px; cursor: pointer; line-height: 1.2;
  display: inline-flex; align-items: center; gap: 6px;
  transition: background .12s ease, border-color .12s ease, transform .08s ease;
}
.btn:hover:not(:disabled) { border-color: var(--accent); }
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn:disabled { opacity: .42; cursor: default; }
.btn.tiny { font-size: 12px; padding: 5px 9px; font-family: var(--font-mono); }
.btn[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); font-weight: 600; }
.btn.primary { background: var(--accent-soft); border-color: var(--accent); font-weight: 600; }
.btn .g { font-family: var(--font-mono); font-size: 13px; line-height: 1; }
@media (pointer: coarse) {
  .btn { padding: 10px 13px; }
  .btn.tiny { padding: 9px 11px; }
}

/* segmented control */
.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
.seg .btn { border: 0; border-radius: 0; border-left: 1px solid var(--line); padding: 7px 10px; }
.seg .btn:first-child { border-left: 0; }
.seg .btn[aria-pressed="true"] { background: var(--accent-soft); }
.seg .btn:focus-visible { outline-offset: -2px; }
.lbl {
  font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .08em;
  text-transform: uppercase; color: var(--muted);
}

/* ---------- artboard ---------- */
.artwrap { margin-top: 12px; display: flex; justify-content: center; }
#art {
  display: block; border: 1px solid var(--line); border-radius: 6px;
  background: var(--field); touch-action: none; cursor: crosshair;
  box-shadow: 0 1px 0 var(--shade);
  max-width: 100%;
}
#art:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
body.tool-pick #art { cursor: cell; }

/* readout chips */
.readout { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
.chip {
  font-family: var(--font-mono); font-size: 11.5px; border: 1px solid var(--line);
  border-radius: 7px; padding: 4px 8px; background: var(--field); color: var(--ink);
  display: inline-flex; gap: 6px; align-items: baseline; white-space: nowrap;
}
.chip b { color: var(--muted); font-weight: 600; font-size: 10px; letter-spacing: .07em; text-transform: uppercase; }

/* ---------- palette ---------- */
.swatches { display: flex; flex-wrap: wrap; gap: 6px; }
#palette {
  display: grid; grid-template-columns: repeat(9, minmax(0, 1fr));
  gap: 6px; max-width: 342px;
}
#palette .sw { width: 100%; height: auto; aspect-ratio: 1; }
@media (max-width: 560px) { #palette { grid-template-columns: repeat(6, minmax(0, 1fr)); max-width: none; } }
.sw {
  width: 30px; height: 30px; padding: 0; border-radius: 7px; cursor: pointer;
  border: 1px solid var(--line); background: var(--c, #000);
  box-shadow: inset 0 0 0 1px rgba(128,128,128,.22);
  transition: transform .1s ease, box-shadow .1s ease;
}
.sw:hover { transform: translateY(-1px); }
.sw[aria-checked="true"], .sw.on {
  box-shadow: inset 0 0 0 1px rgba(128,128,128,.22), 0 0 0 2px var(--surface), 0 0 0 4px var(--accent);
  border-color: transparent;
}
.sw.trans {
  background-image:
    linear-gradient(45deg, var(--line) 25%, transparent 25%),
    linear-gradient(-45deg, var(--line) 25%, transparent 25%),
    linear-gradient(45deg, transparent 75%, var(--line) 75%),
    linear-gradient(-45deg, transparent 75%, var(--line) 75%);
  background-size: 12px 12px;
  background-position: 0 0, 0 6px, 6px -6px, -6px 0;
  background-color: var(--surface);
}
@media (pointer: coarse) { .sw { width: 36px; height: 36px; } }

.current { display: flex; align-items: center; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
.bigsw {
  width: 42px; height: 42px; border-radius: 9px; border: 1px solid var(--line);
  background: var(--c, #000); flex: 0 0 auto;
}
.bigsw.trans {
  background-image:
    linear-gradient(45deg, var(--line) 25%, transparent 25%),
    linear-gradient(-45deg, var(--line) 25%, transparent 25%),
    linear-gradient(45deg, transparent 75%, var(--line) 75%),
    linear-gradient(-45deg, transparent 75%, var(--line) 75%);
  background-size: 14px 14px; background-position: 0 0, 0 7px, 7px -7px, -7px 0;
  background-color: var(--surface);
}
input[type="color"] {
  -webkit-appearance: none; appearance: none; width: 42px; height: 34px; padding: 0;
  border: 1px solid var(--line); border-radius: 8px; background: var(--surface); cursor: pointer;
}
input[type="color"]::-webkit-color-swatch-wrapper { padding: 3px; }
input[type="color"]::-webkit-color-swatch { border: 0; border-radius: 5px; }
input[type="color"]::-moz-color-swatch { border: 0; border-radius: 5px; }

/* ---------- previews ---------- */
.pvstrip {
  display: flex; gap: 16px; align-items: flex-end; flex-wrap: wrap;
  padding: 16px 14px 12px; border: 1px solid var(--line); border-radius: 8px;
}
.pvstrip.bg-checker {
  background-image:
    linear-gradient(45deg, var(--line) 25%, transparent 25%),
    linear-gradient(-45deg, var(--line) 25%, transparent 25%),
    linear-gradient(45deg, transparent 75%, var(--line) 75%),
    linear-gradient(-45deg, transparent 75%, var(--line) 75%);
  background-size: 16px 16px; background-position: 0 0, 0 8px, 8px -8px, -8px 0;
  background-color: var(--field);
}
.pvstrip.bg-light { background: #FFFFFF; }
.pvstrip.bg-dark { background: #14161A; }
.pv { display: grid; gap: 7px; justify-items: center; }
.pv canvas { display: block; image-rendering: pixelated; }
.pv .cap {
  font-family: var(--font-mono); font-size: 10px; letter-spacing: .07em; color: var(--muted);
  background: var(--surface); border: 1px solid var(--line); border-radius: 5px; padding: 1px 5px;
}

/* ---------- browser mock ---------- */
.browser {
  margin-top: 14px; border: 1px solid var(--line); border-radius: 10px; overflow: hidden;
  background: var(--field);
}
.bchrome { display: flex; align-items: flex-end; gap: 8px; padding: 8px 8px 0; }
.dots { display: flex; gap: 5px; padding: 0 2px 9px; flex: 0 0 auto; }
.dots i { width: 9px; height: 9px; border-radius: 50%; background: var(--line); display: block; }
.tabs { display: flex; gap: 4px; align-items: flex-end; min-width: 0; flex: 1 1 auto; }
.tab {
  display: flex; align-items: center; gap: 7px; min-width: 0;
  padding: 7px 10px; border-radius: 8px 8px 0 0; font-size: 12.5px;
  border: 1px solid transparent; border-bottom: 0; color: var(--muted);
}
.tab .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tab.active { background: var(--surface); border-color: var(--line); color: var(--ink); }
.tab canvas { display: block; image-rendering: pixelated; flex: 0 0 auto; }
.ghosticon { width: 16px; height: 16px; border-radius: 3px; background: var(--line); flex: 0 0 auto; }
.burl { display: flex; align-items: center; gap: 8px; padding: 8px 10px; background: var(--surface); border-top: 1px solid var(--line); }
.urlbar {
  flex: 1 1 auto; min-width: 0; background: var(--field); border: 1px solid var(--line);
  border-radius: 999px; padding: 5px 12px; font-family: var(--font-mono); font-size: 11.5px;
  color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.bmarks { display: flex; gap: 12px; padding: 8px 12px 10px; background: var(--surface); flex-wrap: wrap; }
.bm { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); min-width: 0; }
.bm canvas { display: block; image-rendering: pixelated; }

/* ---------- legibility ---------- */
.legi { display: grid; gap: 13px; }
.lrow { display: flex; gap: 12px; align-items: center; }
.lchip {
  width: 42px; height: 42px; border-radius: 9px; border: 1px solid var(--line);
  display: grid; place-items: center; flex: 0 0 auto;
}
.lchip.on-light { background: #FFFFFF; }
.lchip.on-dark { background: #14161A; }
.lchip canvas { display: block; image-rendering: pixelated; }
.lbody { flex: 1 1 auto; min-width: 0; }
.lhead { display: flex; justify-content: space-between; gap: 10px; align-items: baseline; }
.lval { font-family: var(--font-mono); font-size: 13px; }
.meter {
  height: 7px; border-radius: 999px; background: var(--field);
  border: 1px solid var(--line); overflow: hidden; margin: 6px 0 5px;
}
.meter i { display: block; height: 100%; width: 0; background: var(--muted); transition: width .18s ease; }
.meter i.ok { background: var(--run); }
.meter i.warn { background: var(--accent); }
.meter i.bad { background: var(--danger); }
.verdict { font-family: var(--font-mono); font-size: 11px; letter-spacing: .06em; color: var(--muted); }
.verdict.ok { color: var(--run); }
.verdict.bad { color: var(--danger); }

/* ---------- export ---------- */
.status {
  margin-top: 11px; font-family: var(--font-mono); font-size: 12px; color: var(--muted);
  border: 1px dashed var(--line); border-radius: 7px; padding: 7px 9px; word-break: break-word;
}
.status.ok { color: var(--run); border-style: solid; border-color: var(--run); background: var(--run-soft); }
.status.bad { color: var(--danger); border-style: solid; border-color: var(--danger); }
.durl { margin-top: 9px; display: grid; gap: 7px; }
.durl[hidden] { display: none; }
.durl textarea {
  width: 100%; height: 68px; resize: vertical; font-family: var(--font-mono); font-size: 11px;
  color: var(--ink); background: var(--field); border: 1px solid var(--line);
  border-radius: 7px; padding: 7px 8px; line-height: 1.45;
}
kbd {
  font-family: var(--font-mono); font-size: 11.5px; padding: 1px 5px;
  border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 5px;
  background: var(--field); color: var(--ink); white-space: nowrap;
}

footer {
  margin-top: 20px; padding-top: 12px; border-top: 1px solid var(--line);
  font-family: var(--font-mono); font-size: 11px; color: var(--muted);
  display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap;
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { transition: none !important; animation: none !important; scroll-behavior: auto !important; }
}
</style>
</head>
<body>
<div class="wrap">

  <header>
    <p class="kicker">16 × 16 · canvas · png</p>
    <h1>Pixel Icon Painter</h1>
    <p class="lede">Draw the little picture that sits in a browser tab, one pixel at a time. A smiley is
      already loaded — paint over it, and watch the 16-pixel copy land in a tab mock-up on the right.</p>
  </header>

  <div class="grid">

    <!-- ============ left column ============ -->
    <div class="col">

      <section class="card">
        <h2>artboard <span class="sub" id="dims">16 × 16</span></h2>

        <div class="row" role="group" aria-label="Tools">
          <div class="seg" id="tools">
            <button class="btn" type="button" data-tool="pen" aria-pressed="true" title="Paint (B)">Paint</button>
            <button class="btn" type="button" data-tool="eraser" aria-pressed="false" title="Erase (E)">Erase</button>
            <button class="btn" type="button" data-tool="fill" aria-pressed="false" title="Fill (G)">Fill</button>
            <button class="btn" type="button" data-tool="pick" aria-pressed="false" title="Pick a color (I)">Pick</button>
          </div>
          <span class="spacer"></span>
          <div class="seg">
            <button class="btn tiny" type="button" id="undo" title="Undo (Ctrl+Z)" disabled>undo</button>
            <button class="btn tiny" type="button" id="redo" title="Redo (Ctrl+Y)" disabled>redo</button>
          </div>
        </div>

        <div class="row" style="margin-top:9px">
          <span class="lbl" id="symlbl">mirror</span>
          <div class="seg" id="sym" role="group" aria-labelledby="symlbl">
            <button class="btn tiny" type="button" data-sym="0" aria-pressed="true">off</button>
            <button class="btn tiny" type="button" data-sym="1" aria-pressed="false" aria-label="Mirror left to right" title="Mirror left to right">l–r</button>
            <button class="btn tiny" type="button" data-sym="2" aria-pressed="false" aria-label="Mirror top to bottom" title="Mirror top to bottom">u–d</button>
            <button class="btn tiny" type="button" data-sym="3" aria-pressed="false" aria-label="Mirror both ways" title="Mirror both ways">both</button>
          </div>
        </div>

        <div class="artwrap">
          <canvas id="art" width="416" height="416" tabindex="0" role="application"
                  aria-label="16 by 16 pixel artboard. Arrow keys move the cursor, Enter paints."></canvas>
        </div>

        <div class="readout">
          <span class="chip"><b>cursor</b><span id="rCur">—</span></span>
          <span class="chip"><b>filled</b><span id="rFill">0/256</span></span>
          <span class="chip"><b>colors</b><span id="rCols">0</span></span>
          <span class="chip"><b>tool</b><span id="rTool">paint</span></span>
          <span class="chip"><b>mirror</b><span id="rSym">off</span></span>
        </div>

        <p class="hint">Drag to draw. <kbd>B</kbd> paint · <kbd>E</kbd> erase · <kbd>G</kbd> fill ·
          <kbd>I</kbd> pick · <kbd>S</kbd> mirror · <kbd>Ctrl</kbd>+<kbd>Z</kbd> undo.
          Click the artboard first, then arrow keys move and <kbd>Enter</kbd> paints.</p>

        <div class="row" style="margin-top:12px">
          <span class="lbl">start from</span>
          <button class="btn tiny" type="button" data-preset="smiley">smiley</button>
          <button class="btn tiny" type="button" data-preset="heart">heart</button>
          <button class="btn tiny" type="button" data-preset="ghost">ghost</button>
          <button class="btn tiny" type="button" id="clear">empty</button>
        </div>
      </section>

      <section class="card">
        <h2>palette <span class="sub" id="curHex">#C05600</span></h2>
        <div class="swatches" id="palette" role="radiogroup" aria-label="Palette colors"></div>

        <div class="current">
          <span class="bigsw" id="bigsw" style="--c:#C05600" aria-hidden="true"></span>
          <div>
            <div class="lbl">painting with</div>
            <div style="font-family:var(--font-mono);font-size:13px" id="curName">amber</div>
          </div>
          <span class="spacer"></span>
          <label class="lbl" for="custom">custom</label>
          <input type="color" id="custom" value="#C05600" aria-label="Pick a custom color">
        </div>

        <div style="margin-top:12px">
          <div class="lbl" style="margin-bottom:6px">recent</div>
          <div class="swatches" id="recent" role="group" aria-label="Recently used colors">
            <span class="lbl" id="noRecent">nothing yet — pick a custom color</span>
          </div>
        </div>
      </section>

    </div>

    <!-- ============ right column ============ -->
    <div class="col">

      <section class="card">
        <h2>preview <span class="sub">actual size</span></h2>
        <div class="row">
          <span class="lbl">behind it</span>
          <div class="seg" id="bgsel" role="group" aria-label="Preview background">
            <button class="btn tiny" type="button" data-bg="checker" aria-pressed="true">checker</button>
            <button class="btn tiny" type="button" data-bg="light" aria-pressed="false">light</button>
            <button class="btn tiny" type="button" data-bg="dark" aria-pressed="false">dark</button>
          </div>
        </div>

        <div class="pvstrip bg-checker" id="pvstrip">
          <div class="pv"><canvas class="pvc" data-size="16"></canvas><span class="cap">16 px</span></div>
          <div class="pv"><canvas class="pvc" data-size="32"></canvas><span class="cap">32 px</span></div>
          <div class="pv"><canvas class="pvc" data-size="64"></canvas><span class="cap">64 px</span></div>
        </div>

        <p class="hint">A tab favicon is 16 pixels across — the same size as the leftmost square. That is the
          whole reason pixel icons look the way they do.</p>

        <div class="browser" aria-hidden="true">
          <div class="bchrome">
            <span class="dots"><i></i><i></i><i></i></span>
            <div class="tabs">
              <div class="tab active"><canvas class="tabicon" data-size="16"></canvas><span class="t">Pixel Icon Painter</span></div>
              <div class="tab"><span class="ghosticon"></span><span class="t">Notes</span></div>
            </div>
          </div>
          <div class="burl"><div class="urlbar">sloprun.dev/favicon-painter</div></div>
          <div class="bmarks">
            <span class="bm"><canvas class="tabicon" data-size="16"></canvas>My icon</span>
            <span class="bm"><span class="ghosticon"></span>Recipes</span>
            <span class="bm"><span class="ghosticon"></span>Weather</span>
          </div>
        </div>
        <p class="hint">A pretend browser window, drawn on this page — the icon in the tab is your artboard, live.</p>
      </section>

      <section class="card">
        <h2>legibility <span class="sub">at tab size</span></h2>
        <p class="hint" style="margin:0 0 12px">A tab strip is either near-white or near-black, and you get no say
          in which. This counts the share of your icon that stands clear of each one — 3:1 contrast or better.</p>
        <div class="legi">
          <div class="lrow">
            <span class="lchip on-light"><canvas class="tabicon" data-size="16"></canvas></span>
            <div class="lbody">
              <div class="lhead"><span class="lbl">light tab strip</span><span class="lval" id="pctL">—</span></div>
              <div class="meter"><i id="barL"></i></div>
              <div class="verdict" id="wordL">nothing drawn yet</div>
            </div>
          </div>
          <div class="lrow">
            <span class="lchip on-dark"><canvas class="tabicon" data-size="16"></canvas></span>
            <div class="lbody">
              <div class="lhead"><span class="lbl">dark tab strip</span><span class="lval" id="pctD">—</span></div>
              <div class="meter"><i id="barD"></i></div>
              <div class="verdict" id="wordD">nothing drawn yet</div>
            </div>
          </div>
        </div>
      </section>

      <section class="card">
        <h2>export <span class="sub">png, no upload</span></h2>
        <div class="row">
          <span class="lbl">save at</span>
          <button class="btn tiny" type="button" data-png="16">16 px</button>
          <button class="btn tiny primary" type="button" data-png="32">32 px</button>
          <button class="btn tiny" type="button" data-png="64">64 px</button>
          <button class="btn tiny" type="button" data-png="256">256 px</button>
        </div>
        <p class="status" id="status">Nothing saved yet. Every size is drawn from the same 16 × 16 grid with
          smoothing switched off, so the edges stay hard.</p>
        <div class="durl" id="durl" hidden>
          <div class="row">
            <span class="lbl">or copy the data url</span>
            <span class="spacer"></span>
            <button class="btn tiny" type="button" id="copy">copy</button>
          </div>
          <textarea id="durltext" readonly spellcheck="false" aria-label="PNG data URL"></textarea>
          <p class="hint" style="margin:0">Paste this straight into an <code style="font-family:var(--font-mono)">&lt;img src&gt;</code>
            or a <code style="font-family:var(--font-mono)">&lt;link rel="icon"&gt;</code> — no file needed.</p>
        </div>
      </section>

    </div>
  </div>

  <footer>
    <span>demo · sloprun.dev</span>
    <span id="engine">16×16 · 60 undo steps · canvas → png</span>
  </footer>
</div>

<p class="sr-only" id="live" role="status" aria-live="polite"></p>

<script>
(function () {
  "use strict";

  /* ---------- platform theme bridge ---------- */
  addEventListener("message", function (e) {
    var d = e && e.data;
    if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
      document.documentElement.setAttribute("data-theme", d.theme);
      render();
    }
  });
  if (window.matchMedia) {
    var mq = window.matchMedia("(prefers-color-scheme: dark)");
    var onScheme = function () { render(); };
    if (mq.addEventListener) mq.addEventListener("change", onScheme);
    else if (mq.addListener) mq.addListener(onScheme);
  }
  // The artboard bakes theme colours into a canvas, so it has to be repainted whenever
  // the theme changes — including when data-theme is set on <html> directly.
  if (window.MutationObserver) {
    try {
      new MutationObserver(function () { render(); })
        .observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
    } catch (e) { /* the postMessage path above still covers the platform */ }
  }

  var $ = function (id) { return document.getElementById(id); };

  // Are we inside a frame? Decides two honest details: whether we may set the real
  // tab icon, and whether we may claim a download actually landed.
  var framed = true;
  try { framed = window.top !== window.self; } catch (e) { framed = true; }

  /* ---------- model ---------- */
  var W = 16, N = W * W, HIST_MAX = 60;
  var px = new Int32Array(N);           // -1 = transparent, else 0xRRGGBB
  var hist = [], hi = -1;
  var tool = "pen", sym = 0;
  var color = 0xC05600, colorName = "amber", transparentSel = false;
  var curX = 8, curY = 8, hoverOn = false, focusOn = false;
  var recents = [];

  function hexToInt(h) { return parseInt(h.slice(1), 16); }
  function intToHex(v) { return "#" + ("000000" + (v >>> 0).toString(16)).slice(-6).toUpperCase(); }

  /* ---------- palette ---------- */
  var PALETTE = [
    ["#1A1D21", "ink"],       ["#5A6068", "slate"],     ["#9AA0A8", "ash"],       ["#E3E2DC", "chalk"],
    ["#F6F6F3", "paper"],     ["#FFFFFF", "white"],     ["#C05600", "amber"],     ["#FFB454", "honey"],
    ["#FFF3E6", "cream"],     ["#1A7F37", "moss"],      ["#3FB950", "lime"],      ["#C93C37", "brick"],
    ["#F47067", "coral"],     ["#2E6FD9", "sky"],       ["#7A5AF8", "violet"],    ["#0F8B8D", "teal"],
    ["#8C5A2B", "cocoa"]
  ];
  var NAME_OF = {};
  PALETTE.forEach(function (p) { NAME_OF[p[0].toUpperCase()] = p[1]; });

  /* ---------- starter drawings ---------- */
  var PRESETS = {
    smiley: {
      map: { o: "#C05600", y: "#FFB454", k: "#1A1D21" },
      rows: [
        "................",
        ".....oooooo.....",
        "...ooyyyyyyoo...",
        "..ooyyyyyyyyoo..",
        "..oyyyyyyyyyyo..",
        ".oyyykkyykkyyyo.",
        ".oyyykkyykkyyyo.",
        ".oyyyyyyyyyyyyo.",
        ".oyyyyyyyyyyyyo.",
        ".oyykyyyyyykyyo.",
        ".oyyykyyyykyyyo.",
        "..oyyykkkkyyyo..",
        "..ooyyyyyyyyoo..",
        "...ooyyyyyyoo...",
        ".....oooooo.....",
        "................"
      ]
    },
    heart: {
      map: { r: "#C93C37", p: "#F47067" },
      rows: [
        "................",
        "...rrr....rrr...",
        "..rpprr..rrrrr..",
        ".rrprrrrrrrrrrr.",
        ".rrrrrrrrrrrrrr.",
        ".rrrrrrrrrrrrrr.",
        ".rrrrrrrrrrrrrr.",
        "..rrrrrrrrrrrr..",
        "..rrrrrrrrrrrr..",
        "...rrrrrrrrrr...",
        "....rrrrrrrr....",
        ".....rrrrrr.....",
        "......rrrr......",
        ".......rr.......",
        "................",
        "................"
      ]
    },
    ghost: {
      map: { k: "#1A1D21", w: "#F6F6F3", b: "#2E6FD9" },
      rows: [
        "................",
        ".....kkkkkk.....",
        "...kkwwwwwwkk...",
        "..kwwwwwwwwwwk..",
        "..kwwwwwwwwwwk..",
        ".kwwbbwwwwbbwwk.",
        ".kwwbbwwwwbbwwk.",
        ".kwwwwwwwwwwwwk.",
        ".kwwwwwwwwwwwwk.",
        ".kwwwwkkkkwwwwk.",
        ".kwwwwkkkkwwwwk.",
        ".kwwwwwwwwwwwwk.",
        ".kwwwwwwwwwwwwk.",
        ".kwwwkwwwwkwwwk.",
        ".kwwk.kwwk.kwwk.",
        "................"
      ]
    }
  };

  function loadPreset(name) {
    var p = PRESETS[name];
    if (!p) return;
    for (var y = 0; y < W; y++) {
      var row = p.rows[y] || "";
      for (var x = 0; x < W; x++) {
        var c = row.charAt(x);
        px[y * W + x] = (!c || c === ".") ? -1 : hexToInt(p.map[c]);
      }
    }
  }

  /* ---------- canvases ---------- */
  var art = $("art"), ctx = art.getContext("2d");
  var base = document.createElement("canvas");
  base.width = W; base.height = W;
  var bctx = base.getContext("2d");
  var imgData = bctx.createImageData(W, W);

  function updateBase() {
    var d = imgData.data;
    for (var i = 0; i < N; i++) {
      var v = px[i], o = i * 4;
      if (v < 0) { d[o] = d[o + 1] = d[o + 2] = d[o + 3] = 0; }
      else {
        d[o] = (v >> 16) & 255; d[o + 1] = (v >> 8) & 255; d[o + 2] = v & 255; d[o + 3] = 255;
      }
    }
    bctx.putImageData(imgData, 0, 0);
  }

  function cssVar(name) {
    return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || "#888";
  }

  function render() {
    var dpr = Math.min(window.devicePixelRatio || 1, 3);
    var host = art.parentNode;
    var avail = host ? host.clientWidth : 0;
    if (!avail) avail = 320;
    avail = Math.min(avail, 448);
    var cell = Math.max(8, Math.floor((avail * dpr) / W));
    var back = cell * W;
    if (art.width !== back) { art.width = back; art.height = back; }
    art.style.width = (back / dpr) + "px";
    art.style.height = (back / dpr) + "px";

    var chkA = cssVar("--surface"), chkB = cssVar("--line");
    var accent = cssVar("--accent");

    ctx.clearRect(0, 0, back, back);
    for (var y = 0; y < W; y++) {
      for (var x = 0; x < W; x++) {
        var v = px[y * W + x];
        ctx.fillStyle = (v < 0) ? (((x + y) & 1) ? chkB : chkA) : intToHex(v);
        ctx.fillRect(x * cell, y * cell, cell, cell);
      }
    }

    if (cell >= 11) {
      ctx.fillStyle = "rgba(128,128,128,.26)";
      for (var g = 1; g < W; g++) {
        ctx.fillRect(g * cell, 0, 1, back);
        ctx.fillRect(0, g * cell, back, 1);
      }
      ctx.fillStyle = "rgba(128,128,128,.5)";
      ctx.fillRect((W / 2) * cell, 0, 1, back);
      ctx.fillRect(0, (W / 2) * cell, back, 1);
    }

    if (sym) {
      ctx.save();
      ctx.globalAlpha = .6;
      ctx.fillStyle = accent;
      if (sym === 1 || sym === 3) ctx.fillRect((W / 2) * cell - 1, 0, 2, back);
      if (sym === 2 || sym === 3) ctx.fillRect(0, (W / 2) * cell - 1, back, 2);
      ctx.restore();
    }

    if (hoverOn || focusOn) {
      var mir = mirrorsOf(curX, curY);
      for (var m = 1; m < mir.length; m++) ghostRect(mir[m][0], mir[m][1], cell);
      cursorRect(curX, curY, cell);
    }

    updateBase();
    drawPreviews();
  }

  function cursorRect(x, y, cell) {
    var lw = Math.max(2, Math.round(cell * .10));
    ctx.lineWidth = lw;
    ctx.strokeStyle = "rgba(0,0,0,.85)";
    ctx.strokeRect(x * cell + lw / 2, y * cell + lw / 2, cell - lw, cell - lw);
    ctx.strokeStyle = "rgba(255,255,255,.95)";
    ctx.strokeRect(x * cell + lw * 1.5, y * cell + lw * 1.5, cell - lw * 3, cell - lw * 3);
  }
  function ghostRect(x, y, cell) {
    ctx.save();
    ctx.globalAlpha = .75;
    ctx.lineWidth = Math.max(1, Math.round(cell * .07));
    ctx.strokeStyle = cssVar("--accent");
    ctx.strokeRect(x * cell + 2, y * cell + 2, cell - 4, cell - 4);
    ctx.restore();
  }

  /* ---------- previews ---------- */
  var pvcs = [].slice.call(document.querySelectorAll(".pvc, .tabicon"));
  function drawPreviews() {
    var k = Math.max(1, Math.min(3, Math.round(window.devicePixelRatio || 1)));
    pvcs.forEach(function (c) {
      var s = parseInt(c.getAttribute("data-size"), 10) || 16;
      var b = s * k;
      if (c.width !== b) { c.width = b; c.height = b; }
      c.style.width = s + "px"; c.style.height = s + "px";
      var g = c.getContext("2d");
      g.imageSmoothingEnabled = false;
      g.clearRect(0, 0, b, b);
      g.drawImage(base, 0, 0, b, b);
    });
    syncFavicon();
  }

  var favTimer = null;
  function syncFavicon() {
    // Standalone only: inside the platform iframe the tab icon isn't ours to set.
    if (framed) return;
    if (favTimer) { clearTimeout(favTimer); favTimer = null; }
    favTimer = setTimeout(function () {
      favTimer = null;
      try {
        var link = document.getElementById("favlink");
        if (!link) {
          link = document.createElement("link");
          link.id = "favlink"; link.rel = "icon"; link.type = "image/png";
          document.head.appendChild(link);
        }
        link.href = base.toDataURL("image/png");
      } catch (err) { /* the tab icon is a bonus, never a requirement */ }
    }, 220);
  }

  /* ---------- history ---------- */
  // Nothing changed => no history entry. Otherwise clicking the same preset twice
  // would stack identical states and undo would look broken.
  function sameAsHead() {
    var h = hist[hi];
    if (hi < 0 || !h) return false;
    for (var i = 0; i < N; i++) if (h[i] !== px[i]) return false;
    return true;
  }
  function commit() {
    if (sameAsHead()) return;
    hist = hist.slice(0, hi + 1);
    hist.push(px.slice(0));
    if (hist.length > HIST_MAX) hist.shift();
    hi = hist.length - 1;
    syncHistBtns();
  }
  function syncHistBtns() {
    $("undo").disabled = hi <= 0;
    $("redo").disabled = hi >= hist.length - 1;
  }
  function undo() {
    if (hi <= 0) return;
    hi--; px.set(hist[hi]); syncHistBtns(); render(); stats();
    say("undone · step " + (hi + 1) + " of " + hist.length);
  }
  function redo() {
    if (hi >= hist.length - 1) return;
    hi++; px.set(hist[hi]); syncHistBtns(); render(); stats();
    say("redone · step " + (hi + 1) + " of " + hist.length);
  }

  /* ---------- painting ---------- */
  function mirrorsOf(x, y) {
    var t = [[x, y]];
    if (sym === 1 || sym === 3) t.push([W - 1 - x, y]);
    if (sym === 2 || sym === 3) t.push([x, W - 1 - y]);
    if (sym === 3) t.push([W - 1 - x, W - 1 - y]);
    return t;
  }
  var dirty = false;
  function put(x, y, v) {
    var t = mirrorsOf(x, y);
    for (var i = 0; i < t.length; i++) {
      var idx = t[i][1] * W + t[i][0];
      if (px[idx] !== v) { px[idx] = v; dirty = true; }
    }
  }
  function floodFill(x, y, v) {
    var target = px[y * W + x];
    // Same colour and no mirror => nothing can change. With a mirror on, the
    // reflected cells may still differ, so the scan is still worth running.
    if (target === v && !sym) return 0;
    var seen = new Uint8Array(N), stack = [y * W + x], cells = [];
    while (stack.length) {
      var i = stack.pop();
      if (seen[i]) continue;
      seen[i] = 1;
      if (px[i] !== target) continue;
      cells.push(i);
      var cx = i % W, cy = (i - cx) / W;
      if (cx > 0) stack.push(i - 1);
      if (cx < W - 1) stack.push(i + 1);
      if (cy > 0) stack.push(i - W);
      if (cy < W - 1) stack.push(i + W);
    }
    for (var c = 0; c < cells.length; c++) put(cells[c] % W, (cells[c] - cells[c] % W) / W, v);
    return cells.length;
  }

  function applyTool(x, y, isStroke) {
    if (tool === "pen") put(x, y, transparentSel ? -1 : color);
    else if (tool === "eraser") put(x, y, -1);
    else if (tool === "fill") {
      dirty = false;
      var n = floodFill(x, y, transparentSel ? -1 : color);
      say(dirty ? ("filled " + n + " pixel" + (n === 1 ? "" : "s"))
                : "that area is already this colour");
    } else if (tool === "pick") {
      var v = px[y * W + x];
      if (v < 0) { selectTransparent(); say("picked transparent"); }
      else { selectColor(intToHex(v)); pushRecent(intToHex(v)); say("picked " + intToHex(v)); }
      setTool("pen");
      return;
    }
    if (!isStroke && dirty) { commit(); dirty = false; }
    render(); stats();
  }

  /* line between two cells so a fast drag leaves no gaps */
  function strokeTo(x0, y0, x1, y1) {
    var dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
    var dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
    var err = dx + dy;
    for (;;) {
      if (tool === "pen") put(x0, y0, transparentSel ? -1 : color);
      else if (tool === "eraser") put(x0, y0, -1);
      if (x0 === x1 && y0 === y1) break;
      var e2 = 2 * err;
      if (e2 >= dy) { err += dy; x0 += sx; }
      if (e2 <= dx) { err += dx; y0 += sy; }
    }
  }

  /* ---------- pointer ---------- */
  var drawing = false, lastX = 0, lastY = 0;
  function cellFrom(ev) {
    var r = art.getBoundingClientRect();
    if (!r.width) return null;
    var x = Math.floor(((ev.clientX - r.left) / r.width) * W);
    var y = Math.floor(((ev.clientY - r.top) / r.height) * W);
    if (x < 0 || y < 0 || x >= W || y >= W) return null;
    return [x, y];
  }
  art.addEventListener("pointerdown", function (ev) {
    var c = cellFrom(ev);
    if (!c) return;
    ev.preventDefault();
    art.focus();
    curX = c[0]; curY = c[1]; hoverOn = true;
    if (tool === "fill" || tool === "pick") { applyTool(curX, curY, false); return; }
    drawing = true; dirty = false;
    lastX = curX; lastY = curY;
    if (art.setPointerCapture) { try { art.setPointerCapture(ev.pointerId); } catch (e) {} }
    applyTool(curX, curY, true);
  });
  art.addEventListener("pointermove", function (ev) {
    var c = cellFrom(ev);
    if (!c) { if (!drawing && hoverOn) { hoverOn = false; render(); } return; }
    var moved = (c[0] !== curX || c[1] !== curY);
    hoverOn = true;
    curX = c[0]; curY = c[1];
    if (drawing) {
      if (moved) { strokeTo(lastX, lastY, curX, curY); lastX = curX; lastY = curY; }
      render(); stats();
    } else if (moved) { render(); stats(); }
  });
  function endStroke(ev) {
    var wasDrawing = drawing;
    // Touch and pen have no hover: when the finger lifts, the cell outline and the
    // cursor readout would otherwise stay stuck on the last cell touched.
    var touchy = !!(ev && ev.pointerType && ev.pointerType !== "mouse");
    var dropHover = touchy && hoverOn;
    drawing = false;
    if (wasDrawing && dirty) { commit(); dirty = false; }
    if (dropHover) hoverOn = false;
    if (wasDrawing || dropHover) { render(); stats(); }
  }
  art.addEventListener("pointerup", endStroke);
  art.addEventListener("pointercancel", endStroke);
  addEventListener("pointerup", endStroke);
  art.addEventListener("pointerleave", function () {
    if (!drawing) { hoverOn = false; render(); stats(); }
  });
  art.addEventListener("focus", function () { focusOn = true; render(); });
  art.addEventListener("blur", function () { focusOn = false; render(); });

  /* ---------- keyboard on the artboard ---------- */
  art.addEventListener("keydown", function (ev) {
    var k = ev.key, dx = 0, dy = 0;
    if (k === "ArrowLeft") dx = -1;
    else if (k === "ArrowRight") dx = 1;
    else if (k === "ArrowUp") dy = -1;
    else if (k === "ArrowDown") dy = 1;
    else if (k === "Enter" || k === " " || k === "Spacebar") {
      ev.preventDefault();
      dirty = false;
      var was = tool;
      applyTool(curX, curY, false);
      // fill and pick announce their own result; don't talk over them
      if (was === "pen" || was === "eraser") say(describeCell());
      return;
    } else return;
    ev.preventDefault();
    curX = Math.max(0, Math.min(W - 1, curX + dx));
    curY = Math.max(0, Math.min(W - 1, curY + dy));
    if (ev.shiftKey && (tool === "pen" || tool === "eraser")) {
      dirty = false;
      applyTool(curX, curY, false);
    }
    render(); stats();
    say(describeCell());
  });
  function describeCell() {
    var v = px[curY * W + curX];
    return "column " + (curX + 1) + ", row " + (curY + 1) + " — " + (v < 0 ? "empty" : intToHex(v));
  }

  /* ---------- tools / mirror ---------- */
  function setTool(t) {
    tool = t;
    [].forEach.call($("tools").children, function (b) {
      b.setAttribute("aria-pressed", b.getAttribute("data-tool") === t ? "true" : "false");
    });
    document.body.className = "tool-" + t;
    $("rTool").textContent = { pen: "paint", eraser: "erase", fill: "fill", pick: "pick" }[t];
  }
  $("tools").addEventListener("click", function (ev) {
    var b = ev.target.closest ? ev.target.closest("button[data-tool]") : null;
    if (b) { setTool(b.getAttribute("data-tool")); say(b.textContent.trim() + " tool"); }
  });

  function setSym(s) {
    sym = s;
    [].forEach.call($("sym").children, function (b) {
      b.setAttribute("aria-pressed", +b.getAttribute("data-sym") === s ? "true" : "false");
    });
    var names = ["off", "left–right", "up–down", "both ways"];
    $("rSym").textContent = names[s];
    render();
    say("mirror " + names[s]);
  }
  $("sym").addEventListener("click", function (ev) {
    var b = ev.target.closest ? ev.target.closest("button[data-sym]") : null;
    if (b) setSym(+b.getAttribute("data-sym"));
  });

  /* ---------- palette UI ---------- */
  var palEl = $("palette");
  function buildPalette() {
    var frag = document.createDocumentFragment();
    var t = document.createElement("button");
    t.type = "button"; t.className = "sw trans"; t.setAttribute("role", "radio");
    t.setAttribute("aria-checked", "false"); t.setAttribute("aria-label", "Transparent");
    t.setAttribute("data-hex", "none"); t.tabIndex = -1;
    frag.appendChild(t);
    PALETTE.forEach(function (p) {
      var b = document.createElement("button");
      b.type = "button"; b.className = "sw"; b.setAttribute("role", "radio");
      b.setAttribute("aria-checked", "false");
      b.setAttribute("aria-label", p[1] + " " + p[0]);
      b.title = p[1] + " · " + p[0];
      b.style.setProperty("--c", p[0]);
      b.setAttribute("data-hex", p[0]);
      b.tabIndex = -1;
      frag.appendChild(b);
    });
    palEl.appendChild(frag);
  }
  buildPalette();

  function swatchList() { return [].slice.call(palEl.querySelectorAll(".sw")); }
  function syncPalette() {
    var list = swatchList(), anyChecked = false;
    list.forEach(function (b) {
      var h = b.getAttribute("data-hex");
      var on = transparentSel ? (h === "none") : (h !== "none" && hexToInt(h) === color);
      b.setAttribute("aria-checked", on ? "true" : "false");
      b.tabIndex = on ? 0 : -1;
      if (on) anyChecked = true;
    });
    if (!anyChecked && list.length) list[0].tabIndex = 0;
    [].forEach.call($("recent").querySelectorAll(".sw"), function (b) {
      var on = !transparentSel && hexToInt(b.getAttribute("data-hex")) === color;
      b.classList.toggle("on", on);
      b.setAttribute("aria-pressed", on ? "true" : "false");
    });
  }
  function selectColor(hex) {
    hex = hex.toUpperCase();
    transparentSel = false;
    color = hexToInt(hex);
    colorName = NAME_OF[hex] || "custom";
    $("curHex").textContent = hex;
    $("curName").textContent = colorName;
    $("bigsw").style.setProperty("--c", hex);
    $("bigsw").classList.remove("trans");
    $("custom").value = hex.toLowerCase();
    syncPalette();
  }
  function selectTransparent() {
    transparentSel = true;
    $("curHex").textContent = "none";
    $("curName").textContent = "transparent";
    $("bigsw").classList.add("trans");
    syncPalette();
  }
  palEl.addEventListener("click", function (ev) {
    var b = ev.target.closest ? ev.target.closest(".sw") : null;
    if (!b) return;
    var h = b.getAttribute("data-hex");
    if (h === "none") { selectTransparent(); say("transparent selected"); }
    else { selectColor(h); say(b.getAttribute("aria-label") + " selected"); }
  });
  palEl.addEventListener("keydown", function (ev) {
    var list = swatchList(), i = list.indexOf(document.activeElement);
    if (i < 0) return;
    var d = 0;
    if (ev.key === "ArrowRight" || ev.key === "ArrowDown") d = 1;
    else if (ev.key === "ArrowLeft" || ev.key === "ArrowUp") d = -1;
    else if (ev.key === "Home") d = -i;
    else if (ev.key === "End") d = list.length - 1 - i;
    else return;
    ev.preventDefault();
    var n = list[(i + d + list.length) % list.length];
    n.focus(); n.click();
  });

  function pushRecent(hex) {
    hex = hex.toUpperCase();
    if (NAME_OF[hex]) return;
    recents = recents.filter(function (h) { return h !== hex; });
    recents.unshift(hex);
    if (recents.length > 8) recents.pop();
    var box = $("recent");
    box.innerHTML = "";
    recents.forEach(function (h) {
      var b = document.createElement("button");
      b.type = "button"; b.className = "sw"; b.style.setProperty("--c", h);
      b.setAttribute("data-hex", h); b.title = h; b.setAttribute("aria-label", "Recent color " + h);
      b.setAttribute("aria-pressed", "false");
      box.appendChild(b);
    });
    syncPalette();
  }
  $("recent").addEventListener("click", function (ev) {
    var b = ev.target.closest ? ev.target.closest(".sw") : null;
    if (b) { selectColor(b.getAttribute("data-hex")); say(b.getAttribute("data-hex") + " selected"); }
  });
  $("custom").addEventListener("input", function () { selectColor(this.value); });
  $("custom").addEventListener("change", function () { selectColor(this.value); pushRecent(this.value); });

  /* ---------- legibility ---------- */
  var LUM = {};
  function lumOf(v) {
    if (LUM[v] !== undefined) return LUM[v];
    var ch = [(v >> 16) & 255, (v >> 8) & 255, v & 255], out = 0;
    var w = [.2126, .7152, .0722];
    for (var i = 0; i < 3; i++) {
      var u = ch[i] / 255;
      out += w[i] * (u <= .03928 ? u / 12.92 : Math.pow((u + .055) / 1.055, 2.4));
    }
    LUM[v] = out;
    return out;
  }
  function ratio(a, b) {
    var l1 = lumOf(a), l2 = lumOf(b);
    if (l1 < l2) { var t = l1; l1 = l2; l2 = t; }
    return (l1 + .05) / (l2 + .05);
  }
  var TAB_LIGHT = 0xFFFFFF, TAB_DARK = 0x14161A;
  function legibility(filled) {
    var clearL = 0, clearD = 0;
    for (var i = 0; i < N; i++) {
      var v = px[i];
      if (v < 0) continue;
      if (ratio(v, TAB_LIGHT) >= 3) clearL++;
      if (ratio(v, TAB_DARK) >= 3) clearD++;
    }
    paintMeter("L", filled ? Math.round(100 * clearL / filled) : -1);
    paintMeter("D", filled ? Math.round(100 * clearD / filled) : -1);
  }
  function paintMeter(k, pct) {
    var bar = $("bar" + k), val = $("pct" + k), word = $("word" + k);
    if (pct < 0) {
      bar.style.width = "0%"; bar.className = "";
      val.textContent = "—"; word.textContent = "nothing drawn yet"; word.className = "verdict";
      return;
    }
    bar.style.width = pct + "%";
    var cls = pct >= 25 ? "ok" : (pct >= 10 ? "warn" : "bad");
    bar.className = cls;
    val.textContent = pct + "%";
    word.textContent = pct >= 25 ? "reads clearly" : (pct >= 10 ? "only just reads" : "hides against it");
    word.className = "verdict" + (cls === "ok" ? " ok" : cls === "bad" ? " bad" : "");
  }

  /* ---------- stats ---------- */
  function stats() {
    var filled = 0, seen = {}, cols = 0;
    for (var i = 0; i < N; i++) {
      if (px[i] >= 0) {
        filled++;
        if (!seen[px[i]]) { seen[px[i]] = 1; cols++; }
      }
    }
    $("rFill").textContent = filled + "/" + N;
    $("rCols").textContent = String(cols);
    $("rCur").textContent = (hoverOn || focusOn) ? ("x" + (curX + 1) + " y" + (curY + 1)) : "—";
    legibility(filled);
  }

  var liveEl = $("live"), sayTimer = null;
  function say(msg) {
    // Null the handle when it fires: a spent id gets recycled by the browser, and a
    // stale one here would let another timer's clearTimeout cancel this announcement.
    if (sayTimer) { clearTimeout(sayTimer); sayTimer = null; }
    sayTimer = setTimeout(function () { sayTimer = null; liveEl.textContent = msg; }, 60);
  }

  /* ---------- starters, clear ---------- */
  document.addEventListener("click", function (ev) {
    var b = ev.target.closest ? ev.target.closest("[data-preset]") : null;
    if (!b) return;
    loadPreset(b.getAttribute("data-preset"));
    commit(); render(); stats();
    say(b.getAttribute("data-preset") + " loaded");
  });
  $("clear").addEventListener("click", function () {
    for (var i = 0; i < N; i++) px[i] = -1;
    commit(); render(); stats(); say("artboard cleared");
  });
  $("undo").addEventListener("click", undo);
  $("redo").addEventListener("click", redo);

  /* ---------- preview background ---------- */
  $("bgsel").addEventListener("click", function (ev) {
    var b = ev.target.closest ? ev.target.closest("button[data-bg]") : null;
    if (!b) return;
    var v = b.getAttribute("data-bg");
    [].forEach.call($("bgsel").children, function (x) {
      x.setAttribute("aria-pressed", x === b ? "true" : "false");
    });
    $("pvstrip").className = "pvstrip bg-" + v;
  });

  /* ---------- export ---------- */
  function group(n) { return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ","); }
  var statusEl = $("status");
  function setStatus(msg, kind) {
    statusEl.textContent = msg;
    statusEl.className = "status" + (kind ? " " + kind : "");
  }
  // A sandboxed frame may refuse the download outright; that must never throw.
  function offerDownload(href, name) {
    try {
      var a = document.createElement("a");
      a.href = href; a.download = name;
      document.body.appendChild(a); a.click(); a.remove();
    } catch (e) { /* the data url below is the fallback */ }
  }
  function exportPNG(size) {
    var c, g;
    try {
      c = document.createElement("canvas");
      c.width = size; c.height = size;
      g = c.getContext("2d");
      g.imageSmoothingEnabled = false;
      g.drawImage(base, 0, 0, size, size);
    } catch (e) {
      setStatus("this browser would not build a png here", "bad");
      return;
    }
    var done = function (bytes) {
      var head = "icon-" + size + ".png · " + size + "×" + size + " · " + group(bytes) + " bytes";
      var url = "";
      try { url = c.toDataURL("image/png"); } catch (e) { url = ""; }
      $("durltext").value = url;
      $("durl").hidden = !url;
      // Inside a frame the browser may refuse the download, so don't claim it landed.
      setStatus(framed
        ? head + (url
            ? " · built here. If no download appeared, this frame blocked it — copy the data url below."
            : " · built here. If no download appeared, this frame blocked it.")
        : head + " · saved to your downloads", "ok");
      say("png exported at " + size + " pixels");
    };
    // Older engines (and locked-down ones) may have no toBlob, or refuse it outright.
    var viaDataURL = function () {
      var data;
      try { data = c.toDataURL("image/png"); }
      catch (e) { setStatus("this browser would not build a png here", "bad"); return; }
      offerDownload(data, "icon-" + size + ".png");
      done(Math.round((data.length - 22) * 3 / 4));
    };
    if (!c.toBlob) { viaDataURL(); return; }
    try {
      c.toBlob(function (blob) {
        if (!blob) { viaDataURL(); return; }
        try {
          var url = URL.createObjectURL(blob);
          offerDownload(url, "icon-" + size + ".png");
          setTimeout(function () { URL.revokeObjectURL(url); }, 5000);
        } catch (e) { /* the data url in done() is the fallback */ }
        done(blob.size);
      }, "image/png");
    } catch (e) { viaDataURL(); }
  }
  document.addEventListener("click", function (ev) {
    var b = ev.target.closest ? ev.target.closest("[data-png]") : null;
    if (b) exportPNG(+b.getAttribute("data-png"));
  });
  $("copy").addEventListener("click", function () {
    var ta = $("durltext");
    ta.focus(); ta.select();
    var ok = false;
    try { ok = document.execCommand && document.execCommand("copy"); } catch (e) { ok = false; }
    if (ok) { setStatus("data url copied · " + ta.value.length + " characters", "ok"); return; }
    if (navigator.clipboard && navigator.clipboard.writeText) {
      navigator.clipboard.writeText(ta.value).then(function () {
        setStatus("data url copied · " + ta.value.length + " characters", "ok");
      })["catch"](function () {
        setStatus("copying is blocked here — the text is selected, press Ctrl+C", null);
      });
    } else {
      setStatus("copying is blocked here — the text is selected, press Ctrl+C", null);
    }
  });

  /* ---------- global shortcuts ---------- */
  addEventListener("keydown", function (ev) {
    var t = ev.target, tag = t && t.tagName;
    if (tag === "INPUT" || tag === "TEXTAREA" || (t && t.isContentEditable)) return;
    var k = ev.key.toLowerCase();
    if ((ev.ctrlKey || ev.metaKey) && k === "z") {
      ev.preventDefault();
      if (ev.shiftKey) redo(); else undo();
      return;
    }
    if ((ev.ctrlKey || ev.metaKey) && k === "y") { ev.preventDefault(); redo(); return; }
    if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
    if (k === "b") setTool("pen");
    else if (k === "e") setTool("eraser");
    else if (k === "g") setTool("fill");
    else if (k === "i") setTool("pick");
    else if (k === "s") setSym((sym + 1) % 4);
    else return;
    ev.preventDefault();
  });

  /* ---------- resize ---------- */
  var rTimer = null;
  function onResize() {
    if (rTimer) { clearTimeout(rTimer); rTimer = null; }
    rTimer = setTimeout(function () { rTimer = null; render(); }, 60);
  }
  addEventListener("resize", onResize);
  if (window.ResizeObserver) {
    try { new ResizeObserver(onResize).observe(art.parentNode); } catch (e) {}
  }

  /* ---------- boot ---------- */
  loadPreset("smiley");
  commit();
  setTool("pen");
  setSym(0);
  selectColor("#C05600");
  render();
  stats();
})();
</script>
</body>
</html>

source-visible by construction · nothing is published here without its code

Comments

0 total

No comments yet. If you ran it, say what happened.

Report this post

Goes straight to the moderation queue. Enough independent reports and the post is suspended automatically until a human looks.

what is wrong