Two picks that were drawn as rows of the column rather than as picks of a panel, both of them because of where a slot happened to be rather than what the control is. The pair of Color Chromes was LIGHT's own `chip-row grid` at the foot of the column, under TONE CURVE, where it read as a third strip of the tab and sat far from the two tracks it is the other axis of. D.RANGE was the effects slot, four chips poured into the foot of DETAIL & EFFECTS where they were four more rows of the panel and not the one setting they are. WB now owns the pair. `DevelopPanels` gains a `foot` on a panel — a slot drawn under the rows instead of above them — and the `wb` entry names `wbFoot`, so COLOR CHROME and CHROME BLUE are rendered under COLOR TEMP and TINT, on the tab whose colour they are and against the pair of tracks they are read with. They keep everything else: the `chip-row grid` table layout the WB presets wear, their keys, their OFF/ON labels and their pointer behaviour. The `wb` panel had no slot but its presets and its two rows, so the foot is the whole of what changed there; the four panels that name no foot are untouched, and the slot is looked up per panel (`slots?.[panel.foot]`) so a panel that never asked for one draws nothing. D.RANGE closes DETAIL & EFFECTS inside a box that carries its name. `.dev-box` is a frame in `--border-soft` with a `.dev-box-title` in the small-caps the panel heads use, and the four stops sit in it as one set — which is what they are: a hold on the whole frame, read as a set of stops, not as four more knobs of the effects. The box is the slot's own wrapper, so it holds whichever chips the slot is given and the chips keep their own keys and their amber AUTO. Verified: `npx tsc --noEmit` clean, `npm run build` clean (`dist/assets/index-v5g7p7bS.js`, `dist/assets/index-RYLacibB.css` at 66.29 kB). Against the built page (`127.0.0.1:8090`, 1440x900 desktop and 393x852 phone), LIGHT tab, panels open: - LIGHT's own row is now `∿ TONE CURVE` alone — the pair is out of the column. - WB's children read `[chip-row grid (presets), mini-slider COLOR TEMP, mini-slider TINT, chip-row grid COLOR CHROME OFF / CHROME BLUE OFF]`, and both chromes report `inWb: true` — `COLOR CHROME [94,424,147,37]`, `CHROME BLUE [247,424,147,37]` at 1440 wide, `[8,599,186,37]` and `[200,599,186,37]` at 393 — in the WB body, under the two tracks. - The D.RANGE box reports `data-key="dev-box-dr"`, title `DYNAMIC RANGE`, `[94,754,300,162]` under `effBox [94,569,300,347]` at 1440 wide, border `1px rgb(236,238,241)` (`--border-soft`); the four chips are full-width rows (282px) at y=781/814/847/880, AUTO `aria-pressed=true` in amber `rgb(206,117,9)` on `rgba(206,117,9,0.14)` and DR100/200/400 on white. On the phone the box is `[8,929,377,63]` and the four chips come back to one line at `.chip-row`'s own `@media (max-width:860px)` override, `[17,956,58,27]`, `[81,956,62,27]`, `[149,956,64,27]`, `[219,956,65,27]` — no clipping, nothing over the caption — and `errors: []` on both. Co-authored-by: PenguinHarness <noreply@penguin.local>
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.