doodle-decoder / static /index.html
cetusian's picture
Deploy doodle-decoder
8e2476f verified
Raw History Blame Contribute Delete
9 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Doodle Decoder</title>
<meta name="description" content="Draw the word you're given. Surogate Rune looks at your pixels and guesses, live.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
<style>
:root {
--ink: #2a102d; --ink-2: #52504c; --ink-3: #8c847a; --paper: #ffffff; --card: #ffffff;
--amber: #ffaf10; --amber-soft: rgba(255, 175, 16, 0.14); --line: #ece6da; --good: #22c55e;
}
* { box-sizing: border-box; }
body { margin: 0; font: 16px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
background: var(--paper); color: var(--ink); -webkit-font-smoothing: antialiased; }
main { max-width: 1040px; margin: 0 auto; padding: 24px 20px 40px; display: grid; gap: 24px;
grid-template-columns: minmax(0, 1fr) 320px; }
@media (max-width: 800px) { main { grid-template-columns: minmax(0, 1fr); } }
header { grid-column: 1 / -1; }
h1 { margin: 0; font-family: Fraunces, Georgia, serif; font-weight: 600; font-size: clamp(30px, 5vw, 44px); letter-spacing: -0.02em; line-height: 1.05; }
.rune-embedded header, .rune-embedded footer { display: none; }
.rune-embedded main { padding-top: 8px; }
.sub { margin: 6px 0 0; color: var(--ink-2); }
.prompt { display: flex; align-items: baseline; gap: 10px; margin: 0 0 12px; font-size: 20px; color: var(--ink-2); }
.prompt b { font-size: 30px; color: var(--ink); background: var(--amber-soft); padding: 0 10px; border-radius: 8px; }
canvas { display: block; width: 100%; aspect-ratio: 1; background: #fff; border: 1px solid var(--line);
border-radius: 16px; touch-action: none; cursor: crosshair; box-shadow: 0 24px 50px -30px rgba(42, 16, 45, 0.35); }
.tools { display: flex; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
button { font: inherit; font-weight: 600; padding: 9px 16px; border-radius: 10px; border: 2px solid var(--ink);
background: var(--card); color: var(--ink); cursor: pointer; }
button.primary { background: var(--amber); border-color: var(--amber); }
button:focus-visible { outline: 3px solid var(--amber); outline-offset: 3px; }
aside { background: var(--card); border: 1px solid var(--line); border-radius: 16px; padding: 18px 18px 14px;
align-self: start; position: sticky; top: 16px; }
.head { margin: 0 0 12px; font-family: Fraunces, Georgia, serif; font-weight: 600; font-size: 26px; min-height: 1.4em; }
.bar { display: grid; grid-template-columns: 96px 1fr 42px; gap: 8px; align-items: center; margin: 7px 0; font-size: 15px; }
.bar .track { height: 12px; background: var(--line); border-radius: 6px; overflow: hidden; }
.bar .fill { height: 100%; background: var(--amber); border-radius: 6px; transition: width 0.3s ease; }
.bar.top .fill { background: var(--ink); }
.bar .pct { text-align: right; font-variant-numeric: tabular-nums; color: var(--ink-2); }
.status { color: var(--ink-3); font-size: 14px; min-height: 1.5em; margin: 10px 0 0; }
.win { display: none; margin-top: 14px; padding: 14px; border-radius: 12px; background: var(--ink); color: #fff; }
.win.show { display: block; animation: pop 0.35s cubic-bezier(.2, 1.4, .4, 1); }
.win p { margin: 0 0 10px; }
.win button { border-color: var(--amber); background: var(--amber); color: var(--ink); }
@keyframes pop { from { transform: scale(0.92); opacity: 0; } to { transform: none; opacity: 1; } }
footer { grid-column: 1 / -1; color: var(--ink-3); font-size: 14px; }
footer a { color: var(--ink); }
@media (prefers-reduced-motion: reduce) { .bar .fill { transition: none; } .win.show { animation: none; } }
</style>
<script src="embed.js"></script>
<script src="client.js"></script>
</head>
<body>
<main>
<header>
<h1>Doodle Decoder</h1>
<p class="sub">Draw the word. Every time you lift the pen, Rune looks at your pixels and guesses.</p>
</header>
<section>
<p class="prompt">Draw <b id="word"></b></p>
<canvas id="canvas" width="512" height="512" aria-label="Drawing canvas"></canvas>
<div class="tools">
<button id="clear" type="button">Clear</button>
<button id="next" type="button" class="primary">New word</button>
</div>
</section>
<aside aria-live="polite">
<h2 class="head" id="head">Rune is watching…</h2>
<div id="bars"></div>
<p class="status" id="status">Start drawing.</p>
<div class="win" id="win">
<p id="win-text"></p>
<button id="share" type="button">Share</button>
</div>
</aside>
<footer>
Powered by <a href="https://huggingface.co/surogate/rune-26b-a4b-GGUF" target="_blank" rel="noopener">Surogate Rune</a>,
an open decision model. <a href="https://surogate.ai/labs/rune-examples/" target="_blank" rel="noopener">More demos</a>.
Nothing you draw is stored.
</footer>
</main>
<script>
const WORDS = ["cat", "dog", "fish", "bird", "house", "tree", "flower", "sun", "moon", "star", "car", "bicycle",
"boat", "airplane", "train", "rocket", "cup", "chair", "table", "lamp", "clock", "key", "umbrella",
"hat", "shoe", "glasses", "guitar", "pizza", "apple", "banana", "ice cream", "cake", "heart",
"smiley face", "snake", "spider", "butterfly", "mountain", "cloud", "lightning", "snowman",
"ghost", "crown", "sword", "light bulb", "phone", "envelope", "scissors", "pencil", "eye"];
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
ctx.lineWidth = 8; ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.strokeStyle = "#111";
const $ = (id) => document.getElementById(id);
let strokes = 0, drawing = false, busy = false, pending = false, target = "", won = false;
let round = 0;
let last = null; // the previous pointer position; each move draws only the new segment // bumped by Clear and New word; an answer from an earlier round is dropped
function clearCanvas() {
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
strokes = 0; won = false; pending = false; round++;
$("bars").replaceChildren();
$("win").classList.remove("show");
$("head").textContent = "Rune is watching…";
$("status").textContent = "Start drawing.";
}
function newWord(asked) {
let next = WORDS.includes(asked) ? asked : null;
while (!next || next === target) next = WORDS[Math.floor(Math.random() * WORDS.length)];
target = next;
$("word").textContent = target;
clearCanvas();
}
function point(e) {
const r = canvas.getBoundingClientRect();
return [(e.clientX - r.left) * canvas.width / r.width, (e.clientY - r.top) * canvas.height / r.height];
}
canvas.addEventListener("pointerdown", (e) => {
drawing = true;
canvas.setPointerCapture(e.pointerId);
last = point(e);
});
canvas.addEventListener("pointermove", (e) => {
if (!drawing) return;
const p = point(e);
ctx.beginPath();
ctx.moveTo(...last);
ctx.lineTo(...p);
ctx.stroke();
last = p;
});
function endStroke() {
if (!drawing) return;
drawing = false;
strokes++;
guess();
}
canvas.addEventListener("pointerup", endStroke);
canvas.addEventListener("pointercancel", endStroke);
function setBusy(on) {
busy = on;
if (on) document.body.dataset.busy = "1"; else delete document.body.dataset.busy;
}
async function guess() {
if (won) return;
if (busy) { pending = true; return; } // one request at a time; the latest drawing is sent next
setBusy(true);
const mine = round;
try {
const t = performance.now();
const out = await post("decide", { image: canvas.toDataURL("image/png"), context: {} });
if (mine !== round) return;
const probs = Object.entries(out.labels.guess).sort((a, b) => b[1] - a[1]);
const [top, p] = probs[0];
$("head").textContent = p < 0.5 ? `Hmm… a ${top}?` : `It's a ${top}!`;
$("bars").replaceChildren(...probs.slice(0, 5).map(([w, q], i) => bar(w, q, i === 0)));
$("status").textContent = `${Math.round(performance.now() - t)} ms · stroke ${strokes}`;
if (top === target && p >= 0.5) {
won = true;
$("win-text").textContent = `Rune got it in ${strokes} stroke${strokes === 1 ? "" : "s"}, ${Math.round(p * 100)}% sure it's a ${target}.`;
$("win").classList.add("show");
}
} catch (err) {
if (mine === round) $("status").textContent = err.message;
} finally {
setBusy(false);
if (pending && !won) { pending = false; guess(); }
}
}
// The link challenges a friend to draw the same word.
$("share").addEventListener("click", () => share($("share"), `${$("win-text").textContent} Can you beat it?`, { word: target }));
$("clear").addEventListener("click", clearCanvas);
$("next").addEventListener("click", () => newWord());
const asked = new URLSearchParams(location.search).get("word");
newWord(asked);
</script>
</body>
</html>