With HEAL armed the wheel was the brush's size and nothing else. A repair is aimed at a detail — a scratch, a speck on a face — and the detail is usually smaller than the photo, so the one gesture the stage uses to bring it closer was the one gesture the brush had taken: a user could size the spot they were about to lay and could not zoom the photo they were laying it on. The same layer took the pointer the stage pans with, so a zoomed-in photo could not be moved out of the way either, and it swallowed the double-click the img answers with. A tool that covers the surface has to hand back the gestures it does not use. The wheel over the brush is the stage's own now, exactly as it is everywhere else in the app: one notch at the pointer, the point under the cursor held still, the same arithmetic the wrap's listener has always used (lifted out of that listener as zoomAt, so the two callers cannot drift). The brush's size — the one knob this tool has — is that same wheel with a modifier held: Alt, or Ctrl/Meta, which is also what a trackpad sends for a pinch, so pinching still sizes the spot without reaching for a key. The photo can be moved out from under the brush as well: the middle button, or the space bar held, drags the photo while a tool is up, at a zoom, which is the only place a pan means anything — the listener is on the layer, so the key is watched on the window and read from a ref. A pan drag paints nothing, and the modifier wheel does not touch the zoom: measured, 24.744px -> 29.6239px of brush across while the photo stayed at 1568px. The × a chosen spot wears is now drawn for the screen rather than the layer: it keeps 18px and a 5px gap at any zoom, scaled back out of the layer's own transform. It was scaling with the photo — 18px of badge is 27.9px at ×1.52 — and at that size its corner sat over the circle it belongs to, so a press meant to take hold of a repair landed on the × and deleted it instead. 5px of daylight at the fit and at ×1.52, measured both. The histogram goes off the photo while a brush is up. It is a panel over the photo's top-left corner with the pointer on it, and the corner is where a user paints first: a drag under it painted nothing, and the wheel over it belonged to the panel rather than to the photo. The crop frame already had it hidden for the same reason. ponytail: the pan is bound to the middle button and the space bar rather than to a second pointer, so a trackpad-only user has the zoom (two fingers) and the brush's own size (pinch) but no one-finger pan while a tool is up; a modifier plus drag, or a small hand tool on the toolbar, is the upgrade path. The brush size now lives behind a modifier that nothing on screen advertises — the chip's percentage is the only hint — so a stepper in the brush chip is the next thing to add if that turns out to be a wall. Double-click to zoom is deliberately not wired up: the layer swallows the press, so the first half of the double-click lays a spot, and a zoom that leaves a stray repair behind is worse than no zoom. The middle-button pan only engages past the fit, where the drag is free rather than a scroll, matching what the stage already did. Verified: heal-zoom-drag-probe.cjs 28 PASS / 0 FAIL on :5199 and on :8090 after deploy — the brush arms as a layer, the histogram is gone from the corner and the circle follows the pointer there, the wheel zooms (1031px -> 1185px) and leaves the brush its size (24.744px -> 24.744px), Alt+wheel sizes the brush (24.744 -> 29.62) without moving the zoom, the modified wheel over the brush sizes it and leaves the window's own frame alone (1440 CSS px, dpr 1, either side of the notch, so the modified notch is not handed on to the browser's page zoom), a plain drag paints 0 -> 7 spots, the middle button moves the photo 215,34 -> 269,66 without painting, space+drag moves it 269,66 -> 179,6 without painting, and taking hold of a repair moves it from the centre, from 0.8 inside its edge and from its other side at both the fit and ×1.52; the × has 5.0px of daylight and is 18px across at both zooms; 0 console errors. brush-edit-probe.cjs 33/0, heal-blotch-lab 12, heal-edge-lab 9, heal-seam-lab 10, heal-skia-lab 28, heal-search-lab 15, heal-probe 49, heal-zoom-geom 5, heal-zoom-probe 8, mosaic-skia-lab 27, mosaic-probe 51 — all green, and heal-probe.cjs and mosaic-probe.cjs had their wheelTo helper moved to Alt+wheel because the plain wheel now zooms the photo, which is the contract they were testing. Regression on :8090: landing-test 172/0, pro-gate-test 27/0, award-column-probe 18/0, otp-code-probe 10/0, tone-curve-probe 42/0, rc=0. Backend npm test 180 passed, 0 failed. npx tsc --noEmit clean.
RecipesCam web — self-contained stack
A Docker-hosted web build of RecipesCam. Everything it needs is in this folder: move it to another machine, run two commands, and the app is up. It does not need the React Native project around it.
cp .env.example .env
docker compose up -d --build
# → http://localhost:8090
What runs where
| Service | Image | Role |
|---|---|---|
frontend |
nginx:1.27-alpine (built by frontend/Dockerfile) |
Static SPA + /api/ reverse proxy |
api |
node:22-slim (built by backend/Dockerfile) |
Accounts + saved recipes, SQLite on ./data |
frontend resolves api through Docker's embedded DNS and proxies /api/* to
it — that is why the API container is named api and why it is not published on
the host. Only ${WEB_PORT:-8090} is exposed.
Photos never leave the browser. The CanvasKit render pipeline (grade, frame, watermarks, JPEG encode) runs in the visitor's tab; the API only stores recipes as JSON.
Layout
docker-compose.yml the stack
.env.example WEB_PORT
data/ SQLite (created on first run, gitignored)
backend/ Fastify + better-sqlite3 API, own Dockerfile
frontend/ Vite + React + CanvasKit SPA, own Dockerfile + nginx.conf
shared/ vendored copies of the app's types + utils (see below)
src/engine/ skiaShim.ts (CanvasKit) + exportEngine.ts (render pipeline)
+ session.ts (localStorage/IndexedDB studio persistence)
Vendored files
frontend/shared/{types/index.ts,utils/*.ts} are byte-identical copies of
src/types/index.ts and ten src/utils/*.ts files from the React Native
project (@shopify/react-native-skia is aliased to src/engine/skiaShim.ts in
vite.config.ts + tsconfig.json, so those files compile unchanged):
cinemaShader colorUtils defaultRecipes exifWrite frameUtils jpegDpi
paramDefs recipeShare skiaImage toneShader.
The landing page needs no CDN: frontend/public/assets/fonts/*.woff2 are the
seven self-hosted faces behind the three font groups the Themes menu offers
(Plus Jakarta Sans / Inter / JetBrains Mono, Fraunces / Be Vietnam Pro /
Courier Prime, Be Vietnam Pro / Space Mono — all SIL OFL, pulled from Google
Fonts, vietnamese + latin + latin-ext subsets), and
frontend/public/assets/samples/s*.jpg are the six placeholder negatives the
film strip, preset tester and QR card show (swap them for real graded stills
whenever we have them). Its one foreign request is the QR image from
api.qrserver.com, which degrades to an empty slot offline.
When the app changes one of them, copy it back in — the renderer is only "parity" for as long as these stay in sync:
cd <repo>/docker/frontend/shared/utils
cp <repo>/src/utils/<name>.ts .
Operations
docker compose logs -f api # API log
docker compose restart api # after backend/src changes (rebuild: --build)
docker compose down # stop; ./data survives
Backup is the ./data folder — that is the whole database.
Checks
curl -s http://localhost:8090/api/health # {"ok":true}
curl -sI http://localhost:8090/ # 200, index.html
Then open the UI, drop a photo in, and confirm the preview shows the picture and
EXPORT downloads a JPEG that opens. The preview going solid black while the
export still reports a plausible size is the one failure mode worth knowing: it
means the CanvasKit GPU surfaces lost their shared GrDirectContext (see
frontend/src/engine/skiaShim.ts), and with no GPU the raster fallback renders
the same pipeline correctly, just slower.