The hero was a single block of copy with nothing beside it, so a visitor landing
on the page saw no photograph at all until they scrolled. It now splits into
copy + an award card: the highest-rated photos of the current window, one frame
per photo, each labelled for the window it came from.
The frame set is the day's top-rated first, then the week's, both deduped by
photo id, so a photo that is both this day's and this week's best is drawn once
and keeps the day's label — that is the tighter of the two windows and the more
specific claim. Measured on the live data (2026-09-23 UTC, week = ISO Monday
2026-09-21): GET /api/highlights came back with day [] and week a full five
frames (ids 12,13,51,50,3, every one avg 5, n 1). The day window is empty simply
because no rating had landed since 00:00 UTC, and an empty day window must not
empty the column — hence the union rather than a fallback: whatever each window
has, merged, deduped.
Frames change the way the film strip already does, so the card reuses that
machinery rather than inventing a second one: the same .lp-arrow dots and the
same FrameArrows component, which already renders nothing under two frames.
Under two frames the card also keeps still — no arrows and no timer, because a
single frame has nothing to advance to and a timer that swaps a frame for itself
is just a repaint. Five seconds a frame, one second of crossfade: all frames are
stacked in the same box and the active one is the only one at opacity 1, each
transitioning its own opacity over 1s, so the outgoing frame fades out over the
same second the incoming one fades in and the box never flashes empty. Measured
in the built app: mid-step opacities 0.32, 0.68, 0.00, 0.00, 0.00 at the halfway
point of a step, and transitionDuration exactly 1s on every slide. Stepping by
hand restarts that clock instead of letting the old 5s fire on top of the new
frame — an arrow step to 2 then waited 4.2s still sat on that frame, where
without the restart it would have moved on at 5s from the previous frame's
start.
The rating is shown on the frame because it is the whole reason the frame is
there: avg to one decimal, plus the vote count as "1 vote"/"N votes" — one
decent vote and one outstanding vote are not the same window, and the reader
can tell them apart at a glance. Score is mono, bottom-left, over a text
shadow.
Backend side this is one query and one route. topRatedPhotos(since, limit)
joins ratings to photos on CAST(substr(ratings.key, 7) AS INTEGER), since
ratings keys are the strings "photo:<id>"; it filters ratings.key LIKE 'photo:%'
so a look: vote can never award a frame — there is no look to show — and
photos.consent = 1, so a photo pulled from public display is pulled from the
awards with it. Ordering is avg DESC, n DESC, at DESC, id DESC: best average,
then the better-supported average when averages tie, then the freshest, then id
only to make the order total and the frame set stable between requests. avg
comes back rounded to 2dp. GET /api/highlights computes the two windows in UTC
— midnight, and ISO Monday midnight via midnight - ((getUTCDay()+6)%7)*86400000
— and returns { highlights: { day, week } }. HIGHLIGHT_LIMIT is 5.
The column is 340px on the right of the copy, stacking under it below 980px.
Measured on the built app at 1280px: copy ends at 902, card starts at 948, same
hero row, card exactly 340px wide, five slides for five frames, label "Recipe
of the week", score "5.0★1 vote", the two arrows the only .lp-arrow inside
.lp-award, meta #CLASSIC_VIVIDIPES. At 900px the card sits under the copy. With
the window forced to one frame the card draws 0 arrows and 1 slide; with both
windows empty there is no .lp-award and the hero is not split at all, so an
unrated install looks exactly as it did before.
Verified:
award-column-probe.cjs (new, scratchpad) — geometry, arrows, crossfade
opacities, the 5s auto step, the manual step's clock restart, the one-frame
and no-frame windows. 18/18 on http://localhost:8090.
backend npm test — 166 passed, 0 failed, with four new checks in the ratings
section: a vote lands in today's and this week's window, a look: subject is
never an award, and a photo drops off the awards once deleted. The ratings
and photos suites cover the joins the new query leans on.
landing-test.cjs, lp-arrows-test.cjs 33/33, landing-rating-test.cjs 21/21,
landing-photo-guard-test.cjs 21/21 — 0 fail against the built app.
web tsc --noEmit clean. Dark and light themes both eyeballed on the built
app (award-hero-dark.png, award-hero-light.png).
ponytail: the card re-fetches on the page's own reload() rather than polling, so
a rating cast while the tab sits open will not surface until the next reload;
the awards are a landing-page flourish, not a live feed — when they need to be
live, poll the same route on the timer the frames already run. The card's
box-shadow is the dark card's, one rgba(0,0,0,0.35), and reads heavy on the
light theme next to .lp-recipe-card's light-specific shadow; left alone rather
than adding a token for one property.
The ten PHOTO STYLE sims now carry nothing but their stock's own grade, and
each is named for the stock it stands for: PROVIA, VELVIA, CLASSIC CHROME,
CLASSIC VIVID (Velvia spliced with Classic Chrome at the blue row), CLASSIC
NEGATIVE, ASTIA, ETERNA, ACROS, LC STREETLIFE CLASSIC, LC STREETLIFE VIVID.
Grain, clarity, saturation and light moves were dropped from their
`adjustments`, so a sim is a clean starting point and the general knobs read
their defaults while the look still lands on the pixels.
LC STREETLIFE VIVID keeps the one brightness step its stock needs, but as
SIM_EXPOSURE_BIAS in colorUtils rather than as an adjustment: it is folded in
where the Exposure slider applies, so the picture gets the lift and the
parameter stays at 0.
Also in this checkpoint: the watermark/GPS boxes and their colour pickers, the
WATERMARK chip column, the real admin stats, and the fix that stopped presets
from doubling and a frame from refusing to come off when a photo was reopened
(/file is the finished render, /base the editable pixels).
A signed-in account is served exactly like a guest until it opens the
verification link: watermarked 2048px export, no saving, no PRO frames,
GPS stamp or HDF. SMTP is declared in .env; with SMTP_HOST unset the link
goes to the container log. Allowlisted admins count as verified.
The landing strip now carries a score: each look and each contributed
frame shows an average, five stars the visitor can press, and how many
votes it has. Votes are keyed photo:<id> or look:<TAG> and one visitor
has one vote per key, so pressing a second star moves a score instead of
stacking one. The API is public and rate-limited; look: scores survive a
cleared pool, photo: scores are pruned with their photo.
PICTURES was four destination rows; it is now an album per uploader with
a search box, a recipe/rating/newest sort, a minimum-star filter, a big
preview and a filmstrip of thumbnails. Deleting and slot picking still
live in the big box.
A photo's landing section can now be the QR card, and that section is the
only one that hands something out: the server writes the photo's own stored
look back as the app's .recipe file, at
GET /api/photos/:id/preset.recipe, for any row the curator ticked into the
qr slot. Nothing new is stored — the file is built from the recipe the
upload already carried, so it works for a photo uploaded by the phone too.
The admin pane grows a fourth checkbox and a fourth row (QR card); the
row draws the download link as a scannable code, and the box is dead for a
photo with no stored look. The landing's QR card now encodes the curated
photo's own link instead of a mock address. The listing exposes
hasPreset, never the recipe itself.
The picker was one dropdown, so a photo lived in exactly one place. The three
destinations are now independent checkboxes on the card, and the column holds
the set as a comma list — the landing page draws a photo in every section it
was ticked into, each still picking one of its own at random per visit.
Ticking nothing is what `off` used to be: the row is kept and the landing page
stops drawing it, which is what the old "not on the landing page" option did.
Opening one of the folder's own photos and hitting SAVE PHOTO used to
make a second copy of it. Now it replaces that row — same id, same place
— and the look the row carried steps into its history, newest first and
capped at three, because the pixels it described are gone. The frame's
own column in MY PHOTOS lists those looks (click one to put its settings
back on the stage) and carries the landing-page consent as a plain tick,
which answers the click at once. A file from the disk clears the open
id, so a fresh frame still adds one.
One events row per beacon: the kind, the page, the clicked control, a salted
hash of the address (never the address), its coarse place, and the browser,
system and device read off the UA. A new public POST /api/events writes it and
always answers 204; GET /api/admin/stats reads it back as totals, a day series
and one grouped breakdown per dimension, behind the admin gate.
EXPORT no longer burns the caption strip: the pixels stay the photo's own
and the look travels as metadata — ImageDescription (0x010e) for the tag,
UserComment (0x9286, ASCII header) for the recipe JSON.
SAVE PHOTO now stores the look with the frame (photos.recipe) and the
uploader's consent for the community film strip (photos.consent, PATCH
/api/photos/:id for the owner). The landing reel skips non-consented frames,
and a new MY PHOTOS tab lists the account's saves, reopens one with the
settings it was stored with, and carries the two consent switches.
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.
- /admin User account rows gain BLOCK/UNBLOCK, REMOVE/RESTORE and DELETE.
Blocked = cannot sign in (sessions swept), removed = hidden from the strip
and cannot sign in, both reversible; DELETE drops the account with its
photos and recipes and unlinks the files. An allowlisted account is never
a target, so an admin cannot moderate or delete itself.
- Photo uploads move from a 3MB API cap / 4m nginx cap to 12MB / 16m, and
the browser shrinks an oversized still before sending it (2048px JPEG,
avatars 512px) so the declared type still matches the sniffed bytes.
- The studio SAVE leaves the top bar and sits under the CREATE RECIPES tab,
labelled SAVE RECIPES.
- 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)
`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.