feat: media page T1

This commit is contained in:
fsylvain committed 2026-10-01 01:36:35 +02:00
1 parent 67d456baf6
commit 008b0dfbb9
46 files changed
+5742 -204

No files matched your search

+12 -25
View File
@@ -14,11 +14,9 @@ Feature: Media page, part 1: browse and manage
breadcrumb reads "Media" (mobile title "Media")
And this slice is SPA only: the API from slice A already exists and is used unchanged, and
no Go code, use case or repository changes
And this slice covers browse and manage only. Upload, multi-select, the bulk bar, the
Duplicates tab content, duplicate merge, drag-and-drop and the rate-limited state are slice
B2, and this slice renders none of their UI
And the visual states of slice B2 carry skip 'pending' in web/visual/states.ts, print PENDING
and are never counted as passing
And upload, multi-select, the bulk bar, the Duplicates tab content, duplicate merge,
drag-and-drop and the rate-limited state are specified in
features/media_upload_selection_duplicates.feature
And useMedia is a composable over useApi. It exposes the list state (items, counts, loading,
error), the request functions for list, usage, rename and delete, and the copy helper, and
it maps ApiError and NetworkError to the states below
@@ -30,9 +28,8 @@ Feature: Media page, part 1: browse and manage
means grid); a value outside these sets is dropped and the default applies; the names
mirror the API's own q, filter and sort parameters
And the header shows the title "Media", the subtitle "Every image you've uploaded, ready to
use in any article." and a primary "Upload" button (icon upload) on the right; in this slice
that button is rendered disabled and does nothing, and on mobile it is the full-width 48 px
button pinned to the bottom, disabled the same way
use in any article." and a primary "Upload" button (icon upload) on the right; on mobile it is
the full-width 48 px button pinned to the bottom
And the toolbar holds the tabs "All", "Unused" and "Duplicates" with a count each, then the
search field (placeholder "Search by filename"), the sort menu (options "Newest", "Oldest",
"Name", "Largest", sent as sort=newest, oldest, name, largest) and the grid/list toggle
@@ -40,10 +37,7 @@ Feature: Media page, part 1: browse and manage
And the tab counts come from the response's counts object (all, unused, duplicates). The
Duplicates count is a number of groups, drawn as an info-tinted badge only while it is
above 0. The All and Unused counts are drawn as plain muted mono numbers, as in the board
And the Duplicates tab is rendered and disabled in this slice (aria-disabled, not
focusable, no navigation), so it keeps its place and its count badge but leads nowhere;
slice B2 enables it and replaces the content area with the groups view. A ?filter=duplicates
in the URL is treated as an unknown value and falls back to All
And the Duplicates tab selects the groups view and ?filter=duplicates is a valid value
And the grid is 5 columns on desktop and 2 on mobile, with a 16 px gap (10 px on mobile),
and each card is one button holding MediaThumb (4:3, a checkerboard behind PNG and SVG),
the filename (medium weight, ellipsis), "size · dimensions" and the MediaBadges; an SVG's
@@ -65,9 +59,8 @@ Feature: Media page, part 1: browse and manage
trailing chevron-right); in the table the "Used in" cell is an underlined count that
opens the detail panel, and reads the Unused badge when used_in is 0
And the checkbox column of the table (36 px), the 44 px checkbox column of the card list and
the "Select all" row above the grid keep their space in the layout but show no checkbox
and no "Select all" text in this slice (the row shows only "12 images"); slice B2 fills
them
the "Select all" row above the grid are specified in
features/media_upload_selection_duplicates.feature
And the detail panel is a USlideover: 460 px on the right on desktop (a close button x),
full screen on mobile with an arrow-left back button; its title is the filename; the body
holds the 16:10 preview (4:3 on mobile), the actions "Copy URL" (icon link), "Rename"
@@ -107,7 +100,7 @@ Feature: Media page, part 1: browse and manage
Duplicates count drawn as an info-tinted badge
And the grid shows the 12 images in exactly the fixture's order, 5 per row on desktop and 2
on mobile, each card with its thumbnail, filename, "size · dimensions" and badges
And the "Select all" row above the grid shows only "12 images"
And the "Select all" row above the grid reads "Select all" and "12 images"
And the toolbar shows "Search by filename", the sort "Newest" and the grid toggle selected
Scenario Outline: Each card's badges and accessible name follow the image
@@ -175,11 +168,6 @@ Feature: Media page, part 1: browse and manage
Then the URL gains sort=largest and the list is requested again
And selecting "All" removes filter from the URL, "Newest" removes sort, and the grid icon removes view
Scenario: The disabled Duplicates tab does nothing
Given the Default state has loaded
When the user clicks or focuses the "Duplicates 2" tab
Then the tab does not become selected, the URL does not change and no request is sent
Scenario: Loading, skeleton cards after 150 ms
Given GET /v1/media/ has not yet resolved
When the user navigates to /media
@@ -195,7 +183,7 @@ Feature: Media page, part 1: browse and manage
Given GET /v1/media/ resolves 200 with zero items and counts { all: 0, unused: 0, duplicates: 0 } and no q
When the user navigates to /media
Then the toolbar and the tabs are hidden
And one panel shows the images icon, title "No images yet", text "Upload your first image. You can use it in any article.", a primary "Upload an image" button (disabled in this slice), and under it "PNG, JPEG, WEBP, SVG · max 10 MB each"
And one panel shows the images icon, title "No images yet", text "Upload your first image. You can use it in any article.", a primary "Upload an image" button, and under it "PNG, JPEG, WEBP, SVG · max 10 MB each"
Scenario: No results, search and filter
Given the Default state has loaded
@@ -216,8 +204,7 @@ Feature: Media page, part 1: browse and manage
When the user presses Enter on a card
Then the detail panel opens for that image
When the user presses Space on a card
Then nothing happens and no panel opens (Space is selection, which is slice B2)
And no checkbox is shown on hover or on focus in this slice
Then the card's selection toggles and no panel opens
Scenario: Detail panel, in use
Given the Default state has loaded and GET /v1/media/1/usage resolves 200 with [{ id: 10, title: "How we price a bathroom renovation", published: true }, { id: 11, title: "Winter checklist for timber windows", published: false }]
@@ -239,7 +226,7 @@ Feature: Media page, part 1: browse and manage
Given the Default state has loaded, GET /v1/media/?hash=<sha256 of id 12> resolves 200 with ids 12, 5 and 6, and GET /v1/media/12/usage resolves 200
When the user activates the "floorboards.jpg" card
Then an info alert reads "Identical to 2 other images" over "floorboards-copy.jpg and IMG_2208.jpg have the same content." with an action "Review duplicates" (icon arrow-right)
And the action is rendered disabled in this slice, because the Duplicates view ships in slice B2
And the action is enabled and selects the Duplicates tab
Scenario: The Used in count in list view opens the panel
Given the Default state has loaded with ?view=list at md width or more
+13 -22
View File
@@ -6,7 +6,7 @@ SPA only. The media API already exists (slice A), so there is no HTTP-level Go t
and no `test/` file. The Go suite is run only to confirm nothing regressed (`task test`).
Slice B2 (upload, multi-select, bulk bar, Duplicates content, merge, drag and drop, rate limit) is
out of scope: its states are listed under "Pending, slice B2" and are never reported as passing.
specified in features/media_upload_selection_duplicates.feature and its .qa.md.
Toast ids: "media-url-copied-{id}", "media-renamed-{id}", "media-deleted-{id}", "media-not-found-{id}".
@@ -29,26 +29,26 @@ Logic and copy:
"WEBP", image/svg+xml "SVG"); accessible name for used_in 2, 1 and 0 ("used in 2 articles",
"used in 1 article", "unused").
2. **Query string mapping** - Examples-driven from the .feature: each URL produces the exact
request query and control state; unknown values and ?filter=duplicates fall back to defaults;
request query and control state; unknown values fall back to defaults;
changing a control rewrites the URL and drops default values.
3. **Default** - 12 cards in fixture order, tabs "All 12" "Unused 2" "Duplicates 2", the
Duplicates badge only when duplicates > 0, "12 images" in the Select all row, breadcrumb
"Media", no checkbox in the DOM.
Duplicates badge only when duplicates > 0, "Select all" and "12 images" in the Select all row,
breadcrumb "Media".
4. **Badges and accessible names** - the 5 Examples rows (badges and aria-label / accessible name).
5. **Grid, few images** - 3 items; tabs "All 3" "Unused 1" "Duplicates" without a badge.
6. **List view** - desktop: table with the 7 column headers and 12 rows, the Used in cell
underlined count or Unused badge; mobile: card list with "3.9 MB · 4032 × 3024 · JPEG" and
"Marta Alves · 12 Sep 2026"; clicking the Used in count opens the panel.
7. **Disabled Duplicates tab** - aria-disabled, click and focus change nothing, no request.
7. **Duplicates tab** - covered by the B2 spec.
8. **Loading** - unresolved GET: nothing before 150 ms; after 150 ms the skeleton (10 cards,
6 below md) and aria-busy; resolve; cards replace it; same on retry and on search, tab and
sort change, in list view too.
9. **Empty library** - zero items and counts 0 with no q: toolbar and tabs absent, copy exact,
"Upload an image" rendered disabled, the footnote "PNG, JPEG, WEBP, SVG · max 10 MB each".
"Upload an image" enabled, the footnote "PNG, JPEG, WEBP, SVG · max 10 MB each".
10. **No results** - unused + "wall": title `No images match "wall"`, text "Nothing matches in
Unused. Check the spelling, or clear the filters."; "Clear filters" resets q and filter and
restores the cards.
11. **Keyboard** - each card is a button; Enter opens the panel; Space opens nothing; arrow keys
11. **Keyboard** - each card is a button; Enter opens the panel; Space toggles selection and opens nothing; arrow keys
move focus (left, right, up, down by one row, stopping at the edges); Escape closes the panel
and focus returns to the card.
12. **Detail panel, in use** - usage called once for id 1, title, actions, metadata rows, article
@@ -57,7 +57,7 @@ Logic and copy:
text.
14. **Detail panel, duplicate** - hash query used; alert title "Identical to 2 other images" and
"floorboards-copy.jpg and IMG_2208.jpg have the same content." (the image itself is excluded);
the "Review duplicates" action is disabled.
the "Review duplicates" action is enabled and selects the Duplicates tab.
15. **Copy URL** - clipboard called with `{origin}{url}`; toast id "media-url-copied-1", icon
circle-check, title "URL copied", text "Paste it wherever you need the image address.".
16. **Rename dialog** - title, description, field "north-wall" with ".jpg" suffix, help text,
@@ -87,8 +87,7 @@ Logic and copy:
29. **Sidebar and drawer** - Media between Articles and Users with the images icon, active on
/media, in the desktop sidebar and the mobile drawer; the route has meta.auth "verified" and
meta.title "Media" (routerGuard spec extended).
30. **Header** - title, subtitle, and the Upload button disabled and inert in this slice (also
the mobile pinned 48 px variant).
30. **Header** - title, subtitle, and the Upload button (also the mobile pinned 48 px variant).
## Visual (`task visual:check -- media`, desktop and mobile, light and dark)
@@ -105,10 +104,7 @@ icon, the RenewCMS mark on the SVG, a checkerboard behind PNG and SVG), not phot
mocked image URLs must render the same way, or the 10 % gate fails for a reason unrelated to
the code.
Harness note: `web/visual/states.ts` gains a `skip: 'pending'` option, added by coder. It prints
`PENDING` and counts as neither pass nor fail. `task visual:check -- media` still exits non-zero
when a B1 state has no setup, when a `/v1` call is unmocked, when sizes mismatch or when a capture
exceeds its budget. Every B2 state carries `skip: 'pending'` and is never reported as passing.
Harness note: the B2 states now have setups and no longer carry `skip: 'pending'`.
Setups for this slice (name in screens/media.md, then what must be drawn):
@@ -118,9 +114,8 @@ Setups for this slice (name in screens/media.md, then what must be drawn):
- [ ] Loading: skeleton cards (10 desktop, 6 mobile), toolbar visible.
- [ ] Empty library, first run: no toolbar, "No images yet" panel and the footnote.
- [ ] No results: unused + "wall".
- [ ] Hover, focus and keyboard: hover on id 2, focus ring on id 3 (the checkbox reveal is not
drawn in this slice).
- [ ] Detail panel: in use (id 1), unused (id 8), duplicate (id 12; the action rendered disabled).
- [ ] Hover, focus and keyboard: hover on id 2, focus ring on id 3 (the checkbox reveal is covered by B2).
- [ ] Detail panel: in use (id 1), unused (id 8), duplicate (id 12).
- [ ] Copy URL: id 1 panel plus the toast.
- [ ] Rename: dialog, saving, validation, failed, done (id 1, "north-wall-01").
- [ ] Delete: unused (id 8), in use (id 1), in progress (id 1), failed (id 8), done (grid without
@@ -129,11 +124,7 @@ Setups for this slice (name in screens/media.md, then what must be drawn):
- [ ] List failed to load: alert with "Try again".
- [ ] Offline: banner and disabled Upload.
Pending, slice B2 (`skip: 'pending'`, never reported as passing):
- Selection and bulk bar; Bulk delete: confirm, in progress, partly failed, done
- Upload: drag over the page, several files mixed, summary, rejected files (413, 400), too many requests (429)
- Duplicates: groups, empty; Merge: confirm, in progress, failed, conflict (409), done
The B2 states are listed in features/media_upload_selection_duplicates.qa.md.
Manual checks screenshots cannot cover:
@@ -0,0 +1,452 @@
Feature: Media page, part 2: upload, selection and duplicates
As a verified RenewCMS owner
I want to upload images to the library from /media, select several images to delete them
together, and merge identical copies
So that I can keep the library complete and free of duplicates without leaving the page
Background:
Given /media is the verified route from slice B1, rendered as MediaPage inside
DashboardShell, per docs/design/routes.md and docs/design/screens/media.md
And this slice is SPA only: the API from slice A is used unchanged, and no Go code, use case
or repository changes
And this slice supersedes these B1 statements: the Upload button, the mobile pinned button and
"Upload an image" are enabled; the Duplicates tab is enabled and ?filter=duplicates is a
valid value; "Review duplicates" is enabled; Space on a card toggles selection; the checkbox,
the "Select all" text and the partial state exist; the B2 states no longer carry
skip 'pending' in web/visual/states.ts
And the calls used are POST /v1/media/ (multipart field "file", sent with XMLHttpRequest so
that upload progress is reported, with the jwt cookie), GET /v1/media/?hash=<sha256 hex>,
DELETE /v1/media/{id}, POST /v1/media/merge { keep_id, merge_ids } and the list call from
B1 with filter=duplicates; every other /v1 call goes through useApi
And 401 and 403 on any of these calls, including the XHR upload, send the user to
/login?redirect=/media&reason=expired and /register/pending as useApi does, with no local
copy
And the browser-side checks reuse validateImageFile from web/src/lib/imageUpload.ts: size is
checked first (10 MB = 10485760 bytes is allowed, one byte more is too large), then the
file's MIME type and its extension must both be allowed (PNG, JPEG, WEBP, SVG; .png, .jpg,
.jpeg, .webp, .svg); a rejected file is never hashed and never sent
And hashing lives in a shared module under web/src/lib that depends on nothing in the Media
page, so that the editor's media picker slice reuses it: it reads the file, computes the
SHA-256 with crypto.subtle and resolves the lowercase 64-character hex, or resolves null when
crypto.subtle is unavailable
And at most 2 uploads are in flight at once; files are processed in the order they were
chosen or dropped, and hashing runs inside the file's concurrency slot while its row reads
"Waiting"
And uploads from this page are not cropped or resized, and each file is sent as chosen
And toast ids are "media-bulk-deleted", "media-bulk-delete-failed", "media-merged-{keep_id}"
and "media-not-found-{id}", and success toasts last 5 s; the bulk-delete failure toast has
no duration and stays until retried or dismissed
And the 12-image fixture, its counts { all: 12, unused: 2, duplicates: 2 } and its sample
data are the ones in features/media_browse.feature; the duplicate groups are {12, 5, 6}
(floorboards.jpg, floorboards-copy.jpg, IMG_2208.jpg, 3600 × 2400, 2.4 MB each) and
{7, 9} (IMG_4471.jpg, stone-wall-detail.jpg, 3600 × 2400, 3.1 MB each)
And where a board frame contradicts the design prose or the fixture, this spec follows the
prose and the fixture, and the design is corrected by the user, with no visual exception
Rule: Upload entry points
Scenario Outline: The Upload controls open the file chooser
Given the Default state has loaded <context>
When the user activates "<control>"
Then the file chooser opens, accepts several files and offers image/png, image/jpeg, image/webp and image/svg+xml
Examples:
| context | control |
| at md width or more | Upload |
| below md width | Upload |
| and the library has no image | Upload an image |
Scenario: Dropping files anywhere on the page
Given the Default state has loaded at md width or more
When the user drags files over the page
Then a dashed primary-bordered area covers the content, with the upload icon, the title "Drop images to upload" and the text "PNG, JPEG, WEBP, SVG · max 10 MB each"
And the mobile pinned Upload button is hidden while the area shows
When the user drags the files away from the page
Then the area disappears and nothing is uploaded
When the user drops the files on the page
Then the area disappears and the upload of each file starts straight away, with no crop step
Scenario: Dragging something that is not a file does nothing
When the user drags selected text over the page
Then no drop area shows
Rule: Browser checks, nothing is sent
Scenario Outline: A file that fails the checks becomes a rejected row
Given the user chooses the file "<file>" of <size> with MIME type "<mime>"
Then one row is added with the file-x icon, the title "<title>" and the text "Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image."
And no hash lookup and no POST /v1/media/ is sent for it
Examples:
| file | size | mime | title |
| scan.tif | 14.2 MB | image/tiff | scan.tif is 14.2 MB |
| notes.pdf | 210 KB | application/pdf | notes.pdf isn't an image |
| photo.png | 10.1 MB | image/png | photo.png is 10.1 MB |
| fake.jpg | 40 KB | text/plain | fake.jpg isn't an image |
| logo.gif | 40 KB | image/gif | logo.gif isn't an image |
| pic.jpg | 40 KB | image/png | pic.jpg isn't an image |
Scenario: Every rejected file leaves the panel with "Nothing was uploaded"
Given the user chooses scan.tif (14.2 MB), notes.pdf (210 KB) and broken.png (3.0 MB, rejected by the server with 400)
Then the panel header reads "Nothing was uploaded" with "3 files were rejected" and a "Dismiss" button
And the rows read "scan.tif is 14.2 MB", "notes.pdf isn't an image" and "broken.png couldn't be read" with "The server couldn't read this image. Save it again as a PNG or JPEG, then try again."
And no summary line shows
Scenario: Rejected and failed rows without an added image
Given the user chooses one rejected file and one file whose upload fails with 500
Then the panel header reads "Nothing was uploaded" with "2 files need attention"
And no summary line shows
Rule: The upload panel and its rows
Scenario: Files upload two at a time with progress
Given the user chooses 6 valid images and the duplicate lookup finds nothing
Then the panel shows above the images, below the toolbar, with the title "Uploading 6 images", "0 of 6 done" and a "Cancel all" button
And rows 1 and 2 read "Uploading", and rows 3 to 6 read "Waiting" (each row: 40 px thumbnail, filename, size)
When the first upload reports 1.1 MB sent of 2.6 MB
Then its row reads "Uploading 1.1 of 2.6 MB" over a 4 px progress bar filled to 42 % and shows a cancel x
When the first upload resolves 201
Then its row reads "Added to your library" with a circle-check icon, the header reads "1 of 6 done", and row 3 starts uploading
And at no time are more than 2 requests to POST /v1/media/ in flight
Scenario: The panel header counts settled rows
Given a panel with rows Added, Duplicate, Rejected, Failed, Uploading and Waiting
Then the header reads "4 of 6 done"
Scenario: Singular forms
Given the user chooses 1 valid image
Then the panel title reads "Uploading 1 image"
Scenario: New files added while the panel is open
Given the panel is showing rows
When the user chooses or drops more files
Then their rows are appended and the header counts cover all rows
Scenario: A new upload refreshes the list in the background
Given an upload resolves 201
Then the list is requested again with no skeleton, so the new image shows and the counts update
Scenario: A row whose content already exists
Given the user chooses "floorboards-2.jpg" (2.4 MB, valid)
When the SHA-256 of the file is computed
Then GET /v1/media/?hash=<that hex> is sent before any POST
And when it resolves 200 with the image "floorboards.jpg" (id 12, uploaded 14 Jun 2026 by Inès Duarte)
Then the row shows "Already in your library", the text `Same as "floorboards.jpg", uploaded 14 Jun 2026 by Inès Duarte.`, and the buttons "Use existing" and "Upload anyway"
And nothing is sent with POST /v1/media/ for it, and its concurrency slot is released
Scenario: Several existing matches name the oldest one
Given the lookup matches several images
Then the row names the oldest match
Scenario: Use existing selects the existing image
Given the row for "floorboards-2.jpg" is showing "Already in your library" for id 12
When the user activates "Use existing"
Then the row is dropped from the panel
And the image "floorboards.jpg" is selected, the bulk bar reads "1 selected", and the page announces "floorboards.jpg selected, 1 selected"
Scenario: Use existing when the image is not in the loaded list
Given the existing image is not in the loaded list because of the search, the tab or the 100 limit
When the user activates "Use existing"
Then the search and the tab reset to All and the image is selected
And if it is still not loaded the row is dropped without selecting
Scenario: Upload anyway stores a second copy
Given the row for "floorboards-2.jpg" is showing "Already in your library"
When the user activates "Upload anyway"
Then the file is sent with POST /v1/media/ and the row follows the normal upload states
And when it resolves 201 the list is reloaded and the Duplicates tab count reflects the new copy
Scenario Outline: The duplicate check is skipped when it cannot run
Given the user chooses a valid image and <condition>
Then the file is sent with POST /v1/media/ without waiting for the answer to a lookup
And any duplicate then shows up under Duplicates
Examples:
| condition |
| crypto.subtle is undefined (an insecure context) |
| GET /v1/media/?hash= fails with 500 or never reaches the server |
Scenario Outline: Server answers for an upload that passed the checks
Given a valid image "<file>" of <size> is uploading
When POST /v1/media/ resolves <status>
Then the row reads <row>
Examples:
| file | size | status | row |
| roof.png | 1.4 MB | 201 | circle-check "Added to your library" |
| scan.png | 9.9 MB | 413 | file-x "scan.png is 9.9 MB" over "Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image." |
| notes.png | 210 KB | 415 | file-x "notes.png isn't an image" over the same text |
| broken.png | 3.0 MB | 400 | file-x "broken.png couldn't be read" over "The server couldn't read this image. Save it again as a PNG or JPEG, then try again." |
| roof.png | 1.4 MB | 500 | circle-x "Upload failed" over "The server didn't accept the file. Nothing was added to your library." and a "Try again" button (icon rotate-cw) |
| roof.png | 1.4 MB | no answer (network) | the same Failed row |
Scenario: Try again retries one file
Given a row is "Upload failed"
When the user activates "Try again"
Then only that file is sent again with POST /v1/media/ (no new duplicate lookup), and the row follows the normal upload states
Scenario: Cancelling one upload or all of them
Given several files are uploading or waiting
When the user activates the cancel x on an uploading row
Then that request is aborted and the row is removed, and the next waiting file starts
When the user activates "Cancel all"
Then every request in flight is aborted and every waiting row is removed, Added, rejected and failed rows stay, and when no row is left the panel closes
Scenario: The panel settles with a summary
Given 5 files settled as 2 Added, 1 Already in your library (still undecided), 1 rejected and 1 failed
Then the header reads "Upload finished" with "5 images" and a "Dismiss" button in place of "Cancel all"
And a summary line reads "2 uploaded · 1 already in your library · 2 need attention"
And the page announces the summary when the last row settles
When the user activates "Dismiss"
Then the panel and its rows disappear, including rejected and failed rows, which stay until then
Scenario Outline: The summary line counts each outcome and omits empty parts
Given the panel has settled with <uploaded> added, <dup> already in the library, <attention> rejected or failed
Then the summary line reads "<line>"
Examples:
| uploaded | dup | attention | line |
| 2 | 1 | 2 | 2 uploaded · 1 already in your library · 2 need attention |
| 3 | 0 | 0 | 3 uploaded |
| 0 | 1 | 0 | 1 already in your library |
| 1 | 0 | 1 | 1 uploaded · 1 need attention |
Rule: Too many uploads (429)
Scenario: The server rate limits an upload
Given a file is uploading and files are waiting
When POST /v1/media/ resolves 429 with the header Retry-After: 42
Then an info alert with the timer icon reads "Too many uploads" over "For your security, wait a moment before uploading again."
And the Upload button reads "Try again in 0:42" with the timer icon and is disabled, and so is the mobile pinned button
And Added rows stay in the panel
And the rate-limited file goes back to Waiting and the queue pauses
And the countdown is driven by useRetryAfter and ticks once per second
When the countdown reaches 0:00
Then the alert disappears, the Upload button reads "Upload" and is enabled again
And the paused file and the waiting files resume by themselves
Scenario: A missing Retry-After counts as 60 seconds
When POST /v1/media/ resolves 429 without a readable Retry-After header
Then the countdown starts at 1:00
Scenario: Dropping or choosing files while rate limited
Given the countdown is running
When the user drops files on the page
Then nothing is uploaded and the drop area does not show
Rule: Offline
Scenario: Upload, bulk delete and merge need a connection
Given the Default state has loaded and navigator.onLine becomes false
Then the offline banner from B1 shows
And "Upload" (header, mobile pinned button and "Upload an image"), "Delete" in the bulk bar and every "Merge into this one" are disabled, and dropping files does nothing
And browsing, selecting and "Clear" still work
When navigator.onLine becomes true
Then they are enabled again
Rule: Selection
Scenario: Selecting images
Given the Default state has loaded at md width or more
Then no checkbox is shown on the cards
When the pointer rests on a card, or a card takes keyboard focus
Then its checkbox shows at the top left (36 px)
When the user ticks the checkbox of "north-wall.jpg" (or presses Space on the card)
Then the card takes the sand fill (--rc-sand) with a primary outline, and every card now shows its checkbox
And the page announces "north-wall.jpg selected, 1 selected"
And the keyboard path is Space on the card: the checkbox itself is not an extra tab stop
And Enter still opens the detail panel, and ticking a checkbox does not
Scenario: Unselecting announces the new count
Given 3 images are selected
When the user unselects "north-wall.jpg"
Then the page announces "north-wall.jpg unselected, 2 selected"
And when the last one is unselected it announces "Selection cleared"
Scenario: Selection on mobile
Given the Default state has loaded below md width
Then every card shows a 44 px checkbox, and so does every row of the card list in list view
And tapping the checkbox toggles the selection, and tapping the card opens the detail panel
Scenario: Select all and the partial state
Given the Default state has loaded
Then the row above the grid reads "Select all" and "12 images", with an unchecked checkbox
When 3 images are selected
Then that checkbox shows the partial state
When the user activates it while it is partial
Then all 12 loaded images are selected and it shows checked
When the user activates it while it is checked
Then the selection is emptied
And in list view at md width or more the table header checkbox does the same; the mobile card list has no "Select all" row and is selected through its 44 px row checkboxes
Scenario: Escape and Clear empty the selection
Given 3 images are selected and the detail panel is closed
When the user presses Escape, or activates "Clear" in the bulk bar
Then the selection is emptied and the bulk bar disappears
Given the detail panel is open over a selection
When the user presses Escape
Then only the panel closes and the selection stays
Scenario: The selection follows the query
Given 3 images are selected
When the user changes the search text, the tab or the sort
Then the selection is cleared
When the user switches between grid and list
Then the selection is kept
Scenario: The bulk bar
Given the images "north-wall.jpg", "kitchen-after.webp" and "window-frame.jpg" are selected
Then a sticky bar sits at the bottom: 400 px wide and 24 px above the bottom edge, centred over the content area on desktop; full width with 16 px side margins and 16 px above the bottom edge on mobile
And it has the inverted fill, the text "3 selected", a danger "Delete" button (icon trash-2) and an inverted "Clear" button
And on mobile it replaces the pinned Upload button
And toasts sit above it on mobile
Rule: Bulk delete
Scenario: Bulk delete, confirm
Given "north-wall.jpg" (used in 2), "kitchen-after.webp" (used in 3) and "window-frame.jpg" (unused) are selected
When the user activates "Delete"
Then a dialog opens titled "Delete 3 images?" with the description "2 of them are used in 5 articles, which will show a broken image. The other one isn't used anywhere. This can't be undone."
And the body has one bordered row per selected image: the filename and the badge "Used in 2", "Used in 3" or "Unused"
And the buttons read "Cancel" first, then a danger "Delete 3 images"
And no usage request is sent
Scenario: Bulk delete, singular
Given one image is selected
When the user activates "Delete"
Then the dialog is titled "Delete 1 image?" and the confirm button reads "Delete 1 image"
Scenario: Bulk delete, in progress
Given the bulk delete dialog is open for 3 images
When the user activates "Delete 3 images"
Then one DELETE /v1/media/{id} is sent for each of the 3 images
And both buttons are disabled, the confirm button reads "Deleting…" with a loader-circle, and Cancel, Escape and a backdrop click do not close the dialog
Scenario: Bulk delete, done
Given a bulk delete of ids 1, 2 and 8 is in flight
When every DELETE resolves 204
Then the dialog closes, the 3 cards leave the grid and the selection is empty
And focus moves to the card now at the position of the first deleted card, or to the page heading when no card remains
And a success toast appears: icon circle-check, id "media-bulk-deleted", title "3 images deleted", text "They're gone from your library."
And the list is then requested again in the background, with no skeleton
Scenario: Bulk delete, partly failed
Given a bulk delete of ids 1, 2 and 8 is in flight
When DELETE /v1/media/1 and DELETE /v1/media/2 resolve 204 and DELETE /v1/media/8 resolves 500
Then the dialog closes, the cards for ids 1 and 2 leave the grid, and "window-frame.jpg" stays in the grid and stays selected, so the bulk bar reads "1 selected"
And an error toast appears and stays until retried or dismissed: icon circle-x, id "media-bulk-delete-failed", title "Couldn't delete 1 image", text "2 images were deleted. window-frame.jpg is still in your library. Try again." and a "Retry" button (icon rotate-cw)
When the user activates "Retry"
Then DELETE /v1/media/8 is sent again with no dialog, and on 204 the card leaves and the toast is replaced by a success toast
Scenario: Bulk delete, several failures
Given a bulk delete of 4 images in which two fail
Then the toast title reads "Couldn't delete 2 images" and the text names both files, "a.jpg and b.jpg are still in your library. Try again."
Scenario: Bulk delete, an image that no longer exists
Given a bulk delete of 3 images is in flight
When one DELETE resolves 404 and the others 204
Then the 404 image leaves the list and counts as deleted, and the success toast reads "3 images deleted"
Scenario: Bulk delete, every request fails
Given a bulk delete of 3 images is in flight
When every DELETE resolves 500 or never arrives
Then the dialog stays open with an error alert "Couldn't delete the images, so they're still there. Try again in a moment." and both buttons enabled
Rule: Duplicates
Scenario: The Duplicates tab is enabled
Given the Default state has loaded
When the user selects the "Duplicates 2" tab
Then the URL gains ?filter=duplicates and the list is requested with filter=duplicates and limit=100
And the tab is selected, and "Duplicates" carries the info-tinted count of groups
And opening /media?filter=duplicates lands on the same view, and "Review duplicates" in the detail panel of "floorboards.jpg" is enabled and selects this tab
And search applies, the sort menu and the view toggle show as on the other tabs, sort is sent with the request, and the view toggle has no effect on the groups view while the grid or list preference stays in the URL
Scenario: Duplicates, groups
Given GET /v1/media/?filter=duplicates&sort=newest&limit=100 resolves 200 with ids 5, 6, 7, 9 and 12
When the user opens the Duplicates tab
Then the images are grouped by sha256 into 2 bordered cards stacked with a 16 px gap, the group with the oldest suggested copy first
And group 1 has the header "Group 1" over "3 identical images · 2.4 MB each · 3600 × 2400", and group 2 has "Group 2" over "2 identical images · 3.1 MB each · 3600 × 2400"
And group 1 has one column per copy, in the order "floorboards.jpg", "floorboards-copy.jpg", "IMG_2208.jpg" (most used, then oldest, then lowest id), and group 2 has "IMG_4471.jpg" then "stone-wall-detail.jpg"; below md the columns stack
And each copy shows its thumbnail, filename, "Uploaded 14 Jun 2026 by Inès Duarte", an underlined "Used in 4 articles" that opens its detail panel, and a "Merge into this one" button (icon merge)
And "floorboards.jpg" and "IMG_4471.jpg" carry the sand fill, a primary outline, the "Suggested" tag and a primary button, and the other copies are outlined with outline buttons
And each group's footer reads "Suggested: the most used copy, then the oldest. Choosing another copy switches articles to it."
Scenario: Group header details
Given a group of SVG images whose uploader is unknown
Then the header reads "2 identical images · 8 KB each · Vector" and each copy reads "Uploaded 14 Jun 2026" without "by"
Scenario Outline: The suggested copy
Given a group of copies <copies>
Then the suggested copy is <suggested>
Examples:
| copies | suggested |
| used in 4 (14 Jun), 3 (2 Sep) and 2 (30 Aug) | the copy used in 4 |
| used in 2 (29 Aug) and 2 (21 Aug) | the copy of 21 Aug (same use, oldest) |
| used in 0 (3 Sep) and 0 (1 Sep) | the copy of 1 Sep |
Scenario: Duplicates, empty
Given GET /v1/media/?filter=duplicates resolves 200 with no item and counts.duplicates 0
Then the Duplicates tab has no count badge
And a panel shows the copy icon, "No duplicates", "Every image in your library is different." and a "Back to all images" button
When the user activates "Back to all images"
Then the tab returns to All and ?filter leaves the URL
Rule: Merge
Scenario: Merge, confirm
Given the Duplicates tab shows group 1
When the user activates "Merge into this one" on "floorboards.jpg"
Then a dialog opens titled "Merge into floorboards.jpg?" with the description "Articles that use the other 2 copies will be switched to floorboards.jpg, and the other 2 copies will be deleted. 5 articles will be updated. This can't be undone."
And the body shows the kept image: its thumbnail, "floorboards.jpg" and "Kept · used in 4 articles now"
And the buttons read "Cancel" first, then a danger "Merge 2 copies"
Scenario: Choosing another copy keeps that one
When the user activates "Merge into this one" on "IMG_2208.jpg" in group 1
Then the dialog is titled "Merge into IMG_2208.jpg?" and its article count is the sum of the used_in of "floorboards.jpg" and "floorboards-copy.jpg"
Scenario: Merge, singular
Given a group of 2 copies
Then the description reads "Articles that use the other copy will be switched to …" and the confirm button reads "Merge 1 copy"
Scenario: Merge, in progress
Given the Merge dialog is open for group 1 with "floorboards.jpg" kept
When the user activates "Merge 2 copies"
Then POST /v1/media/merge is sent with { "keep_id": 12, "merge_ids": [5, 6] }
And both buttons are disabled, the confirm button reads "Merging…" with a loader-circle, and Cancel, Escape and a backdrop click do not close the dialog
Scenario: Merge, done
Given a merge for group 1 is in flight
When POST /v1/media/merge resolves 200 with { "articles_updated": 5, "merged": 2 }
Then the dialog closes and group 1 leaves the list
And a success toast appears: icon circle-check, id "media-merged-12", title "Copies merged", text "2 copies merged. 5 articles updated."
And the page announces the same text, and the list is requested again in the background so the counts and the Duplicate flags update
And when it was the last group, the "No duplicates" panel shows and the tab loses its count badge
Scenario Outline: Merge, errors keep the dialog open or remove the image
Given a merge for group 1 is in flight
When POST /v1/media/merge <failure>
Then <result>
Examples:
| failure | result |
| resolves with a 500 response | the dialog stays open with the error alert "Couldn't merge the copies. Nothing was changed. Try again in a moment." and both buttons enabled again |
| never reaches the server | the same alert |
| resolves with a 409 response | the dialog stays open with "One of these images changed, so nothing was merged. The list is up to date now. Check it and try again.", and the list is reloaded |
| resolves with a 404 response | the dialog closes, the info toast "That image no longer exists" (id "media-not-found-12") shows, and the list is reloaded |
Rule: Sessions
Scenario Outline: Session errors are global for the new calls
When <call> resolves <status>
Then the user is sent to <destination> and no local copy shows
Examples:
| call | status | destination |
| POST /v1/media/ (XHR) | 401 | /login?redirect=/media&reason=expired |
| POST /v1/media/ (XHR) | 403 | /register/pending |
| POST /v1/media/merge | 401 | /login?redirect=/media&reason=expired |
| DELETE /v1/media/{id} | 403 | /register/pending |
| GET /v1/media/?hash= | 401 | /login?redirect=/media&reason=expired |
@@ -0,0 +1,58 @@
# QA: Media page, part 2 (upload, selection, duplicates)
## Scope note
SPA only. No Go test and no `test/` file; run the Go suite once (`task test`) to confirm nothing regressed. The server never returns 429 for POST /v1/media/ today (no rate limiter is mounted on the media router), so every 429 case is proven with a mock only.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mount MediaPage in DashboardShell with a verified session and a test router at /media, as in the B1 spec. Mock useApi's `request` for GET/DELETE/merge and the list; replace `XMLHttpRequest` with a controllable stub (per instance: `open`, `send`, `abort`, `upload.onprogress` with `loaded`/`total`, `status`, `responseText`, `getResponseHeader('Retry-After')`, `load`, `error`, `abort` events). Stub `crypto.subtle.digest` to a known hex per file with `vi.stubGlobal`, and to `undefined` for the unavailable case. Use fake timers for the 150 ms skeleton, the 120 ms search debounce and the 1 s Retry-After tick. Sample data is the 12-image fixture of media_browse.feature. Files are built with `new File([...], name, { type })` and a `size` override for over-limit sizes.
1. **Hashing lib** (pure): digest of a known byte array gives the expected lowercase 64-char hex; `crypto.subtle` undefined gives null; nothing in the lib imports from the Media page.
2. **Browser checks**: the six rows of the .feature, plus 10485760 bytes accepted and 10485761 rejected; size beats type; a rejected file triggers no hash call, no GET and no XHR; titles and text exact.
3. **Entry points**: Upload (desktop header), the mobile pinned button and "Upload an image" each click the hidden `input[type=file]` (multiple, exact accept list).
4. **Drop overlay**: dragenter with Files shows the copy exactly; dragleave hides; drop starts uploads; a drag without Files shows nothing; mobile pinned button hidden while shown.
5. **Queue**: 6 valid files give at most 2 XHRs open at once; order kept; header "Uploading 6 images" and "N of 6 done" counts settled rows; "Uploading 1 image" singular; progress 1.1 of 2.6 MB reads "Uploading 1.1 of 2.6 MB" with a 42 % bar; 201 gives "Added to your library"; a silent list reload follows; files added while open are appended.
6. **Duplicate check**: GET `?hash=` precedes POST; a match gives the Duplicate row with exact text (oldest match named); slot released; "Use existing" drops the row and selects the id (bulk bar "1 selected", live region text), including the reset-to-All path; "Upload anyway" sends POST; crypto undefined and lookup failure skip straight to POST.
7. **Server statuses**: 201, 413, 415, 400, 500, network error: row copy and icon exact; "Try again" re-sends only that file with no new GET `?hash=`.
8. **Cancel**: row x aborts that XHR and removes the row, the next file starts; "Cancel all" aborts all and removes waiting rows; panel closes when empty.
9. **Summary**: Examples-driven summary lines; "Upload finished" / "{n} images" and "Dismiss"; "Nothing was uploaded" with "3 files were rejected" and with "2 files need attention", no summary; live-region text; Dismiss removes every row.
10. **429**: `Retry-After: 42` gives the alert copy and "Try again in 0:42" (disabled, timer icon; mobile pinned button too); the 429'd file returns to Waiting; advancing 42 s re-enables, removes the alert and resumes the queue; Added rows kept; missing header gives 1:00; drops ignored while counting.
11. **Offline**: Upload, "Upload an image", bulk "Delete" and "Merge into this one" disabled; drops ignored; Clear and selection still work; back online re-enables.
12. **Selection**: checkbox visibility (hover, focus, any selection, always on mobile); tick and Space toggle; Enter opens the panel and ticking does not; live-region texts for select, unselect and "Selection cleared"; the checkbox is not a tab stop; select-all states (unchecked, partial, checked) in the grid row and the table header (no row in the mobile card list); Escape closes the panel first and clears the selection next; Clear; selection cleared on q/tab/sort change, kept on view change.
13. **Bulk bar**: copy, buttons, desktop and mobile placement classes, replaces the mobile pinned button.
14. **Bulk delete**: dialog copy exact (title, description with the summed count, rows "Used in 2", "Used in 3", "Unused"), singular form; no usage request; in progress (one DELETE per id, buttons disabled, not dismissible); done (cards leave, selection empty, focus target, toast id and text, silent reload); partly failed (two removed, failed one stays selected, bar "1 selected", toast with Retry and no auto-dismiss, Retry re-sends only the failed DELETE); several failures title; 404 counted as deleted; all failed gives the inline alert.
15. **Duplicates tab**: enabled, URL and request (`filter=duplicates`, limit=100), deep link, "Review duplicates" enabled and switching tab; sort and view toggle shown (sort sent with the request, toggle keeps the preference in the URL); grouping by sha256, group order, headers (including the SVG/unknown uploader case), column order, "Suggested" tag and footer, "Uploaded 14 Jun 2026 by Inès Duarte", "Used in 4 articles" opens the panel; suggested-copy Examples; empty state and "Back to all images"; badge absent at 0.
16. **Merge**: dialog copy exact for floorboards.jpg and another kept copy, singular form; POST body `{ keep_id: 12, merge_ids: [5, 6] }`; in progress not dismissible; 200 gives group removed, toast id "media-merged-12" and text, live-region text, silent reload, "No duplicates" when last; 500 and network give the alert; 409 gives the alert and a reload; 404 gives the info toast and closes.
17. **Global errors**: 401 and 403 from XHR upload, merge, delete and hash lookup call the same redirect handler as useApi; no local copy.
18. **B1 regression**: media_browse specs updated for the superseded statements and the rest still passes.
## Visual (`task visual:check -- media`, desktop and mobile, light and dark)
Reference: docs/design/screens/media.md and `source/MediaFrame.dc.html`. The 20 B2 states lose `skip: 'pending'` and get a setup in `web/visual/states.ts`. None depends on the current date (dates are absolute), so none is `skip: 'clock-sensitive'`. Where a board frame contradicts the prose or fixture (bulk delete done / partly failed, "4 articles", tab badge at zero, "2 of 6 done", merge done with one group left), the user corrects the board; no exception is added.
Harness requirements:
- Upload states use an init-script XHR stub that holds a request at 1.1 of 2.6 MB (42 %); files come from `setInputFiles`; the duplicate state mocks `GET /v1/media/?hash=` by the hash of the file's bytes.
- The drag state dispatches a synthetic `dragenter` with `dataTransfer.types` = `['Files']`.
- The 429 state freezes timers with Playwright `page.clock` so the button reads "0:42".
Setups:
- [ ] Selection and bulk bar (ids 1, 2, 8; partial select-all)
- [ ] Bulk delete: confirm, in progress, partly failed, done
- [ ] Upload: drag over the page, several files mixed, summary, rejected files (413, 400), too many requests (429)
- [ ] Duplicates: groups, empty
- [ ] Merge: confirm, in progress, failed, conflict (409), done
Manual checks screenshots cannot cover:
- [ ] Keyboard and focus order: Space toggles selection, Enter opens the panel, Escape order (panel first, then selection); focus after a bulk delete; dialog focus starts on Cancel.
- [ ] Drag and drop with real files from the OS, including a folder and a non-image drop.
- [ ] Progress bar smoothness, hover and focus rings, reduced motion.
- [ ] Screen reader announcements for selection, upload completion and merge.
## Manual verification
- Real upload round trip against a running server (`task dev`): progress on a large file, 413 at 10 MB + 1 byte, 415, 400 for a corrupt PNG, uploading the same bytes twice (duplicate row, "Upload anyway", Duplicates tab).
- Real SHA-256 over a 10 MB file, and the skip path on an insecure origin (plain http on a LAN address).
- Real merge: article bodies rewritten, counts after the refresh.
- Real 429 cannot be checked: the media router has no limiter.
- Real bulk delete of 20+ images with one deleted from another tab first (404 handling).