Feature: Image variants on the Media page: badge, filter, sort, Variants tab, delete dialogs and activity As an owner or admin of a RenewCMS site I want the Media page to show which images have generated variants, let me generate, preview and purge them for one image, and warn me what a delete also removes So that I can keep the cache of variants under control without leaving the library Background: Given the spec of record is docs/design/screens/image-variants.md sections 3, 4 and 8 (the Generate all and Cache purged: by image rows), docs/design/screens/media.md, docs/design/screens/activity.md, the board docs/design/source/RenewCMS Screens Image variants.dc.html drawn by VariantsFrame.dc.html (frames 3a to 3f and 4a to 4h), and docs/design/routes.md And this slice is SPA only: the API is the one of features/image_variants_per_image_api.feature and features/image-variants.feature, and features/media_browse.feature and features/media_upload_selection_duplicates.feature stay as they are except where this file says so And the editor picker is in features/image_variants_editor_picker.feature, and the Settings → Media → Cache screen is not in this slice And every new English string has a French entry in web/src/i18n/messages.ts, only design copy is verbatim, and French wording is proposed And the page uses Nuxt UI components only and design tokens, with no raw hex value and no arbitrary Tailwind colour, and no comment is added to any file And the stored url of an image is "/static/uploadedImages/" and the url of its variant is that plus "?v=" And sizes use the library's formatter ("8 KB", "260 KB", "3.9 MB"), dimensions in the library read "4032 × 3024" and in variants "400×300", "800 × auto", "auto × 2400" And the library fixture is the 12-image fixture of features/media_browse.feature, extended with variant_count and variant_size from the board: | id | filename | size | variant_count | variant_size | total | | 1 | north-wall.jpg | 4089446 | 4 | 266240 | 4.2 MB | | 2 | kitchen-after.webp | 1258291 | 5 | 419840 | 1.6 MB | | 3 | kitchen-before.webp | 1153434 | 5 | 404480 | 1.5 MB | | 4 | logo-renew.svg | 8192 | 0 | 0 | 8 KB | | 5 | floorboards-copy.jpg | 2516582 | 3 | 245760 | 2.6 MB | | 6 | IMG_2208.jpg | 2516582 | 0 | 0 | 2.4 MB | | 7 | IMG_4471.jpg | 3250586 | 2 | 153600 | 3.3 MB | | 8 | window-frame.jpg | 1887436 | 0 | 0 | 1.8 MB | | 9 | stone-wall-detail.jpg | 3250586 | 1 | 61440 | 3.2 MB | | 10 | bathroom-tiles.png | 880640 | 3 | 194560 | 1.1 MB | | 11 | lime-plaster.jpg | 2202009 | 4 | 286720 | 2.4 MB | | 12 | floorboards.jpg | 2516582 | 5 | 440320 | 2.8 MB | And the five enabled variants of the Variants tab, in the order the API returns them (name), are card (inside 800 x auto webp), hero (cover 1200 x 630 webp), og (cover 1200 x 630 jpeg), square (cover 600 x 600 jpeg), thumb (cover 400 x 300 webp), and the disabled variant "print" is never listed And the Variants tab of image 1 reads its rows from GET /v1/media/1/variants: output 800×600, 1200×630, 1200×630, 600×600, 400×300 and sizes in bytes thumb 18432, card 65536, hero 98304, square 83968, og 145408 And the session role comes from GET /v1/auth/me and is "owner" unless a scenario says otherwise And every success toast lasts 5 s and toast ids follow the action-plus-id convention Rule: The library badge Scenario: A card with generated variants carries the badge Given GET /v1/media/ resolves 200 with the extended fixture When the user navigates to /media Then the card "north-wall.jpg" shows the badge "4 variants" after "Used in 2" And the card "kitchen-after.webp" shows "Used in 3" then "5 variants" And the badge has the info-soft fill, the layers icon, 12 px medium text and is 20 px high Scenario Outline: The badge text and its absence Given the Default state has loaded Then the card for "" shows the badges "" Examples: | filename | badges | | north-wall.jpg | Used in 2, 4 variants | | stone-wall-detail.jpg | Duplicate, Used in 1, 1 variant | | window-frame.jpg | Unused | | logo-renew.svg | Used in 5 | | IMG_2208.jpg | Duplicate, Used in 2 | Scenario: The accessible name of a card is unchanged Then the card for "north-wall.jpg" is still named "north-wall.jpg, 3.9 MB, used in 2 articles" Scenario: List view shows no variants badge Given the Default state has loaded with ?view=list Then the table and the card list show no "variants" badge Rule: The variants filter Scenario: The segmented control sits between the search field and the sort menu Given the Default state has loaded at desktop width Then the toolbar reads, left to right, the tabs, the search field, a segmented control "All", "With variants", "Without" (30 px high, 1 px accented border, 12 px horizontal padding) and the sort menu And "All" is selected, with the primary fill and inverted text Scenario: On mobile the control is its own full-width row Given the Default state has loaded below md width Then the toolbar stacks search, tabs, the segmented control with three equal segments of 44 px and 12 px text, then the sort menu and the grid toggle Scenario Outline: Choosing a segment writes the URL and requests the list Given the Default state has loaded When the user selects "" Then the URL and GET /v1/media/ is sent with "" and limit=100 And the list shows a skeleton after 150 ms Examples: | segment | url | request | | With variants | gains ?variants=with | filter=all&sort=newest&variants=with | | Without | gains ?variants=without| filter=all&sort=newest&variants=without | Scenario: All removes the parameter Given the URL is /media?variants=with When the user selects "All" Then ?variants leaves the URL and the request carries no variants parameter Scenario Outline: The query string is the source of truth When the user opens /media Then GET /v1/media/ is sent with "" and the control shows "" Examples: | url | request | control | | ?variants=with | filter=all&sort=newest&variants=with | segment "With variants" selected | | ?variants=without&filter=unused | filter=unused&sort=newest&variants=without | tab "Unused" and segment "Without" selected | | ?variants=all | filter=all&sort=newest | segment "All" selected, URL kept clean | | ?variants=zzz | filter=all&sort=newest | segment "All" selected, URL kept clean | Scenario: With variants returns only the images that have cached files Given GET /v1/media/?...&variants=with&sort=total_size resolves 200 with ids 1, 7, 9, 12, 5, 11, 2, 3, 10 Then nine cards show in that order and each carries a variants badge Scenario: Without variants returns the images with no cached file Given GET /v1/media/?...&variants=without resolves 200 with ids 4, 6, 8 Then three cards show and none carries a variants badge Rule: Sorting by total size Scenario: The new option is last in the sort menu When the user opens the sort menu Then the options read "Newest", "Oldest", "Name", "Largest", "Largest total size (original + variants)" in that order, the last one 340 px wide on desktop and labelled "Largest total size" on mobile And the selected option carries a check Scenario: Choosing it sends sort=total_size When the user picks "Largest total size (original + variants)" Then the URL gains sort=total_size and GET /v1/media/ is sent with sort=total_size And the sort button reads "Largest total size (original + variants)" on desktop and "Largest total size" on mobile Scenario Outline: The meta line shows the total while sorted by total size Given the list is sorted by total size Then the card for "" shows the meta line "" in place of "size · dimensions" Examples: | filename | meta | | north-wall.jpg | 4.2 MB total · 3.9 MB + 260 KB | | kitchen-after.webp | 1.6 MB total · 1.2 MB + 410 KB | | window-frame.jpg | 1.8 MB total | | logo-renew.svg | 8 KB total | Scenario: Other sorts keep the usual meta line Given the list is sorted by Newest Then the card for "north-wall.jpg" shows "3.9 MB · 4032 × 3024" Scenario: A stale sort value is dropped When the user opens /media?sort=total Then the sort shows "Newest" and the URL is clean Rule: Nothing to show with the variants filter Scenario: With variants and an empty cache Given GET /v1/media/?...&variants=with resolves 200 with no item and no q is set and the tab is All Then the layers icon shows with the title "No images with variants", the text "Variants are generated when an image is requested with ?v=, or from its Variants tab." and an outline button "Show all images" And the toolbar, including the segmented control, stays visible Scenario: Show all images resets the variants filter When the user activates "Show all images" Then ?variants leaves the URL, the segmented control reads "All" and the Default cards reappear Rule: Delete dialogs name the generated variants they also remove Scenario: A single delete of an image with variants Given the detail panel is open for "north-wall.jpg" (used in 2, 4 variants of 266240 bytes) When the user activates "Delete" Then the dialog keeps its title "Delete an image that's in use?", its description and its list of the two articles And a note shows in the body with the layers icon on a warning-soft background: "Also removes 4 generated variants (260 KB)" And no extra request is sent to compute it Scenario Outline: The note is singular for one variant and absent for none Given the detail panel is open for "" When the user activates "Delete" Then the note reads "" Examples: | filename | note | | stone-wall-detail.jpg | Also removes 1 generated variant (60 KB) | | window-frame.jpg | no note is shown | Scenario: Bulk delete sums the selection Given the user selected "north-wall.jpg", "kitchen-after.webp" and "window-frame.jpg" When the user activates "Delete" in the bulk bar Then the bulk dialog keeps its copy and its one row per image And the note under the rows reads "Also removes 9 generated variants (670 KB)" Scenario: A bulk delete with no variant in the selection shows no note Given the user selected "window-frame.jpg" and "IMG_2208.jpg" Then the dialog shows no note Scenario: The delete flow itself is unchanged Then the pending, failure, 404 and success behaviours of features/media_browse.feature apply, and after a successful delete the list is requested again in the background Rule: The Variants tab in the detail panel Scenario: The panel gets two tabs for an owner or an admin When the user opens the panel of "north-wall.jpg" Then two tabs sit under the 56 px header: "Details" (selected) and "Variants", 40 px high on desktop and 44 px on mobile, 20 px apart, with a 2 px primary underline on the selected one And the Details tab is the existing panel body, unchanged Scenario: A session that is not owner or admin sees no tab Given the session role is neither "owner" nor "admin" When the user opens a panel Then no tab strip shows, no request to /v1/media/{id}/variants is sent, and the panel is the existing one Scenario: Selecting the tab requests the variants once When the user selects "Variants" Then GET /v1/media/1/variants is sent And selecting "Details" then "Variants" again does not send it a second time for the same open panel Scenario: Loading Given GET /v1/media/1/variants has not answered Then nothing is drawn in the tab body for 149 ms And at 150 ms a region announces "Loading variants" with aria-busy="true" and holds 5 skeleton rows Scenario Outline: The variants request fails Given GET /v1/media/1/variants fails with Then an error alert shows the title "Couldn't load variants", the text "Check your connection and try again." and a "Try again" button with a rotate-cw icon When the user activates "Try again" Then the request is sent again Examples: | failure | | status 500 | | a network failure | Scenario: Partly generated Given GET /v1/media/1/variants resolves with card, hero, square and thumb generated and og not Then the tab shows, in order: the Original block, the summary, the list And the Original block (72 x 54 thumbnail, 12 px gap, 12 px padding, 1 px border, --ui-bg-muted) reads "Original · kept untouched", "north-wall.jpg", "JPEG · 4032 × 3024 · 3.9 MB" And the summary reads "4 of 5 generated" in semibold next to a "Generate all" button with the zap icon And the list is a bordered group of five rows in the order card, hero, og, square, thumb Scenario Outline: A generated row Then the row "" shows its name in mono 13 px medium, the badge "Generated" (success-soft, circle-check), and the meta "" And the part " vs original" is in the success colour and the meta before it is toned 12 px text Examples: | name | meta | saving | | thumb | 400×300 · WebP · 18 KB · -99% | -99% | | card | 800×600 · WebP · 64 KB · -98% | -98% | | hero | 1200×630 · WebP · 96 KB · -98% | -98% | | square | 600×600 · JPEG · 82 KB · -98% | -98% | Scenario: A not generated row Then the row "og" shows the badge "Not generated" (outline, circle-dashed) and the meta "1200×630 · JPEG" with no size and no saving Scenario Outline: The saving against the original Given the original is 4089446 bytes When a variant file is bytes Then the saving reads "" Examples: | size | text | | 18432 | -99% | | 65536 | -98% | | 98304 | -98% | | 145408 | -96% | | 3250586 | -21% | | 4089446 | 0% | | 4500000 | +10% | Scenario: Nothing generated yet Given GET /v1/media/1/variants resolves with no row generated Then the summary reads "0 of 5 generated" over the muted line "Nothing generated yet." And every row shows "Not generated" Scenario: Row actions on desktop are icon buttons Given the viewport is 640 px wide or more Then a generated row shows three 28 px square icon buttons Copy URL (link), Preview (eye) and Purge (eraser), each with a tooltip and an accessible name equal to its label And a not generated row shows Copy URL (link) and Generate now (zap) Scenario: Row actions on mobile are labelled buttons on their own line Given the viewport is below 640 px Then each action is a 44 px high labelled button on a line under the meta, "Copy URL", "Preview", "Purge" or "Copy URL", "Generate now" Scenario: Generate all is disabled when everything is generated Given all five rows are generated Then "Generate all" is disabled Scenario: Generate now When the user activates "Generate now" on the row "og" Then POST /v1/media/1/variants/og is sent And the row "og" shows the badge "Generating" (info-soft, loader-circle), its actions are gone, its background is --ui-bg-elevated and the other rows stay as they are And the summary shows "Generating variants…" in the info colour and "Generate all" reads "Generating…" with a loader-circle and is disabled When the API answers 200 with the item generated Then the row shows "Generated" with its meta, the summary reads "5 of 5 generated" And a toast shows icon circle-check, id "media-variant-generated-1-og" and title "Generated og" Scenario: Generate all Given only thumb is generated When the user activates "Generate all" Then POST /v1/media/1/variants is sent And the four rows not generated show "Generating", the generated row stays, and the summary and button read as above When the API answers 200 with "generated" 4 and the five items Then every row shows "Generated" and a toast shows id "media-variants-generated-1" and title "Generated 4 variants" Scenario: A single generated variant reads in the singular When Generate all answers "generated" 1 Then the toast title is "Generated 1 variant" Scenario: Generate all with a failure When Generate all answers 200 with "generated" 4, "failed" 1, failures [{"name": "hero", "cause": "too_large"}] and the five items Then the row "hero" shows the badge "Failed" (error-soft, circle-x), a line with a circle-alert icon in the error colour at 12 px reading "Image too large", and a "Retry" button with a rotate-cw icon (labelled, 28 px high, 12 px text) And the summary reads "4 of 5 generated" and the toast reads "Generated 4 variants" Scenario Outline: A single generation fails with a cause When POST /v1/media/1/variants/hero answers with the plain text body Then the row "hero" shows "Failed" and the line "" with "Retry" And an error toast shows the title "Couldn't generate hero." and the text "." Examples: | status | cause | | 413 | Image too large | | 415 | Unsupported source format | Scenario: Failed state is not remembered Given the row "hero" shows "Failed" When the panel is closed and opened again, and the tab is selected Then the row "hero" shows "Not generated" Scenario: Retry sends the same request When the user activates "Retry" on the row "hero" Then POST /v1/media/1/variants/hero is sent and the row shows "Generating" Scenario Outline: Other failures of a generation When POST /v1/media/1/variants/og answers Then the row "og" returns to "Not generated" And Examples: | status | result | | 500 | a toast shows "Something went wrong" and "The server couldn't finish. Try again. If it keeps happening, check the server log." | | 403 | a toast shows "You don't have access" and "Only admins can manage image variants." | | 404 | a toast shows "Not found" and "This variant or image no longer exists. Refresh the page." and the tab is requested again | | network failure | the same toast as 500 | Scenario: The image is gone When the tab is requested again after a 404 and GET /v1/media/1/variants answers 404 Then the panel closes, the image leaves the list and the info toast "That image no longer exists" shows, as in features/media_browse.feature Scenario: Copy URL copies the absolute variant url When the user activates "Copy URL" on the row "thumb" Then "{origin}/static/uploadedImages/?v=thumb" is written to the clipboard, also for a row that is not generated And a toast shows id "media-variant-url-copied-1-thumb", title "URL copied" and text "Paste it wherever you need the image address." Scenario: Purge a generated row When the user activates "Purge" on the row "thumb" Then DELETE /v1/media/1/variants/thumb is sent When the API answers 200 {"purged_files": 1, "purged_bytes": 18432} Then the row shows "Not generated" with no size, the summary reads "3 of 5 generated" and a toast shows id "media-variants-purged-1-thumb" and title "Purged 1 file (18 KB)" Scenario Outline: Purge fails When DELETE /v1/media/1/variants/thumb answers Then the row stays "Generated" and the toast of the back-office error table for shows Examples: | status | | 403 | | 404 | | 500 | Scenario: Preview When the user activates "Preview" on the row "thumb" Then a dialog opens, 880 px wide on desktop and a bottom sheet on mobile, titled "Preview: thumb", with a "Close" button And two panes sit side by side on desktop (stacked on mobile), each 300 px high (180 px on mobile), bordered, on --ui-bg-muted, overflow scrolling to pan And the left pane shows the variant file at actual size under "thumb · 400×300 · WebP" and "18 KB · saves 99%" And the right pane shows the original at actual size under "Original · 4032×3024 · JPEG" and "3.9 MB" And the hint reads "Both images are shown at actual size. Scroll to pan." And focus is trapped in the dialog and returns to the Preview button on close Scenario: No variants defined Given GET /v1/media/1/variants resolves with no item and "unsupported" null Then the Original block shows, then the empty block with the layers icon, the title "No variants defined", the text "Define a variant in Settings to get resized copies of this image." and a link "Settings → Media → Variants" with an arrow-up-right icon to /settings/media/variants And no summary, no list and no "Generate all" show Scenario: An SVG source Given the panel is open for "logo-renew.svg" and GET /v1/media/4/variants resolves {"unsupported": "svg", "items": []} Then the Original block reads "SVG · Vector · 8 KB" And an info alert shows the title "Variants aren't generated for this format." and the text "SVG images are served as they are." And no summary, no list and no "Generate all" show Rule: Generate all and purge by image in the Activity screen Scenario Outline: The two new sentences Given an activity event with action "" and outcome "success" and target name "north-wall.jpg" Then the Action cell reads "" with "north-wall.jpg" in semibold, last Examples: | action | sentence | | media.variant_generate_all| Generated all variants of north-wall.jpg | | media.variant_purge_image | Purged the variants of north-wall.jpg | Scenario Outline: The failed sentences Given an activity event with action "", outcome "failed" and target name "north-wall.jpg" Then the Action cell reads "" and the Failed badge shows Examples: | action | sentence | | media.variant_generate_all| Failed to generate the variants of north-wall.jpg | | media.variant_purge_image | Failed to purge the variants of north-wall.jpg | Scenario Outline: The detail line under the sentence Given an activity event with action "" and params Then a muted 12 px line under the sentence reads "" in the row and in the details panel Examples: | action | params | detail | | media.variant_generate_all| {"generated": 5, "failed": 0, "causes": []} | 5 variants generated | | media.variant_generate_all| {"generated": 1, "failed": 0, "causes": []} | 1 variant generated | | media.variant_generate_all| {"generated": 4, "failed": 1, "causes": ["too_large"]} | 4 generated · 1 failed (Image too large) | | media.variant_generate_all| {"generated": 3, "failed": 2, "causes": ["too_large", "unsupported_format"]} | 3 generated · 2 failed (Image too large, Unsupported source format) | | media.variant_purge_image | {"purged_files": 4, "purged_bytes": 266240} | Removed 4 files (260 KB) | | media.variant_purge_image | {"purged_files": 1, "purged_bytes": 18432} | Removed 1 file (18 KB) | Scenario: Events without a detail line are unchanged Given an event whose action has no detail line, such as "media.rename" Then the row shows no second line Scenario: The French interface Given the locale is French Then the two sentences, their failed forms and the detail lines come from messages.ts and no English string appears Rule: Accessibility, vocabulary and hygiene Scenario: Status is never colour alone Then each row status shows an icon and a word (Generated circle-check, Not generated circle-dashed, Generating loader, Failed circle-x) and each icon-only button has an accessible name and a tooltip Scenario: Touch targets on mobile Given the viewport is below 640 px Then the segmented control, the tabs, the row buttons and the dialog buttons are at least 44 px high Scenario: Progress is announced When a generation starts and ends Then an aria-live="polite" region announces "Generating variants" and the result Scenario: Vocabulary Then the screens say "variant", "generated" and "purge", never "thumbnail preset", "version" or "rendition" Scenario: French strings Given the locale is French Then every new string of this file has a French entry and no English string appears