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.
Source — the code you see is the code that just ran 1296 lines 51.8 KB 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)"><img src></code>
or a <code style="font-family:var(--font-mono)"><link rel="icon"></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 totalNo comments yet. If you ran it, say what happened.