You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A signed-in collection owner has no reachable way to delete a record, and the one hidden path that does work describes its own blast radius incorrectly — it implies the price guide entry, artist, and label are destroyed along with the record. They aren't.
Scope: hard delete of records, and the confirmation UX around it.
Core design principle: deletion friction should scale with what is actually destroyed. A record with no media is disposable metadata; a record with photographs and audio represents real, unrecoverable work. The UI should treat those two cases completely differently — and it should show the user which one they're in rather than describing it.
⚠️Blocked by #385. Deletion is currently hard to reach, which makes the missing dependent: :destroy cascade a latent bug. Making delete easy before that fix lands would manufacture an orphan backlog that doesn't currently exist.
Action: app/controllers/admin/records_controller.rb:151 — scoped to user_id: 1, rescues ActiveRecord::InvalidForeignKey, redirects with a notice.
UI: app/views/admin/records/show.html.erb:257-277 — a "Danger Zone" <details> block, collapsed by default, at the very bottom of the admin record page.
2. Public destroy — dead code
app/controllers/records_controller.rb:125 defines #destroy with require_user!, require_collection_owner!, and set_record…
…but config/routes.rb:59 is resources :records, except: [:destroy]. There is no DELETE /records/:id route. The action has never been reachable.
3. Attachment-level delete (works, separate concern)
Admin::RecordsController#destroy_attachment (:201) removes individual images/audio via signed blob id, properly scoped. This one is fine — and it becomes the escape hatch in the flow below.
4. Blast radius — the good news
Recordbelongs_to its price / artist / label / genre / format / discogs_release, so destroying it only removes the join. Verified against the DB:
✅ Price guide entry survives.Price has_many :records with no dependent: option.
✅ Artist, Label, Genre, RecordFormat, DiscogsRelease all survive.
✅ Active Storage images/songs are purged (has_many_attached defaults to dependent: :purge_later).
Confirmed by data: 252,228 of 264,914 prices have no record attached at all. The price guide is a reference catalog that mostly doesn't correspond to owned records — further reason it must never be touched by a record delete.
What's broken
A. The feature is effectively invisible
No delete affordance anywhere a normal user works:
❌ Public record show page (app/views/records/show.html.erb) — has an "Edit" button (:72) linking to the admin page, but no delete
❌ Public record edit page — no delete
❌ Records index / grid / list rows — no delete
✅ Admin record show page — but inside a collapsed <details> at the bottom of a long page, reachable only via "Edit" on the public show page and then scrolling to the end
B. The confirmation copy overstates the damage
Current: "Permanently delete this record and all associated data. This cannot be undone."
This reads as though the price guide entry, artist, and label go too. They don't. "and all associated data" is the core sin — it's banned in the copy deck below. Always enumerate what goes and what stays.
C. No tests
grep -rn "destroy" test/ returns nothing. test/controllers/ contains only static_controller_test.rb. Delete has zero coverage.
Measurements
From the dev DB, 20,388 records. Script in the comments; re-run against production before building the UI.
Media coverage — this drives the whole UX
Population
Count
%
Records with images
10,960
53.8%
Records with audio
4,708
23.1%
Records with images and audio
4,281
21.0%
Records with neither — cheap to delete
9,001
44.1%
⚠️Correcting an assumption: "most of these won't have images" is not true of the collection as a whole — it's close to a coin flip. A blanket high-friction confirm would fire on the majority of deletes, which is exactly how confirmation dialogs become muscle memory and stop working.
The instinct behind it is still right, though: the records anyone actually wants to delete (junk entries, duplicates, import mistakes) are overwhelmingly the ones nobody bothered to photograph. That's precisely why the friction must be conditional on media rather than uniform — the common case stays cheap, and the expensive case gets loud.
Image-count distribution (records that have images):
Images
Records
1
87
2
6,791 ← front/back scan, the dominant pattern
3
68
4
2,560
5
870
6
309
7–16
275
Bytes destroyed per delete (avg, for records that have media): 4.6 MB of images, 6.2 MB of audio.
Catalog linkage — nearly every record points at shared data that must survive:
18,998 records (93.2%) have a price guide entry
16,794 records (82.4%) have a Discogs match
UX design: friction that scales with consequence
Two tiers, chosen by whether the record has any Active Storage media. The thumbnails are the friction — seeing four photographs you took yourself is a stronger deterrent than any warning text, and it self-calibrates: a blank media panel means you click straight through.
Tier A — no media
Tier B — has media
Population
9,001 records (44.1%)
11,387 records (55.9%)
Interaction
Inline confirm dialog
Dedicated confirmation page
Confirmation
Click "Delete"
Review media, then type DELETE
Clicks to destroy
2
4 + typing
Escape hatch
—
"Remove individual files instead"
Wireframe 1 — Owner actions on the record show page
Replaces the lone "Edit" button at app/views/records/show.html.erb:72.
┌──────────────────────────────────────────────────────────────┐
│ MILES DAVIS │
│ Kind of Blue · Columbia · LP · 1959 │
│ ┌────────────┐ │
│ │ Near Mint │ │
│ └────────────┘ │
│ │
│ [ ✎ Edit ] [ 🗑 Delete ] ← collection owner only │
│ ^^^^^^^^^^ │
│ olive-100 bg, red-700 text on hover. │
│ Never red-filled: it sits next to Edit and │
│ must not read as the primary action. │
└──────────────────────────────────────────────────────────────┘
Wireframe 2 — Tier A: no media (44.1% of records)
A standard turbo_confirm. No interstitial page — this is disposable metadata and the flow should feel that way.
┌────────────────────────────────────────────────────┐
│ Delete this record? │
│ │
│ Bill Evans — Waltz for Debby │
│ Riverside · LP · Very Good │
│ │
│ No images or audio are attached, so only the │
│ catalog entry is removed. The price guide entry, │
│ artist, and label all stay in the database. │
│ │
│ [ Cancel ] [ Delete record ] │
└────────────────────────────────────────────────────┘
Wireframe 3 — Tier B: has media (55.9% of records) — the main screen
GET /records/:id/confirm_delete. Full page, not a modal: there's too much to show, and a page is linkable, back-button-safe, and screen-reader friendly.
┌──────────────────────────────────────────────────────────────────────┐
│ ← Back to record │
│ │
│ Delete this record? │
│ ──────────────────────────────────────────────────────────────── │
│ MILES DAVIS — Kind of Blue │
│ Columbia · LP · 1959 · Near Mint │
│ │
│ ┏━ PERMANENTLY DESTROYED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ │
│ ┃ ┃ │
│ ┃ 4 images · 5.2 MB ┃ │
│ ┃ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┃ │
│ ┃ │ │ │ │ │ │ │ │ ┃ │
│ ┃ │ front │ │ back │ │ label │ │ sleeve │ ┃ │
│ ┃ │ │ │ │ │ │ │ │ ┃ │
│ ┃ └────────┘ └────────┘ └────────┘ └────────┘ ┃ │
│ ┃ ┃ │
│ ┃ 2 audio files · 11.4 MB ┃ │
│ ┃ ♪ So What.mp3 5.8 MB ┃ │
│ ┃ ♪ Blue in Green.mp3 5.6 MB ┃ │
│ ┃ ┃ │
│ ┃ ⚠ These files are deleted from storage and cannot be ┃ │
│ ┃ recovered. There is no undo. ┃ │
│ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ │
│ │
│ ┌─ KEPT — not affected by this delete ─────────────────────────┐ │
│ │ ✓ Price guide entry Columbia CL 1355 · $80–$200 │ │
│ │ ✓ Artist Miles Davis · 312 other records │ │
│ │ ✓ Label Columbia · 1,204 other records │ │
│ │ ✓ Genre Jazz │ │
│ │ ✓ Discogs release Kind of Blue (1959) │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ Type DELETE to confirm │
│ ┌──────────────────────────┐ │
│ │ │ │
│ └──────────────────────────┘ │
│ │
│ [ Delete record and 6 files ] [ Cancel ] │
│ ^ disabled until input matches │
│ │
│ ───────────────────────────────────────────────────────────── │
│ Only wanted to clear out the files? │
│ Remove individual images or audio and keep the record → │
└──────────────────────────────────────────────────────────────────────┘
Why each piece is there:
Thumbnails, not a count. "4 images will be deleted" is abstract. Four pictures of a record you photographed is not.
The KEPT panel is the direct fix for the mis-described-damage problem. The "312 other records" counts make preservation concrete rather than a promise.
Typed DELETE is deliberately reserved for Tier B only. Applied to all deletes it would fire on 100% of them and become reflex; at 55.9% it still carries signal, and the 44.1% no-media path stays frictionless.
The escape hatch routes to the existing, already-working destroy_attachment flow, which is very often what the user actually wanted.
Button label counts the files — "Delete record and 6 files" restates the consequence at the moment of commitment.
Thumbnails cap at 2 visible with a +N overflow chip. Cancel is full-width below Delete so a thumb reaching the bottom of the screen hits Cancel, not Delete.
Wireframe 5 — Result states
SUCCESS (redirect to /records)
┌──────────────────────────────────────────────────────────────┐
│ ✓ Deleted "Miles Davis — Kind of Blue" along with 4 images │
│ and 2 audio files. The price guide entry was kept. │
└──────────────────────────────────────────────────────────────┘
SUCCESS, no media
┌──────────────────────────────────────────────────────────────┐
│ ✓ Deleted "Bill Evans — Waltz for Debby". │
└──────────────────────────────────────────────────────────────┘
FAILURE (re-render confirm page, nothing removed)
┌──────────────────────────────────────────────────────────────┐
│ ✕ Couldn't delete this record: {reason}. │
│ Nothing was removed. │
└──────────────────────────────────────────────────────────────┘
CONFIRMATION MISMATCH (re-render, inline)
┌──────────────────────────────────────────────────────────────┐
│ Type DELETE to confirm │
│ ┌──────────────────────────┐ │
│ │ delete me │ ← red border │
│ └──────────────────────────┘ │
│ ✕ Type DELETE exactly to confirm. │
└──────────────────────────────────────────────────────────────┘
Wireframe 6 — Admin "Danger Zone" replacement
Currently a collapsed <details> (app/views/admin/records/show.html.erb:257). The collapse hides the feature without adding any real safety. Replace with a visible section that routes into the same shared confirmation flow — one code path, one set of copy, one place to test.
┌─ Danger Zone ────────────────────────────────────────────────┐
│ │
│ This record has 4 images and 2 audio files (16.6 MB). │
│ Deleting removes them permanently. The price guide entry, │
│ artist, and label are not affected. │
│ │
│ [ Delete this record… ] → /records/:id/confirm_delete │
│ │
└──────────────────────────────────────────────────────────────┘
The trailing ellipsis signals "opens a confirmation step," not "deletes now."
Copy deck
Context
String
Show-page button
Delete
Tier A dialog title
Delete this record?
Tier A body
No images or audio are attached, so only the catalog entry is removed. The price guide entry, artist, and label all stay in the database.
Tier B page title
Delete this record?
Destroyed panel heading
Permanently destroyed
Destroyed warning
These files are deleted from storage and cannot be recovered. There is no undo.
Kept panel heading
Kept — not affected by this delete
Typed-confirm label
Type DELETE to confirm
Typed-confirm error
Type DELETE exactly to confirm.
Submit (with media)
Delete record and {n} files
Submit (no media)
Delete record
Escape hatch
Only wanted to clear out the files? Remove individual images or audio and keep the record →
Success (media)
Deleted "{title}" along with {i} images and {a} audio files. The price guide entry was kept.
Success (no media)
Deleted "{title}".
Failure
Couldn't delete this record: {reason}. Nothing was removed.
Banned phrasing: "and all associated data" — the current copy's core sin. It implies the price guide, artist, and label are destroyed. Always enumerate what goes and what stays.
Accessibility
Confirmation page gets <h1>Delete this record?</h1>; focus moves to it on load.
The typed-confirm input has a real <label>, not placeholder-only.
Destroyed/Kept panels distinguished by heading text and icon, never color alone.
Thumbnails carry meaningful alt ("Image 1 of 4 attached to Miles Davis — Kind of Blue").
The submit button is disabled until the input matches — but the server enforces it too (see below). Disabled state announced via aria-describedby pointing at the instruction.
Audio files listed as a real <ul>, filenames as text, not icons alone.
Implementation notes
Routing
resources:recordsdo# drop `except: [:destroy]`member{get:confirm_delete}end
Tier A never visits confirm_delete — the show-page button posts DELETE directly with a turbo_confirm. Tier B's button is a link_to to confirm_delete. The branch is decided by record.has_media? at render time.
The tier must be re-derived server-side. A record can gain or lose attachments between page render and submit, and the Tier A path is a plain DELETE that a client could aim at a media-bearing record. So #destroy re-checks:
defdestroyif@record.has_media? && params[:confirm] != "DELETE"returnredirect_toconfirm_delete_record_path(@record),alert: "Type DELETE exactly to confirm."end
...
end
Eager-load with .with_attached_images.with_attached_songs on the confirm page or the byte sums will N+1.
Edge cases
Missing S3 blobs (Recovery: ~200 images missing from S3 due to failed Refile migration (Jan 4 2026) #346). ~200 images are known missing from S3. The confirm page must render a placeholder tile for a broken thumbnail rather than 500 — and arguably should say so ("1 image is already missing from storage"), since that's useful information at exactly this moment.
Record already deleted (double submit, stale tab) → RecordNotFound → redirect to /records with "That record no longer exists." rather than a 404 page.
Zero-attachment record that reports media.images.attached? is true if an attachment row exists even when the blob is gone; count from attachments, size defensively with &.byte_size.to_i.
Very large media sets. Max observed is 16 attached images. Cap thumbnails at 12 with a +N chip.
Purge is async.purge_later means files disappear shortly after, not instantly. The success copy says "deleted" — accurate from the user's view; don't promise byte-level immediacy.
Delivery: 2 sessions
Both are independently mergeable and leave the app in a working state. Stopping after Session 1 is a valid outcome — the feature is fully usable, just plain on the Tier B path.
#385 must land first. It's a two-line model change plus a migration, and shipping a reachable delete button ahead of it creates orphaned rows on ~54% of deletes.
Session 1 — Delete works end to end
Correct and complete, deliberately plain. Every path functions; none of it is pretty yet.
config/routes.rb:59 → resources :records with a confirm_delete member route
Record#has_media?, #media_counts, #media_bytes
Server-side tier re-derivation and typed-confirmation check in #destroy
Delete button on the record show page, owner-gated, beside Edit (Wireframe 1)
Tier A: turbo_confirm dialog with the no-media copy (Wireframe 2)
Tier B: confirm_delete page — functional skeleton only. Text counts ("4 images, 2 audio files, 16.6 MB"), the KEPT list, typed DELETE. No thumbnails, no mobile work, no visual design.
Success / failure flashes from the copy deck (Wireframe 5)
Decide: keep Admin::RecordsController#destroy or consolidate onto the public path
Tests: Tier A deletes in one request; Tier B refuses without confirm=DELETE; Tier B succeeds with it; signed-out visitor redirected; attachments purged; deleting a record does not delete its Price
Done when: every record in the collection is deletable through a correct, owner-gated flow, and the friction is real rather than cosmetic.
Risk: medium. The server-side enforcement is the piece that's easy to get subtly wrong; treat those three tests as the acceptance gate.
Session 2 — The confirmation experience
Turns the Session 1 skeleton into Wireframe 3. Pure UI; no behavioral change.
Destroyed panel: image thumbnails (cap 12, +N chip), audio list with per-file sizes, byte totals
KEPT panel with "N other records" counts for artist and label
Escape hatch linking to the existing destroy_attachment flow
Mobile layout (Wireframe 4) — +N overflow, Cancel below Delete
Replace the collapsed admin Danger Zone <details> with the visible panel (Wireframe 6), routed into the same flow
Accessibility pass: <h1> focus, real <label>, icon+text not colour alone, meaningful thumbnail alt
Test: confirm page renders when a blob is missing from S3
Done when: the confirmation page matches Wireframe 3 on desktop and Wireframe 4 on mobile.
Risk: low. Presentation only, on top of behavior already covered by tests.
Compresses to 1 if Tier B's visual work rides along with Session 1 — viable, but it makes one large session where correctness work and design work compete for attention, and correctness is the part that must not be rushed.
Known gap, not fixed here
User#admin? (app/models/user.rb:27) is hardcoded return true. Every signed-in user is an admin and can delete any record. RecordsController has the right idea with require_collection_owner! (current_user.id == COLLECTION_USER_ID), but Admin::BaseController only checks current_user&.admin? — always true. Mitigated in practice by there being no self-registration route, but delete shouldn't rely on that.
gem "pundit", "~> 2.3" is in the Gemfile and completely unused — no app/policies, no include Pundit, no authorize calls. The tool for doing this properly is already installed. Worth its own issue; it affects far more than delete.
Summary
A signed-in collection owner has no reachable way to delete a record, and the one hidden path that does work describes its own blast radius incorrectly — it implies the price guide entry, artist, and label are destroyed along with the record. They aren't.
Scope: hard delete of records, and the confirmation UX around it.
photos/songsorphan cascade → Legacy photos/songs rows are orphaned on record delete — add dependent: :destroy + FK constraints #385 (blocks this issue)Core design principle: deletion friction should scale with what is actually destroyed. A record with no media is disposable metadata; a record with photographs and audio represents real, unrecoverable work. The UI should treat those two cases completely differently — and it should show the user which one they're in rather than describing it.
What exists today
1. Admin destroy — the only working path
config/routes.rb:12—namespace :admin { resources :records, only: [..., :destroy] }app/controllers/admin/records_controller.rb:151— scoped touser_id: 1, rescuesActiveRecord::InvalidForeignKey, redirects with a notice.app/views/admin/records/show.html.erb:257-277— a "Danger Zone"<details>block, collapsed by default, at the very bottom of the admin record page.2. Public destroy — dead code
app/controllers/records_controller.rb:125defines#destroywithrequire_user!,require_collection_owner!, andset_record…config/routes.rb:59isresources :records, except: [:destroy]. There is noDELETE /records/:idroute. The action has never been reachable.3. Attachment-level delete (works, separate concern)
Admin::RecordsController#destroy_attachment(:201) removes individual images/audio via signed blob id, properly scoped. This one is fine — and it becomes the escape hatch in the flow below.4. Blast radius — the good news
Recordbelongs_toits price / artist / label / genre / format / discogs_release, so destroying it only removes the join. Verified against the DB:Price has_many :recordswith nodependent:option.images/songsare purged (has_many_attacheddefaults todependent: :purge_later).Confirmed by data: 252,228 of 264,914 prices have no record attached at all. The price guide is a reference catalog that mostly doesn't correspond to owned records — further reason it must never be touched by a record delete.
What's broken
A. The feature is effectively invisible
No delete affordance anywhere a normal user works:
app/views/records/show.html.erb) — has an "Edit" button (:72) linking to the admin page, but no delete<details>at the bottom of a long page, reachable only via "Edit" on the public show page and then scrolling to the endB. The confirmation copy overstates the damage
Current: "Permanently delete this record and all associated data. This cannot be undone."
This reads as though the price guide entry, artist, and label go too. They don't. "and all associated data" is the core sin — it's banned in the copy deck below. Always enumerate what goes and what stays.
C. No tests
grep -rn "destroy" test/returns nothing.test/controllers/contains onlystatic_controller_test.rb. Delete has zero coverage.Measurements
From the dev DB, 20,388 records. Script in the comments; re-run against production before building the UI.
Media coverage — this drives the whole UX
Image-count distribution (records that have images):
Bytes destroyed per delete (avg, for records that have media): 4.6 MB of images, 6.2 MB of audio.
Catalog linkage — nearly every record points at shared data that must survive:
UX design: friction that scales with consequence
Two tiers, chosen by whether the record has any Active Storage media. The thumbnails are the friction — seeing four photographs you took yourself is a stronger deterrent than any warning text, and it self-calibrates: a blank media panel means you click straight through.
DELETEWireframe 1 — Owner actions on the record show page
Replaces the lone "Edit" button at
app/views/records/show.html.erb:72.Wireframe 2 — Tier A: no media (44.1% of records)
A standard
turbo_confirm. No interstitial page — this is disposable metadata and the flow should feel that way.Wireframe 3 — Tier B: has media (55.9% of records) — the main screen
GET /records/:id/confirm_delete. Full page, not a modal: there's too much to show, and a page is linkable, back-button-safe, and screen-reader friendly.Why each piece is there:
DELETEis deliberately reserved for Tier B only. Applied to all deletes it would fire on 100% of them and become reflex; at 55.9% it still carries signal, and the 44.1% no-media path stays frictionless.destroy_attachmentflow, which is very often what the user actually wanted.Wireframe 4 — Mobile (Tier B)
Thumbnails cap at 2 visible with a
+Noverflow chip. Cancel is full-width below Delete so a thumb reaching the bottom of the screen hits Cancel, not Delete.Wireframe 5 — Result states
Wireframe 6 — Admin "Danger Zone" replacement
Currently a collapsed
<details>(app/views/admin/records/show.html.erb:257). The collapse hides the feature without adding any real safety. Replace with a visible section that routes into the same shared confirmation flow — one code path, one set of copy, one place to test.The trailing ellipsis signals "opens a confirmation step," not "deletes now."
Copy deck
DeleteDelete this record?No images or audio are attached, so only the catalog entry is removed. The price guide entry, artist, and label all stay in the database.Delete this record?Permanently destroyedThese files are deleted from storage and cannot be recovered. There is no undo.Kept — not affected by this deleteType DELETE to confirmType DELETE exactly to confirm.Delete record and {n} filesDelete recordOnly wanted to clear out the files? Remove individual images or audio and keep the record →Deleted "{title}" along with {i} images and {a} audio files. The price guide entry was kept.Deleted "{title}".Couldn't delete this record: {reason}. Nothing was removed.Banned phrasing: "and all associated data" — the current copy's core sin. It implies the price guide, artist, and label are destroyed. Always enumerate what goes and what stays.
Accessibility
<h1>Delete this record?</h1>; focus moves to it on load.<label>, not placeholder-only.alt("Image 1 of 4 attached to Miles Davis — Kind of Blue").disableduntil the input matches — but the server enforces it too (see below). Disabled state announced viaaria-describedbypointing at the instruction.<ul>, filenames as text, not icons alone.Implementation notes
Routing
Tier A never visits
confirm_delete— the show-page button postsDELETEdirectly with aturbo_confirm. Tier B's button is alink_totoconfirm_delete. The branch is decided byrecord.has_media?at render time.The tier must be re-derived server-side. A record can gain or lose attachments between page render and submit, and the Tier A path is a plain
DELETEthat a client could aim at a media-bearing record. So#destroyre-checks:Without this, the friction is decorative.
Model helpers
Eager-load with
.with_attached_images.with_attached_songson the confirm page or the byte sums will N+1.Edge cases
"1 image is already missing from storage"), since that's useful information at exactly this moment.RecordNotFound→ redirect to/recordswith"That record no longer exists."rather than a 404 page.images.attached?is true if an attachment row exists even when the blob is gone; count from attachments, size defensively with&.byte_size.to_i.+Nchip.purge_latermeans files disappear shortly after, not instantly. The success copy says "deleted" — accurate from the user's view; don't promise byte-level immediacy.Delivery: 2 sessions
Both are independently mergeable and leave the app in a working state. Stopping after Session 1 is a valid outcome — the feature is fully usable, just plain on the Tier B path.
Session 1 — Delete works end to end
Correct and complete, deliberately plain. Every path functions; none of it is pretty yet.
config/routes.rb:59→resources :recordswith aconfirm_deletemember routeRecord#has_media?,#media_counts,#media_bytes#destroyturbo_confirmdialog with the no-media copy (Wireframe 2)confirm_deletepage — functional skeleton only. Text counts ("4 images, 2 audio files, 16.6 MB"), the KEPT list, typedDELETE. No thumbnails, no mobile work, no visual design.Admin::RecordsController#destroyor consolidate onto the public pathconfirm=DELETE; Tier B succeeds with it; signed-out visitor redirected; attachments purged; deleting a record does not delete itsPriceDone when: every record in the collection is deletable through a correct, owner-gated flow, and the friction is real rather than cosmetic.
Risk: medium. The server-side enforcement is the piece that's easy to get subtly wrong; treat those three tests as the acceptance gate.
Session 2 — The confirmation experience
Turns the Session 1 skeleton into Wireframe 3. Pure UI; no behavioral change.
+Nchip), audio list with per-file sizes, byte totalsdestroy_attachmentflow+Noverflow, Cancel below Delete<details>with the visible panel (Wireframe 6), routed into the same flow<h1>focus, real<label>, icon+text not colour alone, meaningful thumbnailaltDone when: the confirmation page matches Wireframe 3 on desktop and Wireframe 4 on mobile.
Risk: low. Presentation only, on top of behavior already covered by tests.
Compresses to 1 if Tier B's visual work rides along with Session 1 — viable, but it makes one large session where correctness work and design work compete for attention, and correctness is the part that must not be rushed.
Known gap, not fixed here
User#admin?(app/models/user.rb:27) is hardcodedreturn true. Every signed-in user is an admin and can delete any record.RecordsControllerhas the right idea withrequire_collection_owner!(current_user.id == COLLECTION_USER_ID), butAdmin::BaseControlleronly checkscurrent_user&.admin?— always true. Mitigated in practice by there being no self-registration route, but delete shouldn't rely on that.gem "pundit", "~> 2.3"is in the Gemfile and completely unused — noapp/policies, noinclude Pundit, noauthorizecalls. The tool for doing this properly is already installed. Worth its own issue; it affects far more than delete.Out of scope
photos/songsorphan cascade → Legacy photos/songs rows are orphaned on record delete — add dependent: :destroy + FK constraints #385 (blocks this issue)User#admin?Related