- boundary: src/ui/ErrorBoundary.tsx, around the whole app rather than around a
screen. A throw on the way to the first paint used to be a white page with
nothing in it to report; it is a sentence, the message and the stack now, with
a reload beside them, so the next report of a crash carries what went wrong.
- one window: src/ui/SingleTab.tsx holds the app to one tab over localStorage and
a BroadcastChannel. A second tab on /app or /library is told so and offered the
roll — "use this tab" takes it, and the tab that held it gets a bar and the
offer back. A tab holding a reading is a tab the machine has already spent its
memory on, and a second one beside it is the memory the first will not get.
- mount: the studio and the library are mounted the first time their route is
asked for and stay mounted after that, not on every visit. Booting CanvasKit
(~20MB) and reading the whole catalogue on a page that shows neither is the
read and the memory a machine short of either takes for a crash; a trip back
from the studio is a screen opening rather than a page loading.
- reading: READ_MS bounds one touch of the disk at 30s. getFile and arrayBuffer
on a drive that has gone away do not fail, they never settle — the lane waits
for ever, the reading never ends, and STOP is a button with nothing left to
read it. A frame given up on is a frame the next reading takes up again.
- reading: the rows of a walk go down a pass at a time, so a tab that never comes
back leaves the column with the folders this reading walked rather than the ones
the last reading did.
- reading: a stopped reading hands over the numbers it counted instead of null.
Handed null it threw its own count away and had noughts written for every folder
it walked that the file did not already hold, so a folder added to a drive read
nought until the next scan.
- repair: STOP ends the afternoon. A repair is a loop the button has no handle on,
so it holds the count of stops it started at and compares between rolls, and
says it was cut short rather than reporting a run it did not make.
- add: a folder the browser could not name — every drive root on Windows is "\" —
is asked for one, and a folder whose name is already another folder's is refused
rather than folded in with it. The record, the frame ids, the walk's position
and the number the column draws are all keyed by that name.
- wall: four hundred tiles queued ahead is a reader waiting on frames three
screens below the one they are looking at; sixty is a screenful and the wall
mints the rest as it scrolls. The catalogue is read back every 30s, not every
5s — reading it back costs every thumbnail in it.
- library: the note is drawn under the frame rather than above it. A line that
comes and goes in the flow moved the frame, the column and the strip by its own
height every time a scan started or ended.
- manifest: "id": "/", so the installed app and the tab are one app.
- checks: tree-scan-check waited for a count of exactly four rows — a state the
screen does not have. The wait ran out its clock and then measured whatever the
reading had done since; it waits for the whole tree now.
- scan: rows go down on a clock as well as on a full batch. The reader's folder
of 26 RAWs is under one batch of 50, so nothing of it reached the screen until
the reading was over — a wall that stood empty for the whole scan and then
filled in one go. Frames now reach the screen every 400ms while the roll is
still being read, so the count climbs and the first tiles are drawn as the
frames land.
- tiles: a HEIC or a RAW has its tile written the first time it is drawn, so
every later visit reads the tile back instead of the file.
- resume: a reading a reload cut off is offered by the next visit with a chip
instead of being run behind the reader's back.
- offline listing: the desk's listing is kept whole in IndexedDB under
`recipescam-offline`/`reads`, past the 200 000 characters localStorage held,
so an offline visit opens on the listing it kept instead of on nothing.
- offline writes: a vote or a view cast with no server is held and goes out when
one answers again, with a bar that says how many are waiting.
- sw: the shell precaches its own bundle, stylesheet and icon, read out of the
document — a worker that precached only the document answered an offline
launch with a page whose JavaScript the machine did not have.
- checks in docker/frontend/scripts: tree-scan-check (counts while reading,
resume after a reload, tiles of a long roll), offline-read-check,
offline-queue-check.
Two holes left by the last change. A frame is opened with `window.location.href`
rather than a link, so the studio was still handed a fresh page and the scan with
it — every route out of the catalogue now goes through `go()`, which pushes the
address instead of reloading while a scan is in flight. And a row counted what was
filed away rather than what had been read, so it sat at zero for the whole of a
first scan: the catalogue's frames only reach IndexedDB in one batch at the end.
`ScanProgress.counts` carries the frames the scan has reached, per row, off the
scan's own bookkeeping, and the column reads it while the scan runs — the row
under the reader's eye moves as the roll is read, and the toolbar's line stays the
whole picture. A re-read counts from the start, which is what it is doing.
The regression check grows the two: a frame opened mid-scan from the catalogue
(of a roll it already holds) has to stay in the page, and the row has to count.
The catalogue hands the visitor to the studio with a plain `<a href>`, and the
app has no router: following it threw the page away, so a scan that was halfway
through a roll died with it. A scan belongs to the tab, not to the screen that
started it.
The scan now lives outside the component (`scanSession`/`startScan`/`stopScan`,
watched by whoever is up), so the catalogue can unmount and come back to a
reading that never stopped; a screen that returns joins it and sees the toolbar
line, the stop button and the rows filling in. The internal links are taken over
for a history push *only while a scan is in flight* — everywhere else the
browser navigates exactly as before, so the landing-to-studio flow is untouched.
`scripts/scan-nav-check.mjs` is the regression: a roll read one frame at a time
off a slow server, handed to the studio mid-scan, back to the catalogue, and the
whole roll read to the end with the studio up, counting documents along the way.
The three things a browser asks for, without a plugin: a manifest in
public/ (name, /app as the start, three icons cut from the one piece of
art this repo has), a service worker, and the two metas iOS reads
instead of the manifest.
The worker caches the shell — /, /app, /library, all one document under
the SPA fallback — and the hashed assets the build emits. A navigation
is network-first, so a deploy is never pinned behind the cache; a
hashed asset or the wasm is cache-first, because under a given build
those never change. /api and any non-GET go straight out: a worker is a
cache, not a proxy. nginx serves sw.js and manifest.json `no-cache`
(both names outlive their contents) with the isolation headers the
worker script needs under COEP.
The offer is the app's own dialog, not Chromium's mini-infobar: the
event is held, and it is spent either after the visitor has been in the
studio two minutes or the moment an export lands — the point at which
the app has done their work. Safari never fires the event, so it gets
the Share > Add to Home Screen line instead. A refusal is remembered and
never asked again.
node scripts/make-icons.mjs 192x192 39785B / 512x512 159296B / maskable 512x512 123723B
node scripts/pwa-check.mjs manifest 3 icons · worker activated · shell cached
· offline reload of /app paints
off (https://localhost:8090) same four, through nginx
installTracking() beacons one view per page load and one click per control that
carries a data-key, so every existing button is already counted. The new STATS
pane reads it back: a 7/30/90-day range, the three totals, an SVG timeline and
eight proportional bar lists (pages, clicked features, country, region, city,
browser, system, device).
Every member gets /photos — their own uploads, counted against a 12-photo
cap, each card showing the tagline and the technical line the studio would
print. The studio gains SAVE PHOTO n/12 in the top bar: it renders the full
resolution look, stores the strip (tag/title/meta) with the upload so the
landing reel frames it the same way, and refuses past the cap.
EXPORT now burns that strip into the file: the amber #TAG over the photo's
top-left plus a dark caption band below carrying the recipe name and the
ISO / grain / warmth line. The live preview stays clean, and the saved
upload stays clean too — the reel draws its own frame from the stored
labels, so a burned band would tag the tag twice.
Admins manage any photo through DELETE /api/photos/:id; members only their
own. The users table's photo counts stay in step with the folder.
- an account can carry a picture: POST /api/auth/avatar (raw bytes,
sniffed, replaces and unlinks the old file) and the public
GET /api/users/:id/avatar. It rides wherever the account is named —
the landing chip, the studio TopBar, the profile form.
- new /profile page for members, sharing one Profile form (picture,
email, password) with the admin drawer.
- /admin is now one bordered frame whose left column is
Profile / User account / Pictures / Close. Pictures lists every
photo in the system with the slot that shows it; User account lists
each account's name, email, picture and contribution count.
- account control opens a menu: Admin page + Log out for an admin,
Profile + Log out for a member.
Backend
- photos table + upload storage under DATA_DIR/uploads (magic-byte sniffing,
no multipart dep, SVG rejected, wx exclusive writes)
- POST/GET /api/photos, GET /api/photos/:id/file with nosniff + sandboxed CSP
- admin routes (ADMIN_EMAILS allowlist): list, delete one, clear all
- identity-keyed rate limits (login 20/15m, signup 5/h, upload 60/h)
- cookie gains Secure when the request is https (via trustProxy)
- /api/auth/me now 200 {user:null} instead of 401 when signed out
Frontend
- landing strip section: signed-in users upload straight from the reel,
guests get a /app?auth=1 link
- /admin page: grid of uploads with delete + clear all
- nginx: nosniff / X-Frame-Options / Referrer-Policy, forward
X-Forwarded-Proto so the API can mark cookies Secure behind TLS
Tests: docker/backend test/security.mjs (45 checks)
The theme menu now carries a fourth choice beside light/dark and the accent:
the font pairing. All three pairings are free for commercial use (SIL OFL),
have a Vietnamese subset, and are self-hosted — the landing still makes no
CDN request.
- styles/fonts.css: 22 @font-face blocks for Plus Jakarta Sans, Inter,
JetBrains Mono, Fraunces, Be Vietnam Pro, Courier Prime and Space Mono,
vietnamese/latin-ext/latin subsets only, under public/assets/fonts.
- styles/tokens.css: [data-fonts="studio|editorial|native"] sets --font,
--font-heading and --mono. Studio (Plus Jakarta Sans + Inter + JetBrains
Mono) is the default.
- theme/: FontSetId + FONT_SETS, persisted as rc.fonts, applied as
<html data-fonts> next to data-theme and data-accent.
- TopBar and the landing nav both get the picker; on the landing the theme
tool now opens a small popover (light/dark + font group) instead of
toggling on click.
- landing.css: --lp-display/--lp-mono now resolve to the chosen group, so the
picker retypes the whole page. Syne.woff2 goes with its @font-face.
Ten dark cinematic sections: fixed glass nav with a hamburger sheet, hero
with the badge/dual CTA/stats bar, a pausable 35mm film-strip marquee, the
live preset tester (5 stocks, HUD, spec bars), the three-knob custom recipe
simulator, the 6-card feature grid, the QR sharing showcase, free-vs-Pro
pricing, reviews + FAQ accordion and the final CTA/footer.
The page owns its palette and its .lp-* styles, so it renders the same in
either workspace theme, and it pulls no CDN: Syne is bundled and the six
sample negatives are vendored (see docker/README.md). Store buttons raise a
toast instead of the old alert(). The now-dead landing CSS and the unused
land.* dictionary keys are gone.
`docker/` now holds the whole web build — frontend (Vite + React + CanvasKit),
backend (Fastify + SQLite) and the compose file — so the folder can be moved to
another machine and run without the React Native project:
cd docker && cp .env.example .env && docker compose up -d --build
Only `${WEB_PORT:-8090}` is published; nginx serves the SPA and proxies /api to
the `api` container over Docker's DNS. Photos never reach the server.
The shared render code is vendored into `docker/frontend/shared/` and aliased to
a CanvasKit shim, so the app's own frameUtils/toneShader/jpegDpi run unchanged.
Fix the all-black render on GPU surfaces: `MakeWebGLCanvasSurface` creates a
separate WebGL context per call, and a texture from one context cannot be
sampled by a surface on another — so any pass that drew a snapshot onto a second
surface (output sharpen, screen sharpen, polaroid/wallframe cards) came out
solid black, while the raster fallback was correct. Use one shared
GrDirectContext + MakeRenderTarget instead.
Verified in headless Chromium against the running stack: 12MP JPEG in, preview
mean=120.5 sd=60.5, export 2048x1536 mean=107.2 sd=62.1, JFIF density 300/300,
EXIF present, no console errors; health/signup/login/me/recipes all 2xx through
the nginx proxy.