
I wanted to sketch a database schema last month and the first three whiteboards I opened asked me to sign in. So I wrote my own. While I was at it I decided the no-upload thing shouldn't be a line in a privacy policy, because you can't check a privacy policy.
It's at board.jamuny.com. Below is what I hit building it.
One CSP directive does most of the work
The page ships with this header:
Content-Security-Policy: default-src 'self'; script-src 'self';
img-src 'self' blob: data:; font-src 'self'; connect-src 'none';
object-src 'none'; form-action 'none'; frame-ancestors 'none'
connect-src 'none' blocks fetch, XMLHttpRequest, WebSocket, EventSource and sendBeacon at the browser level. If I got sloppy in six months and pasted in an analytics snippet, it wouldn't quietly start working. The browser would refuse the connection and log a policy violation.
You can check this yourself in about thirty seconds: open devtools, draw a few shapes, watch the network panel stay empty.
There's a Playwright test that fails if it ever stops being true:
test('draws, switches boards and reloads without a single request', async ({ page }) => {
const requests: string[] = [];
page.on('request', (r) => requests.push(r.url()));
page.on('websocket', (ws) => requests.push(ws.url()));
// draw, switch boards, reload, export PNG and SVG
expect(requests.filter(isNotOwnAsset)).toEqual([]);
});
One thing worth knowing if you write a test like this: page.on('request') doesn't fire for WebSocket handshakes. I had the request listener alone for a while, and an actual WebSocket connection would have gone straight past it. You need page.on('websocket') too.
The obvious cost is that there's no collaboration and there won't be. Multiplayer needs a server, a server means the strokes leave your machine, and then I've built the thing I was trying to avoid.
No framework, for fairly boring reasons
Astro emitting static HTML, plus a couple of <script type="module"> islands. No React, no Svelte.
This isn't asceticism about bundle size. A framework's job is reconciling a component tree against the DOM, and the DOM here is one <canvas> element that never changes. I'd be paying for hydration to manage a toolbar. Script weight sits around 23KB transferred, with a Lighthouse assertion that fails the build if it creeps up.
There are two canvases stacked, actually. One holds the committed scene, the other holds whatever stroke you're currently drawing. Repaint the whole board on every pointermove and it gets sluggish once there are a few hundred elements on it, so the overlay keeps an in-progress stroke down to one shape's worth of drawing.
roughjs draws a rectangle as four separate lines
The board gives you Clean or Sketch per shape. Sketch runs through roughjs for the hand-drawn wobble, and Clean originally did too, with roughness dialled to zero.
Then I got a bug report saying the rectangles looked like "a line connecting four edges."
I dumped the ops roughjs was producing for a rectangle:
["move", "bcurveTo", "move", "bcurveTo", "move", "bcurveTo", "move", "bcurveTo"]
Four independent subpaths, one per edge. That's correct for a sketchy look, since each edge is meant to read as its own pencil stroke. The consequence is that there's no line join anywhere, because as far as the canvas API is concerned there's no corner, just four lines that happen to end near each other. At stroke width 2 nobody notices. At width 8 there's a chunk missing from every corner.
Clean mode draws with plain canvas calls now (ctx.rect(), ctx.ellipse(), moveTo/lineTo), one beginPath/stroke per shape so lineJoin applies. Sketch still uses roughjs, where none of this was ever a problem.
There's a related one I only caught by opening an exported PNG. I was stroking an arrow's shaft and its head as a single path, so ctx.setLineDash() applied to both. A dashed arrow came out with the head chopped into two or three dash fragments, and a dotted one barely had a head at all. On screen at normal zoom it reads as a slightly scruffy arrowhead, which is why I'd been looking at it for days without registering it. At 2x in an export it's obvious.
Fixing it meant touching three places that all have to agree: the canvas renderer, the SVG exporter and the roughjs path. The roughjs one was the annoying bit. A Drawable's options (including strokeLineDash) are shared across every opset inside it, so a dashed shaft and a solid head can't be one Drawable however you build the path string. It has to return two.
The style panel spent two revisions in the wrong place
It started in the top toolbar. That row ended up carrying the tools plus seven stroke colours, five fills, four widths, Clean/Sketch, and Solid/Dashed/Dotted. Around twenty controls, which clipped off the right edge on a normal laptop window. You'd get Clean | Sketch | Solid | D⦠and everything past that was gone.
So I floated the panel over the canvas's top-left corner. That corner then stopped accepting pointer events, so you couldn't draw there. Nobody files a bug about this. They just feel like the app is slightly broken and don't come back.
What shipped is a docked rail: a real flex column next to the canvas, with the canvas at flex: 1; min-width: 0. Every canvas pixel is drawable because of how the layout is built, rather than because someone remembered to leave a gap.
That created a layout shift problem, since the rail's width depends on what's selected. The fix was to stop doing it in JavaScript:
.rail:has(> #style-panel:not([hidden])),
.rail:has(> #app-menu:not([hidden])) { width: 213px; }
The script only toggles the hidden attribute it was already toggling. The static markup ships in the same expanded state the script lands on a moment later, so there's no frame where the canvas box jumps. CLS measures 0.0000.
One more that took me a while to see. Clicking an unselected shape starts a drag and populates the selection in the same pointerdown. The next frame widens the rail, which shifts the canvas out from under a drag whose anchor was captured against the old edge, so the shape jumps sideways by the width of the rail while your finger hasn't moved. The rail is frozen for the duration of any live gesture now.
I'd been using the wrong contrast standard
The stroke palette started life as the syntax colours from another tool of mine, which are held to WCAG's 4.5:1 because they're text. Carried over to drawing strokes, that made everything dark and slightly muddy. The feedback I got was that the colours all looked pale, which was fair.
4.5:1 is the figure for text. WCAG 1.4.11 asks 3:1 of non-text content, which is what a drawing stroke is. Moving to the correct bar is what made a bright palette possible at all, and I measured every value rather than relaxing the test until it passed.
Two things went wrong doing it. First, white isn't the only background a stroke sits on, since you can draw on top of a filled shape. My new lime cleared 3:1 against the canvas and came in at 2.67 against the blue fill tint.
The second cost me a deploy. My selection colour was resolved like this:
STROKE_COLORS.find((s) => s.id === 'blue')!.value
The palette rewrite renamed blue to sky. find returned undefined, the ! kept TypeScript quiet, and .value threw a TypeError at module load, so the whole island stopped booting. All 737 unit tests stayed green, because nothing imports that constant. What caught it was a Lighthouse assertion on errors-in-console, which is the only gate I have that loads the real page in a real browser.
Where it is now
Pen, rectangle, ellipse, line, arrow and text. Select, move, resize, delete. Clean or Sketch per element, solid or dashed or dotted. Boards as tabs, stored in IndexedDB. Undo and redo per board. Infinite canvas with pan and zoom. PNG and SVG export.
There's no account system, no sync, and no image tool yet.
If you'd rather verify than believe me: devtools, draw something, watch the network panel.
Built on perfect-freehand (MIT), roughjs (MIT) and idb (ISC).
Top comments (1)
This is a nice example of browser security being part of the product design, not just an obstacle. When an app cannot silently upload what the user drew, the workflow has to make consent visible. That friction can be annoying, but it also gives the user a real boundary to understand.