Feature: Image variants cache panel: cache statistics, purge by scope, request tracking and the Cache screen As an owner or admin of a RenewCMS site I want to see what the variant cache holds and to purge all of it, the unused part, one variant or one image So that I can reclaim disk without deleting a variant, and the activity log records every purge Background: Given the spec of record is docs/design/screens/image-variants.md sections 5 and 8, its "Back-office API errors" table, the cache frames of docs/design/source/VariantsFrame.dc.html and docs/design/routes.md And the routes the design marks "proposed" are fixed here: GET /v1/image-variants/cache and POST /v1/image-variants/cache/purge, mounted in NewImageVariantRouter in api/router.go behind the existing owner or admin middleware chain, so a request with an API key has no user_id claim and answers 401 And features/image-variants.feature and features/image_variants_per_image_api.feature are the contract for variants, index rows and per-image purge, and are unchanged And a cached file is one VariantFile index row, a row counts whatever the state of its fingerprint, and sizes are in bytes (1 MB = 1048576) And the page is /settings/media/cache, a verified route rendered as CachePage inside DashboardShell, and it supersedes the aria-disabled "Cache" tab of features/image_variants_screens.feature, which becomes a link to this route And request validation goes through validate tags and validator.Validate.Struct, an unreadable JSON body answers 400 with apperr.BodyErrorMessage, a failed validation answers 422, and error responses are plain text from http.Error And new apperr constants are CachePurgeScopeErrorMessage = "Scope must be all, unused, variant or image." and CachePurgeTargetErrorMessage = "A variant name or an image id is required for this scope." And request tracking has two parts: the last time each generated file was requested, and a counter of rejected requests per UTC day And tracking never writes to the database on a request: hits are kept in memory by a thread-safe tracker behind a domain port, merged per (variant, source) key, and flushed in one batch by a background loop every 30 seconds, when 500 distinct keys are pending, on GET /v1/image-variants/cache before it reads, and on graceful shutdown And the flush reaches the database through a domain repository port, the models are added to internal/infrastructure/persistence/database/migrate.go: a nullable last_requested_at column on ImageVariantFile and a new model ImageVariantRejectionDay with a unique day and a count And the SQL stays portable across SQLite, MySQL/MariaDB and PostgreSQL: plain GROUP BY, ORDER BY, LIMIT and comparisons, no dialect function and no upsert syntax outside GORM's portable clauses And the cache body is JSON with snake_case fields, arrays are never null, and the Default fixture below is used by the scenarios And the Default fixture holds six variants, the enabled ones are card, hero, og, square and thumb, the disabled one is print, and the index holds: | variant | files | bytes | | hero | 244 | 88080384 | | card | 318 | 60817408 | | thumb | 412 | 22020096 | | print | 74 | 22020096 | | square | 140 | 19922944 | | og | 96 | 9437184 | And 312 files (42991616 bytes) of enabled variants have a reference time older than 30 days, the reference time of a row being last_requested_at when set and created_at otherwise And the ten images using the most variant bytes are, in order: north-wall.jpg (id 1, 6 files, 6082765 bytes), lime-plaster.jpg (6, 5138022), kitchen-after.webp (5, 4613734), kitchen-before.webp (5, 4089446), floorboards.jpg (5, 3774873), IMG_4471.jpg (4, 3250585), stone-wall-detail.jpg (4, 2936012), bathroom-tiles.png (4, 2726297), roof-lantern.jpg (3, 2411724) and chimney-stack.jpg (3, 2202009) And 37 rejected requests were counted over the last 7 UTC days Rule: Only owners and admins reach the cache routes Scenario Outline: Every cache route is guarded When the client sends with Then the response status is And the plain text body is And no index row, blob, counter or activity event is read or changed Examples: | method | path | credentials | status | body | | GET | /v1/image-variants/cache | no cookie | 401 | apperr.UnauthorizedErrorMessage | | GET | /v1/image-variants/cache | a stale token_version cookie | 401 | apperr.UnauthorizedErrorMessage | | GET | /v1/image-variants/cache | the cookie of an unverified user | 403 | apperr.VerificationRequiredErrorMessage | | GET | /v1/image-variants/cache | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage | | POST | /v1/image-variants/cache/purge | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage | | GET | /v1/image-variants/cache | a valid API key | 401 | apperr.UnauthorizedErrorMessage | | POST | /v1/image-variants/cache/purge | a valid API key | 401 | apperr.UnauthorizedErrorMessage | Scenario Outline: Owners and admins are both allowed When sends GET /v1/image-variants/cache with a valid cookie Then the response status is 200 Examples: | role | | the owner | | an admin | Rule: The cache statistics Scenario: The Default fixture When an admin sends GET /v1/image-variants/cache Then the response status is 200 And the body holds "generated_files" 1284 and "disk_used" 222298112 And it holds "variants_defined" 6, "variants_enabled" 5 and "variants_disabled" 1 And it holds "rejected_requests" 37 and "rejected_window_days" 7 And it holds "unused" {"files": 312, "bytes": 42991616, "days": 30} And "by_variant" lists every defined variant sorted by bytes descending then name ascending: hero, card, print, thumb, square, og, each as {"name", "enabled", "files", "bytes"} And "top_images" lists the ten images above in that order, each as {"id", "filename", "url", "files", "bytes"} where url is "/static/uploadedImages/" Scenario: Counts are the sum of the index rows Then "generated_files" equals the sum of the "files" of "by_variant" and "disk_used" equals the sum of their "bytes" Scenario: A variant without a file is listed with zero Given the variant "banner" is defined and enabled and has no file When an admin sends GET /v1/image-variants/cache Then "by_variant" holds {"name": "banner", "enabled": true, "files": 0, "bytes": 0} and "variants_defined" is 7 Scenario: A disabled variant keeps its files in the totals Then "by_variant" holds print with "enabled" false, "files" 74 and "bytes" 22020096 And print is counted in "generated_files" and "disk_used" but not in "unused" Scenario: An image deleted behind the index is not a top image Given index rows exist for a stored name that no image record has When an admin sends GET /v1/image-variants/cache Then the rows count in "generated_files", "disk_used" and "by_variant" and no entry of "top_images" names that stored name Scenario: Top images are capped at ten and tie-break by id Given eleven images have variant files and images 7 and 8 have equal bytes When an admin sends GET /v1/image-variants/cache Then "top_images" has ten entries and image 7 comes before image 8 Scenario: An empty cache Given the index holds no row When an admin sends GET /v1/image-variants/cache Then the response status is 200 And "generated_files" is 0, "disk_used" is 0, "unused" is {"files": 0, "bytes": 0, "days": 30} And the raw body holds "top_images":[] and every "by_variant" entry has files 0 And "variants_defined" and "rejected_requests" are unchanged Scenario: No variant defined Given no variant exists When an admin sends GET /v1/image-variants/cache Then the raw body holds "by_variant":[] and "variants_defined":0 Scenario: Unused counts only enabled variants older than the cutoff Given a row of "hero" with last_requested_at 29 days ago and a row of "hero" with last_requested_at 31 days ago And a row of "print", a disabled variant, with last_requested_at 90 days ago And a row of "card" with no last_requested_at and a created_at 45 days ago Then the rows of "hero" 31 days ago and of "card" are counted in "unused", and the other two are not Scenario: A row requested exactly 30 days ago is still in use Given a row whose reference time is exactly the cutoff Then it is not counted in "unused" Scenario: The statistics are aggregated, not read per image When an admin sends GET /v1/image-variants/cache Then the file repository answered the totals, the unused totals and the top images in one aggregated call each, never one query per image or per file Scenario: The read flushes pending tracking first Given 12 hits and 3 rejections are pending in memory When an admin sends GET /v1/image-variants/cache Then one batch write happens before the read and "rejected_requests" includes the 3 pending Scenario: The statistics change nothing else When an admin sends GET /v1/image-variants/cache Then no blob is opened, no render happens, no row is deleted and no activity event is recorded Scenario: A failing repository answers 500 Given the file repository fails to total When an admin sends GET /v1/image-variants/cache Then the response status is 500 and the plain text body is "Internal server error" Rule: The last time each generated file was requested is tracked without a write per hit Scenario Outline: What counts as a request for a file When Then for the pair (variant, stored name) in memory and no database write happens Examples: | request | effect | | the public client gets /static/uploadedImages/a1b2.jpg?v=thumb, cached | the last requested time is set | | the public client gets it and the file is generated by that request | the last requested time is set | | the public client gets it with a matching If-None-Match and receives 304 | the last requested time is set | | the public client gets /static/uploadedImages/a1b2.jpg with no variant | nothing is recorded | | the public client gets it with ?v=unknown | nothing is recorded | | the public client gets it with ?v=print, a disabled variant | nothing is recorded | | the public client gets /static/uploadedImages/missing.jpg?v=thumb | nothing is recorded | | an admin sends POST /v1/media/1/variants/thumb | nothing is recorded | | an admin sends GET /v1/image-variants/cache | nothing is recorded | Scenario: A thousand hits on one file are one pending key When the public client requests the same cached file 1000 times Then the database received zero writes during the requests And after a flush exactly one batch write holds one update for that file with the time of the last hit Scenario: The flush writes one batch for many files Given 40 distinct files were requested When the tracker flushes Then the repository received one batch of 40 updates in one call Scenario: Nothing pending means no write When the tracker flushes and nothing was recorded since the last flush Then the repository is not called Scenario: The flush loop and the size trigger Given 500 distinct keys are pending Then a flush is started without waiting for the interval And a flush also happens at each 30 second tick and on graceful shutdown Scenario: A failed flush keeps the pending hits Given the repository fails on the batch When the tracker flushes, then the repository recovers and the tracker flushes again Then the second batch holds the hits of both flushes with the latest time per file and nothing is lost or counted twice Scenario: A request is never blocked by or failed by the tracker Given the repository fails on every batch When the public client requests a cached file Then the response is 200 with the file bytes Scenario: A stored time never moves back Given a row with last_requested_at 10:05 When a batch carries the time 10:01 for that file Then last_requested_at is still 10:05 Scenario: A purged file ignores a late update Given the batch carries a time for a file that was purged since When the tracker flushes Then no row is created and the call succeeds Scenario: Concurrent requests are race free When 50 goroutines record hits and rejections while another flushes Then the totals written equal the recorded ones, with the race detector enabled Rule: Rejected public requests are counted per day Scenario Outline: What counts as rejected When the public client sends GET Then Examples: | url | effect | status | | /static/uploadedImages/a1b2.jpg?v=unknown | the rejected counter of today is +1 | 400 | | /static/uploadedImages/a1b2.jpg?v=print | the rejected counter of today is +1 | 400 | | /static/uploadedImages/a1b2.jpg?width=400 | the rejected counter of today is +1 | 400 | | /static/uploadedImages/a1b2.jpg?v=Thumb | the rejected counter of today is +1 | 400 | | /static/uploadedImages/missing.jpg?v=thumb | the counter is unchanged | 404 | | /static/uploadedImages/a1b2.jpg?v=thumb | the counter is unchanged | 200 | | /static/uploadedImages/a1b2.jpg | the counter is unchanged | 200 | Scenario: A rejection is an in-memory increment When the public client sends 1000 rejected requests Then the database received zero writes during the requests And after a flush one batch adds 1000 to today's day row Scenario: The window is today and the six previous UTC days Given the tracker clock reads 2026-10-08 23:59 UTC and day rows of 2026-10-02 (5) and 2026-10-01 (9) When an admin sends GET /v1/image-variants/cache Then "rejected_requests" counts the row of 2026-10-02 and not the row of 2026-10-01 Scenario: A day boundary starts a new bucket Given a rejection at 2026-10-08 23:59:59 UTC and another at 2026-10-09 00:00:00 UTC Then they are counted in two different day rows Scenario: Old day rows are pruned by the flush Given a day row older than 7 days When the tracker flushes Then that row is deleted and the rows of the window are kept Scenario: A failed flush keeps the pending rejections Given the repository fails on the batch When the tracker flushes, then the repository recovers and flushes again Then the rejections are written once Rule: Purging the cache by scope Background: Given the Default fixture with blobs in image.Storage for every row And images 1 "north-wall.jpg" and 2 "lime-plaster.jpg" hold index rows Scenario: Purge all removes every generated file of every variant When an admin sends POST /v1/image-variants/cache/purge with {"scope": "all"} Then the response status is 200 And the body is {"scope": "all", "purged_files": 1284, "purged_bytes": 222298112, "failed_files": 0} And every generated blob is deleted from image.Storage and the index holds no row And the original blobs and every variant definition are unchanged Scenario: Purge all includes the files of disabled variants Then the 74 files of "print" are among the 1284 purged Scenario: Purge unused removes the unused files of enabled variants only When an admin sends POST /v1/image-variants/cache/purge with {"scope": "unused"} Then the response status is 200 And the body is {"scope": "unused", "purged_files": 312, "purged_bytes": 42991616, "failed_files": 0} And the remaining 972 rows are untouched, including any row of "print" and every row requested within 30 days Scenario: Purge by variant removes the files of that variant only When an admin sends POST /v1/image-variants/cache/purge with {"scope": "variant", "variant": "hero"} Then the body is {"scope": "variant", "purged_files": 244, "purged_bytes": 88080384, "failed_files": 0} And the files of the other variants are untouched Scenario: Purge by variant works for a disabled variant When an admin sends POST /v1/image-variants/cache/purge with {"scope": "variant", "variant": "print"} Then "purged_files" is 74 Scenario: Purge by image removes every variant file of that image Given image 1 holds 6 rows of 6082765 bytes When an admin sends POST /v1/image-variants/cache/purge with {"scope": "image", "image_id": 1} Then the body is {"scope": "image", "purged_files": 6, "purged_bytes": 6082765, "failed_files": 0} And the files of image 2 are untouched and the original of image 1 is unchanged Scenario: The next public request generates a purged file again Given a purge with {"scope": "all"} answered 200 When the public client sends GET /static/uploadedImages/a1b2.jpg?v=thumb Then the response is 200 and a new blob was put Scenario: Purging an empty cache answers zero Given the index holds no row When an admin sends POST /v1/image-variants/cache/purge with {"scope": "all"} Then the response status is 200 and the body is {"scope": "all", "purged_files": 0, "purged_bytes": 0, "failed_files": 0} Scenario: A blob already missing from storage does not stop a purge Given image.Storage reports a missing blob for one row When an admin sends POST /v1/image-variants/cache/purge with {"scope": "all"} Then its row is removed and counted as purged and the others are purged Scenario Outline: A request that cannot be served When an admin sends POST /v1/image-variants/cache/purge with Then the response status is and the plain text body is And no blob is deleted and no row is deleted and no activity event is recorded Examples: | body | status | message | | {"scope": "everything"} | 422 | apperr.CachePurgeScopeErrorMessage | | {"scope": "ALL"} | 422 | apperr.CachePurgeScopeErrorMessage | | {} | 422 | apperr.CachePurgeScopeErrorMessage | | {"scope": "variant"} | 422 | apperr.CachePurgeTargetErrorMessage | | {"scope": "variant", "variant": ""} | 422 | apperr.CachePurgeTargetErrorMessage | | {"scope": "image"} | 422 | apperr.CachePurgeTargetErrorMessage | | {"scope": "image", "image_id": 0} | 422 | apperr.CachePurgeTargetErrorMessage | | {"scope": "image", "image_id": -1} | 422 | apperr.CachePurgeTargetErrorMessage | | {"scope": "variant", "variant": "missing"} | 404 | apperr.VariantNotFoundErrorMessage | | {"scope": "image", "image_id": 9999} | 404 | Image not found | | not json | 400 | apperr.BodyErrorMessage | Scenario Outline: A target given for a scope that ignores it is ignored When an admin sends POST /v1/image-variants/cache/purge with {"scope": "", "variant": "hero", "image_id": 1} Then the response status is 200 and the purged files are those of the scope "" alone Examples: | scope | | all | | unused | Rule: A purge that does not complete reports what it did Background: Given the Default fixture and image.Storage fails to delete the blobs of 644 rows Scenario: A partial purge answers 200 with the failed count When an admin sends POST /v1/image-variants/cache/purge with {"scope": "all"} Then the response status is 200 And the body is {"scope": "all", "purged_files": 640, "purged_bytes": , "failed_files": 644} And the 644 rows are kept, so a second call can retry them Scenario: Retrying removes the rest Given a partial purge answered 200 When image.Storage recovers and the admin sends the same request again Then "purged_files" is 644 and "failed_files" is 0 Scenario: A failure to list the rows answers 500 Given the file repository fails to list the rows to purge When an admin sends POST /v1/image-variants/cache/purge with {"scope": "all"} Then the response status is 500 and the plain text body is "Internal server error" Scenario: Rows are processed in bounded batches When an admin sends POST /v1/image-variants/cache/purge with {"scope": "all"} over 5000 rows Then the rows were read in batches of at most 500 and never all loaded at once Rule: Each purge scope is recorded in the activity log Background: Given recording follows features/activity-log-producers.feature: once, from the use case, after the change, with the request origin And the category is "media" And the domain audit gains the actions media.variant_purge_all and media.variant_purge_unused, and reuses media.variant_purge and media.variant_purge_image Scenario Outline: A purge that removes files is recorded When the owner sends POST /v1/image-variants/cache/purge with Then one event exists with action "", target , outcome "success" and params Examples: | body | action | target | params | | {"scope": "all"} | media.variant_purge_all | none | {"purged_files": 1284, "purged_bytes": 222298112} | | {"scope": "unused"} | media.variant_purge_unused | none | {"purged_files": 312, "purged_bytes": 42991616, "older_than_days": 30} | | {"scope": "variant", "variant": "hero"}| media.variant_purge | {"type": "image_variant", "id": "hero", "name": "hero"} | {"purged_files": 244, "purged_bytes": 88080384} | | {"scope": "image", "image_id": 1} | media.variant_purge_image | {"type": "image", "id": "1", "name": "north-wall.jpg"} | {"purged_files": 6, "purged_bytes": 6082765} | Scenario: A partial failure is a success that names the failed count Given a purge removes 640 files and fails on 644 When the owner sends POST /v1/image-variants/cache/purge with {"scope": "all"} Then one event exists with action "media.variant_purge_all", outcome "success" and params {"purged_files": 640, "purged_bytes": , "failed_files": 644} Scenario: Every file failing is a failed event with a plain reason Given image.Storage fails to delete every blob When the owner sends POST /v1/image-variants/cache/purge with {"scope": "all"} Then the response is 200 with "purged_files" 0 and "failed_files" 1284 And one event exists with action "media.variant_purge_all", outcome "failed" and failure_reason "Something went wrong on our side. Please try again." Scenario Outline: Nothing removed, nothing recorded When Then no event is recorded Examples: | request | | the owner purges all on an empty cache | | the owner purges unused when nothing is unused | | the owner purges the variant "hero" and it has no file | | the owner purges image 1 and it has no file | | the owner sends an unknown scope | | the owner purges an unknown variant | | the owner purges an unknown image | | the owner sends GET /v1/image-variants/cache | Scenario: A failing audit write never changes the response Given the audit repository fails on Append When the owner sends POST /v1/image-variants/cache/purge with {"scope": "all"} Then the response is 200 with the same body and the files are gone Scenario: The events read back through the existing activity API Given one event of each of the four scopes was recorded When the owner sends GET /v1/activity?category=media Then the four events are listed with their action, target and params Scenario Outline: The Activity screen words each scope Given an activity item with action "", outcome "" and the params above Then the sentence reads "" and the detail line reads "" Examples: | action | outcome | sentence | detail | | media.variant_purge_all | success | Purged all variants | Removed 1,284 files (212 MB) | | media.variant_purge_all | failed | Failed to purge all variants | | | media.variant_purge_unused | success | Purged unused variants | Removed 312 files (41 MB) not requested for 30 days | | media.variant_purge_unused | failed | Failed to purge unused variants | | | media.variant_purge | success | Purged the cache of variant “hero” | Removed 244 files (84 MB) | | media.variant_purge | failed | Failed to purge the cache of variant “hero” | | | media.variant_purge_image | success | Purged the variants of north-wall.jpg | Removed 6 files (5.8 MB) | Scenario: A partial purge adds the failed count to the detail line Given an item "media.variant_purge_all" with params {"purged_files": 640, "purged_bytes": 111149056, "failed_files": 644} Then the detail line reads "Removed 640 files (106 MB) · 644 failed" Rule: Cache screen, access and header Background: Given the session user has role "owner" or "admin" from GET /v1/auth/me unless a scenario says otherwise And the page uses Nuxt UI components only and design tokens, with no raw hex value and no arbitrary Tailwind colour And every new English string has a French entry in web/src/i18n/messages.ts And the stats come from GET /v1/image-variants/cache once per navigation, and every size is shown with the formatter of the Variants page (1 MB = 1048576), numbers with thousands separators And plain-text API errors are mapped by status, never shown raw And the desktop breadcrumb is "Settings › Media › Cache", the mobile top bar title is "Cache", and the sidebar item "Settings" is current Scenario Outline: A session whose role is not owner or admin gets the Forbidden state Given the session has When the user opens /settings/media/cache Then the page shows the empty block with a lock icon, the title "You don't have access" and the text "Only admins can manage image variants. Ask an admin if you need one changed." And it offers one button "Back to Home" with an arrow-left icon, which routes to /home And no header, no tabs, no tile and no list is drawn And Examples: | session | request | | no role | no request to GET /v1/image-variants/cache is sent | | role "owner" but the API answers 403 | the request was sent and its 403 is handled here | Scenario: The header and the tabs When the Default fixture is loaded Then the title is "Cache" and the description is "Generated variant files. Purged files are generated again on the next request." And two buttons are shown: "Purge unused" with a clock icon and "Purge all variants" with a trash-2 icon in the danger tone And two tabs follow: "Variants" (icon layers, a link to /settings/media/variants) and "Cache" (icon database, active, medium weight, 2 px primary underline) And activating "Variants" routes to /settings/media/variants, where its own "Cache" tab routes back here Rule: Cache screen, loaded Scenario: The four stat tiles When the Default fixture is loaded Then four tiles show, as label, value and sub text: | label | value | sub text | | Generated files | 1,284 | Across 6 variants | | Disk used | 212 MB | Originals not included | | Variants defined | 6 | 5 enabled, 1 disabled | | Rejected requests | 37 | Last 7 days | And the tiles form four columns on desktop and two on mobile Scenario: The By variant list When the Default fixture is loaded Then the card "By variant" lists in the API order: | name | stats | badge | bar width of the largest | | hero | 244 files · 84 MB | | 100 % | | card | 318 files · 58 MB | | 69 % | | print | 74 files · 21 MB | Disabled | 25 % | | thumb | 412 files · 21 MB | | 25 % | | square | 140 files · 19 MB | | 23 % | | og | 96 files · 9 MB | | 11 % | And the bar width is the bytes of the row over the largest bytes, 0 when the largest is 0 And a disabled variant draws its bar in the border-accented colour and the others in the primary colour And the names are IBM Plex Mono medium and the singular reads "1 file" Scenario: The Top 10 list on desktop When the Default fixture is loaded Then the card "Top 10 images by variant disk usage" lists ten rows numbered 1 to 10, each with a 32 x 24 thumbnail of the original, the filename, "N variants", the size and a "Purge" button with an eraser icon And the first row reads north-wall.jpg, 6 variants, 5.8 MB and the last chimney-stack.jpg, 3 variants, 2.1 MB And the singular reads "1 variant" Scenario: The Top 10 list on mobile Given the viewport is below 640 px When the Default fixture is loaded Then the tiles are two columns, the two cards stack, each Top 10 row is 60 px high with the size after the count ("6 variants · 5.8 MB") and the Purge button is icon only, 44 px square, with aria-label "Purge north-wall.jpg" and a tooltip Scenario: The layout is two columns on desktop Given the viewport is 640 px wide or more Then By variant is 460 px wide and Top 10 takes the rest Rule: Cache screen, loading, empty and error Scenario: Loading Given GET /v1/image-variants/cache is unresolved Then at 149 ms nothing is drawn and at 150 ms the region has aria-busy and the label "Loading cache" And the four tiles show skeletons for the value and the sub text, By variant shows 5 skeleton rows, Top 10 shows 6 skeleton rows on desktop and is hidden on mobile And a response at 100 ms never draws skeletons Scenario: Empty cache Given the stats hold zero files and variants_defined 6 Then the tiles read "0 · Nothing cached" for Generated files, "0 MB · Nothing cached" for Disk used, "6 · 5 enabled, 1 disabled" and "37 · Last 7 days" And an empty block with a database icon shows the title "Nothing generated yet" and the text "Variants are generated on the first request with ?v=, or from an image's Variants tab." And "Purge unused" and "Purge all variants" are disabled and no By variant or Top 10 card is drawn Scenario Outline: Error loading Given the GET answers Then an error alert shows the icon circle-x, the title "Couldn't load cache stats", the text "Check your connection and try again." and the button "Try again" And no tile, list or table is drawn and the alert has no close button When the user activates "Try again" Then GET /v1/image-variants/cache is sent again and the Default fixture renders Examples: | failure | | a 500 | | a network error | Scenario: Purge unused is disabled when nothing is unused Given the stats hold "unused" {"files": 0, "bytes": 0, "days": 30} and 1284 files Then "Purge unused" is disabled and "Purge all variants" is enabled Rule: Cache screen, purge all with a typed word Scenario: The dialog opens with the button disabled When the user activates "Purge all variants" Then a dialog titled "Purge all variants?" shows "This removes 1,284 generated files (212 MB) for every variant. Originals aren't touched. Files are generated again on request." And a field labelled "Type purge to confirm" in IBM Plex Mono is focused, "Cancel" is offered and "Purge all variants" in the danger tone is disabled And no request is sent Scenario Outline: The word unlocks the button Given the dialog is open When the user types "" Then the confirm button is Examples: | typed | state | | pur | disabled | | Purge | disabled | | purge | enabled | | purge | enabled | | purged | disabled | Scenario: Cancel and Escape send nothing When the user activates Cancel, or presses Escape Then the dialog closes, focus returns to "Purge all variants" and no request is sent Scenario: Confirming sends the purge Given the typed word is "purge" When the user activates "Purge all variants" Then POST /v1/image-variants/cache/purge is sent once with {"scope": "all"} and the dialog closes Rule: Cache screen, purge unused Scenario: The dialog states the count from the stats When the user activates "Purge unused" Then a dialog titled "Purge unused variants?" shows "This removes 312 files (41 MB) from enabled variants that haven't been requested for 30 days. Files are generated again on request." And it offers "Cancel" and "Purge 312 files", with no typed word Scenario: Confirming sends the purge When the user activates "Purge 312 files" Then POST /v1/image-variants/cache/purge is sent once with {"scope": "unused"} Rule: Cache screen, purging, done and failed Scenario: Purging Given a purge request is in flight and the stats hold 1284 files Then the page shows an info banner with a loader icon and "Purging 1,284 files" with an indeterminate bar, announced with aria-live="polite" And the tiles and lists are dimmed to 60 % opacity, "Purge unused", "Purge all variants" and every row Purge button are disabled, and the tabs stay active Scenario: Purge done after purge all Given the purge answered {"scope": "all", "purged_files": 1284, "purged_bytes": 222298112, "failed_files": 0} Then a success toast reads "Purged 1,284 files, freed 212 MB" And the stats are fetched again once and the empty cache state shows Scenario: Purge done after purge unused Given the purge answered {"scope": "unused", "purged_files": 312, "purged_bytes": 42991616, "failed_files": 0} Then a success toast reads "Purged 312 files, freed 41 MB" and the stats are fetched again once Scenario: Purge failed Given the purge answered {"scope": "all", "purged_files": 640, "purged_bytes": 111149056, "failed_files": 644} Then an error toast shows the title "Purge didn't finish", the text "640 of 1,284 files were removed. Try again." and the button "Try again" And the stats are fetched again once When the user activates "Try again" Then the same request is sent again, without asking for the word again Scenario Outline: A purge request that errors Given the POST answers Then the banner is removed, the content is restored and a toast shows and <text> Examples: | failure | title | text | | a 400 | Check the request | A value in the request isn't valid. Reload the page and try again. | | a 403 | You don't have access| Only admins can manage image variants. | | a 500 | Something went wrong | The server couldn't finish. Try again. If it keeps happening, check the server log. | | a network error | Something went wrong | The server couldn't finish. Try again. If it keeps happening, check the server log. | Scenario: A 401 sends the user to sign in Given the POST answers 401 Then the user is redirected to sign in and a toast reads "Session expired" with "Sign in again to continue." Rule: Cache screen, purging the variants of one image Scenario: A row Purge sends the image scope When the user activates "Purge" on the north-wall.jpg row Then POST /v1/image-variants/cache/purge is sent once with {"scope": "image", "image_id": 1} and no confirm dialog is shown And while it runs the row shows a loader on its button and the other Purge buttons stay enabled Scenario: Done Given the purge answered {"scope": "image", "purged_files": 6, "purged_bytes": 6082765, "failed_files": 0} Then a success toast reads "Purged 6 files (5.8 MB) for north-wall.jpg" and the stats are fetched again once Scenario: The image no longer exists Given the POST answers 404 Then a toast shows "Not found" and "This variant or image no longer exists. Refresh the page." and the stats are fetched again Scenario: Failed Given the purge answered {"scope": "image", "purged_files": 4, "purged_bytes": 1000, "failed_files": 2} Then the "Purge didn't finish" toast reads "4 of 6 files were removed. Try again." with "Try again" Rule: Accessibility and vocabulary Scenario: Roles and announcements Then the tabs have role tablist and tab, the dialog traps focus and returns it to its opener, the icon-only buttons have an aria-label and a tooltip, touch targets on mobile are at least 44 px, and every status is an icon plus text And no rendered text contains "thumbnail preset", "version" or "rendition" Rule: Layering and hygiene Scenario: The dependency rule holds Then internal/domain/image and internal/domain/audit import only the stdlib and other internal/domain packages And the tracker port, the usage repository port and the cache port are declared in internal/domain/image And the new use cases (cache stats, purge cache) and the tracker import no persistence, mailer, models, gorm, net/http, chi or api package And the handler imports no persistence, mailer, models or gorm package And only api/dependecyInjection.go constructs the tracker, starts its flush loop, flushes it on shutdown and wires the use cases into imagevariant.NewHandler and the serve path of the public handler And a repository method with a single-row Find or Save keeps its current signature And the models are listed in internal/infrastructure/persistence/database/migrate.go And no comment is added to any file, except tool directives