From 12be188ada21ca50653d2b7150b7a75550d61605 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 27 May 2026 20:59:58 -0400 Subject: [PATCH] feat(showcase): IR-parity R-key shuffle + stagger entry animation; fix(cleanup): min-dim Delete swallowed crypto.subtle TypeError on plain HTTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **showcase R-key + entry animation** Restores two behaviors lost during the FC-2 IR→Vue port. Operator-flagged 2026-05-27. - ShowcaseView listens for keydown 'r'/'R' on window. Triggers `store.shuffle()`. Skips when an input/textarea/contenteditable is focused or a Vuetify overlay is open (the dialog/menu sets `.v-overlay--active` on the body). - MasonryGrid gains an opt-in `animateFromIndex` prop (default `Number.POSITIVE_INFINITY` = off). When set, items with index ≥ the threshold animate in with a stagger fade-in: 12px translateY, 0.25s ease, 60ms per item, capped by `prefers-reduced-motion`. Stagger uses original-items-array index (resolved via an `idxById` Map) so the reading order is preserved even after the masonry distributes items across columns. - ShowcaseView watches `store.images.length`: shrink-or-zero baseline ⇒ `animateFromIndex=0` (animate everything on initial load / shuffle); grow ⇒ baseline=prevCount (animate only the appended tail on infinite-scroll). Other MasonryGrid consumers (ArtistView's Gallery tab) don't pass the prop, so they keep their current no-animation behavior. Direct port of IR's `app/static/js/showcase.js` keyboard handler + `app/static/style.css` itemFadeIn keyframe. **min-dim Delete: crypto.subtle TypeError fix** The Delete button on the Cleanup → Minimum Dimensions card was silently no-op'ing. Root cause: `crypto.subtle` is Secure-Context-gated (undefined on plain-HTTP origins per the homelab posture). The card's `onDeleteClick` computed the Tier-C confirm token via `crypto.subtle.digest('SHA-256', ...)`, which threw TypeError before `showModal.value = true`. The promise rejected, the click handler had no `.catch`, the modal never opened — exactly the operator's reported symptom. Same shape as the v26.05.26.0 `navigator.clipboard` fix on the ErrorDetailModal Copy button. Fix: backend `/api/cleanup/min-dimension/preview` now returns `confirm_token` (the canonical `delete-min-dim-` string) in its response. Frontend reads it from the preview response and feeds the 8-char suffix to DestructiveConfirmModal's `runId` prop — no client-side crypto needed. Single source of truth. Integration test `test_min_dimension_preview_returns_count` pinned to also assert `body["confirm_token"]` matches the server-side compute. --- backend/app/api/cleanup.py | 7 +++ .../components/cleanup/MinDimensionCard.vue | 32 +++++++----- .../src/components/discovery/MasonryGrid.vue | 51 ++++++++++++++++++- frontend/src/views/ShowcaseView.vue | 44 ++++++++++++++-- tests/test_api_cleanup.py | 4 ++ 5 files changed, 118 insertions(+), 20 deletions(-) diff --git a/backend/app/api/cleanup.py b/backend/app/api/cleanup.py index 386c3e5..7149525 100644 --- a/backend/app/api/cleanup.py +++ b/backend/app/api/cleanup.py @@ -81,6 +81,13 @@ async def min_dim_preview(): s, min_width=min_w, min_height=min_h, ) ) + # Hand the canonical Tier-C delete token back with the preview so + # the frontend doesn't have to recompute SHA-256 client-side. + # window.crypto.subtle is Secure-Context-gated and undefined on + # plain-HTTP origins (homelab posture); without this the Delete + # button silently swallowed the TypeError and never opened the + # confirm modal. Operator-flagged 2026-05-27. + projection["confirm_token"] = _min_dim_token(min_w, min_h) return jsonify(projection) diff --git a/frontend/src/components/cleanup/MinDimensionCard.vue b/frontend/src/components/cleanup/MinDimensionCard.vue index a26ae2d..b1a1e9b 100644 --- a/frontend/src/components/cleanup/MinDimensionCard.vue +++ b/frontend/src/components/cleanup/MinDimensionCard.vue @@ -52,7 +52,7 @@ v-model="showModal" action="delete" kind="min-dim" - :run-id="tokenSha8" + :run-id="tokenSuffix" tier="C" :projected-counts="projectedCounts" :description="`Width < ${minW} OR height < ${minH}`" @@ -62,7 +62,7 @@ diff --git a/tests/test_api_cleanup.py b/tests/test_api_cleanup.py index ba9534f..35478a4 100644 --- a/tests/test_api_cleanup.py +++ b/tests/test_api_cleanup.py @@ -66,6 +66,10 @@ async def test_min_dimension_preview_returns_count(client, db, tmp_path): assert resp.status_code == 200 body = await resp.get_json() assert body["count"] == 1 + # Preview hands the canonical Tier-C confirm token back so the + # frontend doesn't have to recompute SHA-256 client-side + # (crypto.subtle is Secure-Context-gated; FC runs over plain HTTP). + assert body["confirm_token"] == _sha256_min_dim_token(200, 200) @pytest.mark.asyncio