HEAL draws every repair it holds as the circle the shader fills — the brush's own size at the moment it was laid — so a photo with two repairs has two rings and a photo with twenty has twenty. Each one is loud enough to be the loudest thing on the picture, and none of them is the one the user is looking for: the speck that was mended, and the patch borrowed to mend it, are both inside the ring that covers them. What the ring is for is the spot that is about to be taken hold of, and there is only ever one of those. A repair now wears its circle only while it is the spot in hand — the one the pointer is over, the one just laid, which is the one the user is watching, or the one a press chose, which is the one wearing the ×. The moment the pointer walks elsewhere the ring goes, and what is left is the pair the renderer works with: the patch it borrowed, still dashed, and a soft print of the place it mended. The ring comes back under the pointer, which is where the spot is taken hold of again; the grab was always the geometry — the circle plus a few pixels of slop — and never depended on the ring being drawn, so an idle spot is as easy to move as a ringed one (measured: a 60,40px drag on an idle spot moved it 970.2,390.7 -> 1030.3,430.7). The run a stroke left is the mark of that stroke, so the spots the band stands for keep their boxes and give up their own edges as before, and no print is laid under the band: a row of blurred discs under one translucent band is a second, blurrier band. The one spot of the run that is in hand wears its circle again over the band, because that is the spot a press would take hold of. MOSAIC keeps its rings. Its spots are not repairs to be placed and moved — the circle is the only thing that says where the cover is, and the cover is the edit. ponytail: the print is a blurred translucent disc (14% grey, a soft dark shadow, 1px of blur) rather than a tint read off the pixels under it, so it reads on a photo of any tone without a second pass over the render — the upgrade path, if a print that sits on the repaired pixels themselves is wanted, is the shader the repair already runs through. The ring under the pointer is reported from the pointer's own move events rather than from a hit test per frame, so the one case it does not cover is the pointer that has not moved since the repair landed; that case is covered by the spot just laid being in hand, and a twitch of the mouse covers it everywhere else. The idle/hover split is HEAL's only: MOSAIC's spots keep the border they always had, which is the asymmetry this tool set already had about moving them. Verified: heal-idle-probe.cjs (new, 23 checks) 23 PASS / 0 FAIL on :5199 and on :8090 after deploy — a press on the speck lays one repair and that repair is in hand, so it wears its circle (rgba(255,255,255,0.85)); a repair the pointer has left is idle with the ring given up (rgba(0,0,0,0)), the print of the place it mended left (rgba(127,127,127,0.14)) and the patch it borrowed still dashed and in the same place (640.3,297.9 vs 640.4,297.9); the ring comes back under the pointer; a press chooses it, puts its × on the photo and the × stays while the pointer walks off; laying the next repair takes the choice and the × off the first and gives its ring up; hovering the second leaves the first idle; a drag still takes hold of an idle spot; a run of 13 spots shows the band, no print under it and no ring while the pointer is away, and the spot of the run under the pointer wears its circle again; a MOSAIC spot keeps its ring; 0 page errors. brush-edit-probe.cjs 33/0 — its "every spot of the run gave up its own edge" now reads "but the one in hand", which is the rule this commit adds, and its "a spot with no neighbour keeps its own circle" passes off the repair just laid being in hand. heal-zoom-drag-probe 28/0 (the wheel, the pan, the × and taking hold of a repair, at the fit and at x1.52, unchanged), 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 on :8090. Regression: 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.