28 September, 2026 · a canvas for two, yours to copy
khat is a shared scribble canvas for two people. One drawing pad, always in sync, so a doodle left on my phone shows up on my girlfriend's.
No feed, no login, no notifications. It behaves like a shared fridge door. It is one HTML file and a storage bucket, so you can copy the whole thing and run your own in about ten minutes. Steps are below.
I started with seven fancy pens. They looked great and got in the way. A note between two people needs three things.
Instead of covering the canvas with panels, the toolbar itself morphs. Tap size and it becomes sliders, tap color and it becomes a palette, then a full wheel.
There is no database of strokes and no realtime server. The whole canvas is one PNG in cloud storage. When your hand rests, it uploads the picture. Every few seconds, when you are not drawing, it pulls the latest one back down. Simple enough to fit in a single file.
A home screen widget shows the latest scribble and opens the canvas when you tap it. It runs in Scriptable, a free app for running small scripts on iOS. Do this on each phone.
First get the canvas running, the widget depends on it: a Supabase project, a Storage bucket named exactly scribbles set to Public so the image loads without a login, and the canvas deployed to a URL. The widget reads shared.png from that bucket and opens that URL when tapped. Source is on GitHub.
khat. The name has to match the code.// ── CONFIG ────────────────────────────────────────────────────────────────
const SUPABASE_URL = "https://YOUR-PROJECT.supabase.co";
const WEB_APP_URL = "https://your-canvas-url";
// ─────────────────────────────────────────────────────────────────────────
const SCRIPT_NAME = "khat"; // MUST match this script's name in Scriptable exactly
const IMAGE_URL = `${SUPABASE_URL}/storage/v1/object/public/scribbles/shared.png`;
const CANVAS_BG = "#fdf6d3"; // warm notepad yellow, matches the web canvas
// When tapped, open canvas as full-screen WebView inside Scriptable
if (!config.runsInWidget) {
const wv = new WebView();
await wv.loadURL(WEB_APP_URL);
await wv.present(true);
Script.complete();
return;
}
const widget = new ListWidget();
widget.url = `scriptable:///run?scriptName=${encodeURIComponent(SCRIPT_NAME)}`;
widget.setPadding(22, 20, 16, 16);
// Transparent so the home screen wallpaper shows around the note
widget.backgroundColor = new Color("#000000", 0);
widget.refreshAfterDate = new Date(Date.now() + 5 * 60 * 1000);
let img = null;
try {
img = await new Request(IMAGE_URL + "?t=" + Date.now()).loadImage();
} catch (e) { /* no scribble yet */ }
widget.backgroundImage = renderCard(img, widgetSize());
// ── Top-left: love notes pill ───────────────────────────────────────────────
const topRow = widget.addStack();
topRow.layoutHorizontally();
const lovePill = topRow.addStack();
lovePill.layoutHorizontally();
lovePill.centerAlignContent();
lovePill.setPadding(5, 10, 5, 10);
lovePill.cornerRadius = 30;
lovePill.backgroundColor = new Color("#1a1014", 0.5);
const heart = lovePill.addText("♡");
heart.textColor = new Color("#ff9ebb");
heart.font = Font.boldSystemFont(11);
lovePill.addSpacer(5);
const loveLabel = lovePill.addText("love notes");
loveLabel.textColor = new Color("#ffe1ea");
loveLabel.font = Font.semiboldSystemFont(11);
topRow.addSpacer();
widget.addSpacer();
// ── Bottom-right: time-based greeting pill ──────────────────────────────────
const bottomRow = widget.addStack();
bottomRow.layoutHorizontally();
bottomRow.addSpacer();
const g = greeting();
const greetPill = bottomRow.addStack();
greetPill.layoutHorizontally();
greetPill.centerAlignContent();
greetPill.setPadding(4, 9, 4, 9);
greetPill.cornerRadius = 30;
greetPill.backgroundColor = new Color("#1a1014", 0.45);
const emo = greetPill.addText(g.emoji);
emo.font = Font.systemFont(10);
greetPill.addSpacer(4);
const greetLabel = greetPill.addText(g.text);
greetLabel.textColor = new Color("#ffffff", 0.92);
greetLabel.font = Font.mediumSystemFont(10);
Script.setWidget(widget);
Script.complete();
// ── Render the yellow note card on a transparent background ─────────────────
function renderCard(scribble, [w, h]) {
const ctx = new DrawContext();
ctx.size = new Size(w, h);
ctx.opaque = false; // transparent outside the card
ctx.respectScreenScale = true;
const inset = 9;
const radius = 20;
const card = new Rect(inset, inset + 4, w - inset * 2, h - inset * 2 - 4);
// Soft drop shadow so the note feels lifted off the wallpaper
for (let i = 4; i >= 1; i--) {
ctx.setFillColor(new Color("#000000", 0.05));
fillRounded(ctx, card.minX, card.minY + i * 1.5, card.width, card.height, radius);
}
// Paper card
ctx.setFillColor(new Color(CANVAS_BG));
fillRounded(ctx, card.minX, card.minY, card.width, card.height, radius);
// Scribble aspect-fit inside the card (inset keeps the rounded corners clean)
if (scribble) {
const pad = 10;
const availW = card.width - pad * 2;
const availH = card.height - pad * 2;
const s = Math.min(availW / scribble.size.width, availH / scribble.size.height);
const dw = scribble.size.width * s;
const dh = scribble.size.height * s;
ctx.drawImageInRect(
scribble,
new Rect(card.minX + (card.width - dw) / 2, card.minY + (card.height - dh) / 2, dw, dh)
);
} else {
ctx.setTextColor(new Color("#b6a55a"));
ctx.setFont(Font.semiboldSystemFont(13));
ctx.setTextAlignedCenter();
ctx.drawTextInRect("✏️ a blank page", new Rect(card.minX, card.minY + card.height / 2 - 12, card.width, 24));
}
// Washi tape straddling the top edge of the card
const tapeW = Math.min(card.width * 0.36, 120);
const tapeH = 18;
const tapeX = card.minX + (card.width - tapeW) / 2;
const tapeY = card.minY - tapeH / 2;
ctx.setFillColor(new Color("#ff9ebb", 0.55));
fillRounded(ctx, tapeX, tapeY, tapeW, tapeH, 3);
return ctx.getImage();
}
// ── Helpers ─────────────────────────────────────────────────────────────────
function fillRounded(ctx, x, y, w, h, r) {
const p = new Path();
p.addRoundedRect(new Rect(x, y, w, h), r, r);
ctx.addPath(p);
ctx.fillPath();
}
function widgetSize() {
switch (config.widgetFamily) {
case "small": return [158, 158];
case "large": return [338, 354];
default: return [338, 158]; // medium
}
}
function greeting() {
const h = new Date().getHours();
if (h < 5) return { text: "still up?", emoji: "🌙" };
if (h < 12) return { text: "good morning", emoji: "☀️" };
if (h < 17) return { text: "good afternoon", emoji: "🌼" };
if (h < 21) return { text: "good evening", emoji: "🌇" };
return { text: "good night", emoji: "🌙" };
}
SUPABASE_URL to your Supabase Project URL and WEB_APP_URL to wherever you deployed the canvas.khat.The widget refreshes on iOS's own schedule, every few minutes. Tap it any time to open the canvas and see the latest right away.