Gallery hero strip (“All renders”)
Gallery shot detail → Images tab → “All renders” is a static list of thumbnails with app tick boxes (SelectionTickButton). One take is the hero still on disk. This page records the contract so we stop re-breaking it.
User-visible behaviour
| Rule | Detail |
|---|---|
| Fixed list | Tile count and order are fixed for the session on that shot. Picking a new hero does not add tiles or reorder them. |
| Tick only moves | Only the green tick moves between tiles. Thumbnails do not swap URLs when the hero changes. |
| Any tick always works | User can pick A → B → A. Ticks are never disabled because they were selected before. |
| Tick never jumps back | If saving to disk fails, show an error — do not revert the tick to the previous slot. |
| Preview | Click the image (not the tick) to preview above; tick sets hero on disk. |
| No duplicate pixels | Hero + byte-identical candidate collapse to one tile (same SHA-256). |
Implementation map
| Piece | Path |
|---|---|
| UI | src/components/GalleryStillTakesStrip.tsx |
| Thumbnail + tick | GalleryRenderStripTile, SelectionTickTileCorner in SelectionTickButton.tsx (top-left tick, top-right type badge) |
| Strip list (dedupe, order) | listGalleryStripTakes, dedupeStripTakesByContent, initialHeroStripIndex in src/lib/still-take-strip-order.ts |
| Thumbnail bytes for save | readGalleryStripSlotBytes, bytesToBase64 in src/lib/gallery-strip-slot-bytes.ts |
| Hero write API | POST /api/project-still-lock — prefers imageBase64 from the thumbnail; optional sourceUrl for other callers |
| Parent shell | GalleryInlinePreview in src/components/ShotsGalleryView.tsx — key={shot.id} on strip; do not refetch project-image-index on every hero change |
Invariants (do not break)
- Snapshot once per shot —
snapshottedForShotref; never rebuildslotsfrom a live index after the user starts ticking. - Never
setSelectedIndex(prev)on lock failure — selection is UI truth; disk catch-up is best-effort with an error message. - Never disable the tick when
selected—SelectionTickButtonmust not passdisabled={selected};busyshows a spinner only. - Do not gate strip mount on
stillGroup— mount withkey={shot.id}so a brief index null does not remount and resnapshot with extra archive files. - Do not refetch image index on
onStillLocked— refresh shot-assets row only (onAssetRefresh); index refetch grows the candidate list and caused “extra image” bugs. - Do not rewrite slot URLs after lock — never set
url/sourceUrltosavedUrl(shared hero path); that made every old tile show the current hero image. - Lock uses thumbnail pixels —
imageBase64fromreadGalleryStripSlotBytesso stale paths still work when the browser shows the frame. - Dedupe by
content_sha256— uselistGalleryStripTakes, not rawlistSavedTakesByRenderOrder, for the strip snapshot.
Anti-patterns we already hit
- Rebuilding the strip from
project-image-indexafter each lock (archives add rows → count 6→7→10). - Deleting candidate files on promote then locking by frozen path (“Could not read source image”).
- Optimistic URL swap to
/generated/<shot>.jpgon the promoted slot (duplicate-looking hero on every tile). - Custom tick
disabledwhen selected (felt “can’t select again”). stillIndexTick+ refetch in parent (full strip flash).
Tests
src/lib/still-take-strip-order.test.ts—listGalleryStripTakes,dedupeStripTakesByContent,initialHeroStripIndex.
Selection tick placement (app-wide)
All checkboxes and tile ticks use AppCheckbox / SelectionTickTileCorner from SelectionTickButton.tsx — selected = solid bg-emerald-600, no border. Tile ticks sit top-left (left-1.5 top-1.5). Type labels (Hero, Take, Clip) use TILE_TYPE_BADGE_CLASS at top-right. Do not use native <input type="checkbox"> or custom bordered/violet tick styles in app UI.
Related
- User guide: Gallery
- Shot library — different UI (browse/pick), same tick component family
- API Routes —
project-still-lock
