Whiteboard
GPT 5.6 Luna Pro · typescript

Browser check: Artifact runtime error. This is a technical check, not a quality review.
Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 18,172 bytes · SHA-256 e3094ec2cb18
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Canvasly — Collaborative Whiteboard</title>
<style>
/* ── css: base ── */
*{box-sizing:border-box}html,body,#app{margin:0;width:100%;height:100%;overflow:hidden;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#1e293b}#board-app{position:relative;width:100%;height:100%;background:#e9edf2}#canvas{position:absolute;inset:0;width:100%;height:100%;cursor:crosshair}.topbar{position:absolute;top:18px;left:22px;right:22px;height:54px;display:flex;align-items:center;gap:22px;pointer-events:none}.brand,.history,.hint{pointer-events:auto;background:rgba(255,255,255,.92);box-shadow:0 8px 24px rgba(30,41,59,.1);border:1px solid rgba(148,163,184,.2)}.brand{height:46px;border-radius:14px;padding:0 16px;display:flex;align-items:center;gap:9px;font-weight:750;letter-spacing:-.02em}.brand-mark{display:grid;place-items:center;width:25px;height:25px;border-radius:8px;background:#f2c94c;color:#fff;font-size:17px}.brand em{font-style:normal;font-size:10px;color:#94a3b8;text-transform:uppercase;letter-spacing:.1em;margin-left:3px}.history{display:flex;border-radius:12px;overflow:hidden}.history button{border:0;background:transparent;color:#64748b;font-size:23px;width:40px;height:42px;cursor:pointer}.history button:hover{background:#f1f5f9;color:#334155}.hint{margin-left:auto;border-radius:999px;padding:9px 14px;color:#94a3b8;font-size:11px}.toolbar{position:absolute;top:88px;left:22px;display:flex;flex-direction:column;padding:7px;gap:4px;background:rgba(255,255,255,.94);border:1px solid rgba(148,163,184,.2);border-radius:14px;box-shadow:0 8px 24px rgba(30,41,59,.1)}.tool{border:0;background:transparent;border-radius:9px;width:58px;height:52px;color:#64748b;cursor:pointer;font-size:21px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px}.tool span{font-size:9px;font-weight:650}.tool:hover{background:#f1f5f9}.tool.active{background:#e7f0ff;color:#3b73d1;box-shadow:inset 0 0 0 1px #c7dbff}.props{position:absolute;left:50%;transform:translateX(-50%);bottom:22px;background:rgba(255,255,255,.95);padding:10px 14px;border:1px solid rgba(148,163,184,.2);border-radius:13px;box-shadow:0 8px 24px rgba(30,41,59,.1);display:flex;align-items:center;gap:16px;font-size:11px;color:#64748b}.props label{display:flex;align-items:center;gap:7px;white-space:nowrap}.props input[type=color]{width:27px;height:27px;border:0;padding:0;background:none}.props input[type=range]{width:85px;accent-color:#4f86df}.props output{width:27px;color:#334155}.zoom{position:absolute;right:22px;bottom:22px;display:flex;align-items:center;gap:10px;background:rgba(255,255,255,.94);padding:10px 13px;border-radius:12px;box-shadow:0 8px 24px rgba(30,41,59,.1);font-size:12px}.zoom strong{min-width:42px;text-align:center}.zoom button{border:0;background:#f1f5f9;color:#64748b;border-radius:7px;padding:6px 9px;font-size:11px;cursor:pointer}.zoom button:hover{background:#e2e8f0}.text-editor{position:absolute;display:none;min-width:80px;min-height:30px;padding:3px 5px;border:1px dashed #4f86df;outline:none;background:rgba(255,255,255,.8);font:20px/1.25 ui-sans-serif;color:#1e293b;white-space:pre-wrap}.text-editor.show{display:block}#empty-state{position:absolute;inset:0;display:grid;place-content:center;text-align:center;pointer-events:none;color:#94a3b8;transform:translateY(20px)}#empty-state h1{margin:12px 0 4px;font-size:22px;color:#64748b;font-weight:650;letter-spacing:-.03em}#empty-state p{margin:0;font-size:13px}.empty-icon{font-size:38px;color:#cbd5e1}.has-content #empty-state{display:none}@media(max-width:700px){.hint{display:none}.props{left:12px;right:12px;transform:none;justify-content:space-between;gap:5px}.toolbar{left:10px;top:82px}.tool{width:48px}.brand{left:10px}.brand em{display:none}}
/* ── css: selection ── */
.rubber{position:absolute;border:1px dashed #4f86df;background:rgba(79,134,223,.1);pointer-events:none}.text-editor:focus{box-shadow:0 0 0 3px rgba(79,134,223,.12)}
</style>
</head>
<body>
<main id="board-app"><canvas id="canvas" aria-label="Whiteboard canvas"></canvas><header class="topbar"><div class="brand"><span class="brand-mark">✦</span><span>Canvasly</span><em>local board</em></div><div class="history"><button id="undo" title="Undo (Ctrl+Z)">↶</button><button id="redo" title="Redo (Ctrl+Shift+Z)">↷</button></div><div class="hint">Space + drag to pan · Scroll to zoom</div></header><nav class="toolbar" aria-label="Tools"><button class="tool active" data-tool="select" title="Select (V)">↖<span>Select</span></button><button class="tool" data-tool="pen" title="Pen (P)">✎<span>Pen</span></button><button class="tool" data-tool="rect" title="Rectangle (R)">▱<span>Box</span></button><button class="tool" data-tool="ellipse" title="Ellipse (O)">◯<span>Oval</span></button><button class="tool" data-tool="line" title="Line / Arrow (L)">╱<span>Line</span></button><button class="tool" data-tool="text" title="Text (T)">T<span>Text</span></button><button class="tool" data-tool="eraser" title="Eraser (E)">⌫<span>Erase</span></button></nav><section class="props"><label>Stroke <input id="stroke" type="color" value="#334155"></label><label>Fill <input id="fill" type="color" value="#ffffff"></label><label>Width <input id="width" type="range" min="1" max="12" value="2"><output id="width-out">2px</output></label></section><aside class="zoom"><strong id="zoom-label">100%</strong><button id="reset">Reset view</button></aside><div id="text-editor" class="text-editor" contenteditable="true" spellcheck="false"></div><div id="empty-state"><div class="empty-icon">✦</div><h1>Make space for ideas</h1><p>Choose a tool and draw anywhere on the canvas.</p></div></main>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {};
function __require(id) {
if (__cache[id]) return __cache[id].exports;
var module = __cache[id] = { exports: {} };
var factory = __mods[id];
if (!factory) throw new Error("Module not found: " + id);
factory(module.exports, function (spec) {
var target = (__map[id] && __map[id][spec]) || spec;
return __require(target);
}, module);
return module.exports;
}
// ── module: src/main.ts ──
__mods["src/main.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const C = { min: .25, max: 4, hit: 8, grid: 28, handle: 7 };
const canvas = document.querySelector('#canvas'), ctx = canvas.getContext('2d'), app = document.querySelector('#board-app');
const copy = (v) => JSON.parse(JSON.stringify(v));
let scene = JSON.parse(localStorage.getItem('canvasly-scene') || '[]'), selected = new Set(), tool = 'select', zoom = 1, view = { x: 0, y: 0 }, stroke = '#334155', fill = '#ffffff', lineWidth = 2, pan = false;
let hist = [copy(scene)], future = [];
let act = null;
const editor = document.querySelector('#text-editor');
const id = () => Math.random().toString(36).slice(2, 9);
const save = () => { localStorage.setItem('canvasly-scene', JSON.stringify(scene)); app.classList.toggle('has-content', scene.length > 0); };
function resize() { const d = devicePixelRatio || 1; canvas.width = innerWidth * d; canvas.height = innerHeight * d; ctx.setTransform(d, 0, 0, d, 0, 0); draw(); }
addEventListener('resize', resize);
const W = (p) => ({ x: p.x * zoom + view.x, y: p.y * zoom + view.y }), S = (x, y) => ({ x: (x - view.x) / zoom, y: (y - view.y) / zoom });
function commit() { if (JSON.stringify(hist[hist.length - 1]) !== JSON.stringify(scene)) {
hist.push(copy(scene));
future = [];
} save(); draw(); }
function setTool(t) { tool = t; document.querySelectorAll('.tool').forEach(b => b.classList.toggle('active', b.dataset.tool === t)); canvas.style.cursor = t === 'select' ? 'default' : t === 'eraser' ? 'not-allowed' : 'crosshair'; }
function bounds(o) { if (o.type === 'pen' && o.points?.length) {
const xs = o.points.map(p => p.x), ys = o.points.map(p => p.y);
return { x: Math.min(...xs), y: Math.min(...ys), w: Math.max(...xs) - Math.min(...xs), h: Math.max(...ys) - Math.min(...ys) };
} return { x: o.x, y: o.y, w: o.w || 0, h: o.h || 0 }; }
function render(o) { ctx.save(); ctx.strokeStyle = o.stroke; ctx.fillStyle = o.fill; ctx.lineWidth = o.width; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; const b = bounds(o); if (o.type === 'rect') {
ctx.fillRect(b.x, b.y, b.w, b.h);
ctx.strokeRect(b.x, b.y, b.w, b.h);
}
else if (o.type === 'ellipse') {
ctx.beginPath();
ctx.ellipse(b.x + b.w / 2, b.y + b.h / 2, Math.abs(b.w / 2), Math.abs(b.h / 2), 0, 0, 7);
ctx.fill();
ctx.stroke();
}
else if (o.type === 'line') {
ctx.beginPath();
ctx.moveTo(o.x, o.y);
ctx.lineTo(o.x + (o.w || 0), o.y + (o.h || 0));
ctx.stroke();
}
else if (o.type === 'pen' && o.points) {
ctx.beginPath();
o.points.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y));
ctx.stroke();
}
else if (o.type === 'text') {
ctx.font = '20px sans-serif';
ctx.fillStyle = o.stroke;
ctx.fillText(o.text || '', o.x, o.y + 22);
} ctx.restore(); }
function draw() { ctx.clearRect(0, 0, innerWidth, innerHeight); ctx.fillStyle = '#f8fafc'; ctx.fillRect(0, 0, innerWidth, innerHeight); ctx.save(); ctx.translate(view.x, view.y); ctx.scale(zoom, zoom); const l = -view.x / zoom, t = -view.y / zoom, r = l + innerWidth / zoom, b = t + innerHeight / zoom; ctx.fillStyle = '#dbe3ec'; for (let x = Math.floor(l / C.grid) * C.grid; x < r; x += C.grid)
for (let y = Math.floor(t / C.grid) * C.grid; y < b; y += C.grid) {
ctx.beginPath();
ctx.arc(x, y, 1 / zoom, 0, 7);
ctx.fill();
} scene.forEach(render); if (act?.kind === 'draw' && tool !== 'pen') {
const p = act.start, q = act.last;
ctx.strokeStyle = stroke;
ctx.lineWidth = lineWidth / zoom;
ctx.setLineDash([6 / zoom, 4 / zoom]);
if (tool === 'rect')
ctx.strokeRect(p.x, p.y, q.x - p.x, q.y - p.y);
if (tool === 'ellipse') {
ctx.beginPath();
ctx.ellipse((p.x + q.x) / 2, (p.y + q.y) / 2, Math.abs(q.x - p.x) / 2, Math.abs(q.y - p.y) / 2, 0, 0, 7);
ctx.stroke();
}
if (tool === 'line') {
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(q.x, q.y);
ctx.stroke();
}
ctx.setLineDash([]);
} ctx.restore(); if (selected.size)
selection(); }
function selection() { const bs = [...selected].map(i => bounds(scene.find(o => o.id === i))), x = Math.min(...bs.map(b => b.x)), y = Math.min(...bs.map(b => b.y)), r = Math.max(...bs.map(b => b.x + b.w)), b = Math.max(...bs.map(b => b.y + b.h)); ctx.save(); ctx.strokeStyle = '#4f86df'; ctx.setLineDash([5, 4]); ctx.strokeRect(x * zoom + view.x, y * zoom + view.y, (r - x) * zoom, (b - y) * zoom); ctx.setLineDash([]); ctx.fillStyle = '#fff'; for (const p of [{ x, y }, { x: r, y }, { x, y: b }, { x: r, y: b }]) {
ctx.beginPath();
ctx.rect(p.x * zoom + view.x - C.handle, p.y * zoom + view.y - C.handle, C.handle * 2, C.handle * 2);
ctx.fill();
ctx.stroke();
} ctx.restore(); }
function hit(p) { return [...scene].reverse().find(o => { const b = bounds(o); if (o.type === 'ellipse') {
const dx = (p.x - (b.x + b.w / 2)) / (Math.abs(b.w / 2) || 1), dy = (p.y - (b.y + b.h / 2)) / (Math.abs(b.h / 2) || 1);
return dx * dx + dy * dy <= 1.02;
} if (o.type === 'pen' && o.points)
return o.points.some(q => Math.hypot(q.x - p.x, q.y - p.y) < C.hit / zoom); if (o.type === 'line') {
const dx = o.w || 0, dy = o.h || 0, t = Math.max(0, Math.min(1, ((p.x - o.x) * dx + (p.y - o.y) * dy) / (dx * dx + dy * dy || 1)));
return Math.hypot(p.x - o.x - t * dx, p.y - o.y - t * dy) < C.hit / zoom;
} return p.x >= b.x - C.hit / zoom && p.x <= b.x + b.w + C.hit / zoom && p.y >= b.y - C.hit / zoom && p.y <= b.y + b.h + C.hit / zoom; }); }
function down(e) { const p = S(e.clientX, e.clientY); if (e.button === 1 || pan) {
act = { kind: 'pan', last: { x: e.clientX, y: e.clientY } };
canvas.setPointerCapture(e.pointerId);
return;
} if (tool === 'text') {
textAt(p);
return;
} if (tool === 'eraser') {
const o = hit(p);
if (o) {
scene = scene.filter(x => x.id !== o.id);
commit();
}
return;
} if (tool === 'select') {
const o = hit(p);
if (o) {
if (!e.shiftKey)
selected.clear();
selected.add(o.id);
act = { kind: 'move', start: p, last: p, before: copy(scene), ids: [...selected] };
}
else {
if (!e.shiftKey)
selected.clear();
act = { kind: 'rubber', start: p, last: p };
}
canvas.setPointerCapture(e.pointerId);
draw();
return;
} act = { kind: 'draw', start: p, last: p, before: copy(scene) }; if (tool === 'pen') {
const o = { id: id(), type: 'pen', x: p.x, y: p.y, points: [p], stroke, fill: 'transparent', width: lineWidth };
scene.push(o);
} canvas.setPointerCapture(e.pointerId); draw(); }
function move(e) { if (!act)
return; const p = S(e.clientX, e.clientY); if (act.kind === 'pan') {
view.x += e.clientX - act.last.x;
view.y += e.clientY - act.last.y;
act.last = { x: e.clientX, y: e.clientY };
draw();
return;
} if (act.kind === 'draw') {
act.last = p;
if (tool === 'pen')
scene[hist.length - 1].points.push(p);
draw();
return;
} if (act.kind === 'move') {
const dx = p.x - act.start.x, dy = p.y - act.start.y;
scene.forEach(o => { if (act.ids.includes(o.id)) {
const old = act.before.find((q) => q.id === o.id);
o.x = old.x + dx;
o.y = old.y + dy;
if (o.type === 'pen')
o.points = o.points.map((q, i) => ({ x: old.points[i].x + dx, y: old.points[i].y + dy }));
} });
draw();
return;
} if (act.kind === 'rubber') {
act.last = p;
const x = Math.min(act.start.x, p.x), y = Math.min(act.start.y, p.y), r = Math.max(act.start.x, p.x), b = Math.max(act.start.y, p.y);
selected = new Set(scene.filter(o => { const q = bounds(o); return q.x >= x && q.y >= y && q.x + q.w <= r && q.y + q.h <= b; }).map(o => o.id));
draw();
} }
function up() { if (!act)
return; const a = act; act = null; if (a.kind === 'draw' && tool !== 'pen') {
const p = a.start, q = a.last;
if (Math.hypot(q.x - p.x, q.y - p.y) > 3) {
const o = { id: id(), type: tool, x: p.x, y: p.y, w: q.x - p.x, h: q.y - p.y, stroke, fill, width: lineWidth };
if (tool === 'line')
o.fill = 'transparent';
scene.push(o);
selected = new Set([o.id]);
}
} if (['draw', 'move'].includes(a.kind))
commit(); if (a.kind === 'pan')
draw(); if (a.kind === 'rubber')
draw(); }
function textAt(p) { const s = W(p); editor.style.left = s.x + 'px'; editor.style.top = s.y + 'px'; editor.textContent = ''; editor.classList.add('show'); editor.focus(); const done = () => { const t = editor.textContent?.trim(); editor.classList.remove('show'); editor.removeEventListener('blur', done); if (t) {
scene.push({ id: id(), type: 'text', x: p.x, y: p.y, text: t, stroke, fill: 'transparent', width: lineWidth });
commit();
} }; editor.addEventListener('blur', done); }
function undo() { if (hist.length < 2)
return; future.unshift(hist.pop()); scene = copy(hist[hist.length - 1]); selected.clear(); save(); draw(); }
function redo() { if (!future.length)
return; scene = copy(future.shift()); hist.push(copy(scene)); save(); draw(); }
canvas.addEventListener('pointerdown', down);
canvas.addEventListener('pointermove', move);
canvas.addEventListener('pointerup', up);
canvas.addEventListener('pointercancel', up);
canvas.addEventListener('wheel', e => { e.preventDefault(); const p = S(e.clientX, e.clientY); zoom = Math.max(C.min, Math.min(C.max, zoom * (e.deltaY < 0 ? 1.1 : .9))); view.x = e.clientX - p.x * zoom; view.y = e.clientY - p.y * zoom; document.querySelector('#zoom-label').textContent = Math.round(zoom * 100) + '%'; draw(); }, { passive: false });
addEventListener('keydown', e => { if (e.code === 'Space') {
pan = true;
canvas.style.cursor = 'grab';
} if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') {
e.preventDefault();
e.shiftKey ? redo() : undo();
} if ((e.key === 'Delete' || e.key === 'Backspace') && selected.size) {
scene = scene.filter(o => !selected.has(o.id));
selected.clear();
commit();
} const n = e.shiftKey ? 10 : 1; if (selected.size && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
e.preventDefault();
scene.forEach(o => { if (selected.has(o.id)) {
o.x += e.key === 'ArrowLeft' ? -n : e.key === 'ArrowRight' ? n : 0;
o.y += e.key === 'ArrowUp' ? -n : e.key === 'ArrowDown' ? n : 0;
} });
commit();
} });
addEventListener('keyup', e => { if (e.code === 'Space') {
pan = false;
setTool(tool);
} });
document.querySelectorAll('.tool').forEach(b => b.addEventListener('click', () => setTool(b.dataset.tool)));
document.querySelector('#undo').addEventListener('click', undo);
document.querySelector('#redo').addEventListener('click', redo);
document.querySelector('#reset').addEventListener('click', () => { zoom = 1; view = { x: 0, y: 0 }; document.querySelector('#zoom-label').textContent = '100%'; draw(); });
document.querySelector('#stroke').addEventListener('input', e => stroke = e.target.value);
document.querySelector('#fill').addEventListener('input', e => fill = e.target.value);
document.querySelector('#width').addEventListener('input', e => { lineWidth = +e.target.value; document.querySelector('#width-out').textContent = lineWidth + 'px'; });
resize();
setTool('select');
save();
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"openai/gpt-5.6-luna-pro","provider":"openrouter","persona":"typescript","sessionId":"7862a9e7-ea18-4202-98ba-36275cf86122","tokensIn":506189,"tokensOut":20723,"tokensTotal":526912,"turns":13,"toolCalls":15,"failedToolCalls":2,"timestamp":"2026-07-31T02:59:39.573Z"} -->