Feature: Integration: images and variants, and CodeSnippet as the only code block As a developer building a site on RenewCMS I want the Integration page to explain image variants and give me copyable, correct URLs So that I can request the right image size without reading the source Background: Given the spec of record is docs/design/screens/integration.md ("Integration: images and ?v="), docs/design/screens/integration-api-keys.md, docs/design/components.md (CodeSnippet), the boards docs/design/source/IntegrationImages.dc.html and CodeSnippet.dc.html, and docs/design/routes.md And this slice is SPA only: no Go change, and the image API is the one of features/image-variants.feature And /integration is a verified route rendered as IntegrationPage inside DashboardShell, per features/integration.feature And the section "Images and variants" has the anchor id "images", sits where the board draws it (after the API groups and before "Session only" and "Errors"), and has an entry "Images and variants" in the section index and the "Jump to" select And the section index and the "Jump to" select list the sections in page order, so the "Images and variants" entry sits right before "Session only" And the board and its own index disagree on this position, which is logged for the design pass And the section title is an h2 "Images and variants" (18/26) with sub headings h3 (14/22) And the section intro reads "Every image in the media library can be requested as a named variant. Add the ?v= parameter to the image URL. The original file is never changed, and a variant is generated the first time it is requested. Variants are managed in Settings, Media, Variants." And the section calls, with a relative URL through useApi only, GET /v1/image-variants, an owner/admin-only route And an item has the fields name, width, height, fit, format, quality, generate_on_upload, enabled, generated_files and cache_size, and items arrive in name order And only items with enabled true are "enabled variants"; the order of the API response is kept And "first variant" below means the first enabled variant, "second" and "third" the next ones, each falling back to the first when absent And {origin} is window.location.origin and {image} is the builder image, "north-wall.jpg", a fixed sample with no media lookup And a variant URL is "{origin}/media/{image}?v={name}" And every visible string goes through t(...), with the English text as the key and its French entry in web/src/i18n/messages.ts And the section uses Nuxt UI components and design tokens only, with no raw colour And the list is read through the useImageVariantsStore And the board sample data is {origin} "https://cms.renew.example", image "north-wall.jpg" and these variants, in this order: | name | width | height | format | | thumb | 400 | 300 | webp | | card | 800 | null | webp | | hero | 1200 | 630 | webp | | square | 600 | 600 | jpeg | | og | 1200 | 630 | jpeg | | banner | 1600 | 400 | webp | | teaser | 640 | 360 | webp | | sidebar | 320 | null | webp | | gallery | 1000 | 750 | webp | | avatar | 96 | 96 | webp | | wide | 1920 | 800 | webp | | mini | 120 | 90 | webp | Rule: CodeSnippet is the only code block Scenario: CodeSnippet anatomy Given a CodeSnippet with the tabs curl, HTML and JavaScript Then a header row at least 44 px high holds the tab list (aria-label "Language") on the left and the Copy button on the right And the selected tab has text --ui-text-highlighted, weight 500 and a 2 px --ui-primary underline And unselected tabs have --ui-text-toned and weight 400 And below it a pre with tabindex 0 shows the active tab's code in IBM Plex Mono 12/19, scrolls horizontally and has no syntax colouring And the background is --ui-bg-elevated with a --ui-border border and no corner radius And the Copy button is at least 44 px wide, reads "Copy" with icon copy and has the accessible name "Copy code" And each tab and the Copy button show a 2 px --ui-primary focus ring when focused with the keyboard Scenario: A CodeSnippet with one tab shows a plain label Given a CodeSnippet with a single tab "Base URL" Then the label is plain --ui-text-muted text, not a tab, with no underline Scenario: Copy writes the active tab and reads Copied for 2 s Given a CodeSnippet with the tabs curl, HTML and JavaScript and the JavaScript tab active When the user activates "Copy" Then the JavaScript code is written to the clipboard And the button shows icon check and "Copied" in --ui-success And the text lives in an aria-live "polite" region, so "Copied" is announced politely When 2 seconds elapse Then the button reads "Copy" with icon copy again Scenario: Two CodeSnippets copy independently Given the page shows several CodeSnippets When the user activates "Copy" on one of them Then only that one reads "Copied" Scenario Outline: Every code block of the product is a CodeSnippet Then renders as a CodeSnippet with And no CodeBlock, no shiki highlighted block and no hand-rolled copy button remains there Examples: | place | tabs | | Base URL on /integration (label "Base URL" above it) | one tab "Base URL" with {origin} | | the Authentication "API key" example | tabs curl and fetch, GET /v1/auth/me | | an expanded EndpointCard | tabs fetch, curl and Response, fetch active | | the Errors section sample | one tab "Error response · text/plain" | | the "Request an image" snippet | tabs curl, HTML and JavaScript | | the URL builder output | one tab "URL" | | the one-time key of ApiKeyReveal on /integration/api-keys | one tab "API key" | Scenario: The old component is removed Then web/src/components/CodeBlock.vue no longer exists and nothing imports it, including EndpointCard.vue and IntegrationView.vue And web/src/components/CodeText.vue stays, because it renders inline code in prose and not a code block And the shiki highlighter helpers and styles used only by CodeBlock are removed when nothing else uses them And the hand-rolled Base URL box, the error sample pre and the key box of ApiKeyReveal are gone Scenario: The one-time key reveal keeps its behaviour Given the ApiKeyReveal dialog opens after POST /v1/api-key answers 201 Then the key is shown in a CodeSnippet with one tab "API key" and "Copy" And the Copy button receives focus when the dialog opens, as before And activating it writes the full key to the clipboard and reads "Copied" for 2 s, announced politely And the warning "You won't be able to see this key again." and the text "Closing this window discards the key from the screen. Copy it first." stay unchanged Rule: The variants list is reloaded Scenario: Reload triggers Given the session role is "owner" or "admin" Then GET /v1/image-variants is sent on mount And it is sent again on "Try again" And it is sent again on every window focus event while /integration is shown And a reload keeps the active snippet tab and the builder selection when the selected variant is still enabled Scenario: A non-admin never calls the API Given the session role is neither "owner" nor "admin" Then no request to GET /v1/image-variants is sent on mount, on focus or on any other trigger Rule: Section content Background: Given the session role is "owner" Scenario: I1. Many variants Given GET /v1/image-variants answers 200 with the twelve sample variants enabled and "print" (disabled) When the user opens /integration#images Then the h3 "Request an image" shows a CodeSnippet with tabs "curl", "HTML", "JavaScript", with HTML active And the h3 "Variants" shows "12 enabled variants" And the table has the columns Name, Dimensions, Format and Example URL and 12 rows, in API order And the "thumb" row reads "thumb", "400×300", "WebP", "/media/north-wall.jpg?v=thumb" And the "card" row reads "800 × auto" And the table body scrolls vertically at 264 px, because there are more than 6 rows And the note reads "Disabled variants are not served. A request for one returns 400 "Unknown variant"." And "print" is not a row And the h3 "URL builder" shows the Image, Variant (default "thumb"), URL (CodeSnippet) and Preview And the h3 "Responses" shows the responses table Scenario: The disabled-variants note matches the backend Given a variant "print" is disabled When /media/north-wall.jpg?v=print is requested Then the backend answers 400 application/json with {"status":400,"error":"unknown_variant","message":"Unknown variant","detail":"print"} and does not fall back to the original And the note is shown only when the response contains at least one disabled variant Scenario: The snippets use the first variants Given the sample variants Then the curl tab reads: curl -O "https://cms.renew.example/media/north-wall.jpg?v=thumb" And the HTML tab reads: """ North wall """ And the JavaScript tab reads: """ const res = await fetch("https://cms.renew.example/media/north-wall.jpg?v=thumb") if (!res.ok) throw new Error(await res.text()) const blob = await res.blob() """ And the srcset widths 400w, 800w and 1200w are sample values, not read from the variants Scenario: Tables and snippet scroll on narrow screens Given a viewport of 390 px Then the variants table keeps a minimum width of 520 px and scrolls horizontally inside its border And the Responses table keeps a minimum width of 560 px and scrolls horizontally And the CodeSnippet body scrolls horizontally and the page itself does not Scenario: I2. One variant Given GET /v1/image-variants answers 200 with only "thumb" enabled Then the Variants heading reads "1 enabled variant" and the table has one row without a vertical scroll limit And the three snippets use "thumb" for the first, second and third variant And the "Disabled variants are not served" note is not shown Scenario: I3. No variants, admin Given the session role is "admin" And GET /v1/image-variants answers 200 with {"items": []} or only disabled variants Then an empty block with the layers icon shows "No image variants yet" And its text reads "Until you add a variant, any request with the ?v= parameter returns status 400 unknown_variant. Requests without it return the original image." And a primary button "Go to image variants" navigates to /settings/media/variants And the snippets use the placeholder And no table and no URL builder are drawn And the Responses table is drawn Scenario: I4. No variants, not an admin Given the session role is neither "owner" nor "admin" Then no request to GET /v1/image-variants is sent And the empty block shows "No image variants yet" And its text reads "Ask an owner or admin to add one. Until you do, any request with the ?v= parameter returns status 400 unknown_variant. Requests without it return the original image." And there is no button and no link And the snippets use the placeholder Scenario: The empty-state copy is corrected against the backend Then the I3 and I4 texts above replace the design copy "the ?v= parameter is not accepted and every request returns the original image", which is false And the backend returns 400 unknown_variant for any ?v= value when no variants exist, and serves the original only without ?v And the board copy is logged as wrong for the design pass, and docs/design is not edited Scenario: I5. Variant removed Given the URL builder has "hero" selected And a reload of the list returns no enabled "hero" Then the Variant field reads "hero (removed)" and has a --ui-warning border And the URL snippet reads "https://cms.renew.example/media/north-wall.jpg?v=hero" And a warning alert (role alert, triangle-alert icon) reads the title "The variant "hero" no longer exists" And its text reads "There is no fallback to the original. Requests with ?v=hero return status 400 with the body { "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "hero" }. Remove the parameter to get the original image, or pick another variant." And the Preview is dimmed to 50 %, 4 / 3, and reads "No preview: unknown variant" Scenario: A reload on window focus can trigger I5 Given the builder has "hero" selected and the list was loaded with "hero" enabled When "hero" is disabled or deleted elsewhere and the window receives a focus event Then GET /v1/image-variants is sent again and I5 is shown Scenario: I6. Loading Given GET /v1/image-variants has not answered Then three skeleton blocks of 140, 200 and 120 px (--ui-bg-accented) fill a region with aria-busy "true" And the "Request an image" snippet, the variants table, the URL builder and the Responses table are not drawn And the intro paragraph is drawn Scenario Outline: I7. Error loading Given GET /v1/image-variants fails with Then an error alert (role alert, circle-x icon) reads "Couldn't load image variants" And its text reads "The snippets below still work. The variant list and the URL builder need a connection." And a button "Try again" is shown And the "Request an image" snippet is drawn with the placeholder And no table, no URL builder and no Responses table are drawn When the user activates "Try again" Then GET /v1/image-variants is sent again Examples: | failure | | status 500 | | a network failure | Scenario: A 403 on the list is handled as not an admin Given the session role is "owner" but GET /v1/image-variants answers 403 Then I4 is shown And a 401 is left to the global handler of features/api_client_global_error_handling.feature Scenario: I8. Snippet copied Given the sample variants and the HTML tab active When the user activates "Copy" on the "Request an image" snippet Then the HTML code is written to the clipboard And the button reads "Copied" with icon check for 2 s, announced politely And the URL builder's Copy button still reads "Copy" Scenario: The URL builder Given the sample variants Then the Image field is a select showing "north-wall.jpg" and the Variant field is a select listing the enabled variants When the user picks "square" in Variant Then the URL snippet reads "https://cms.renew.example/media/north-wall.jpg?v=square" And the Preview is a framed placeholder with aspect-ratio width / height of the variant, and 4 / 3 when the width or the height is null, shows the label "square · 600×600 · JPEG" and is announced with role img and the same aria-label And the Preview is at most 240 px high and full width And no image is requested from the server Scenario: I9. URL builder copied Given the URL builder shows "https://cms.renew.example/media/north-wall.jpg?v=thumb" When the user activates "Copy" on the URL snippet Then that exact URL is written to the clipboard And the button reads "Copied" for 2 s, announced politely And the "Request an image" snippet's button still reads "Copy" Scenario: The Responses table Given any state except Loading and Error loading Then the table has the columns Request, Status and Response and these rows: | request | status | response | body | | /media/photo.png?v=thumb | 200 | The variant image. Generated on first request. | | | /media/photo.png?v=unknown | 400 | Unknown variant | { "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "unknown" } | | /media/photo.png?width=400 | 400 | Unsupported parameter | { "status": 400, "error": "unsupported_parameter", "message": "Unsupported parameter", "detail": "width" } | | /media/missing.png?v=thumb | 404 | Not found | 404 page not found | | /media/huge.png?v=thumb | 413 | Source image too large | The source image is too large to generate this variant. | | /media/photo.png?v=thumb | 500 | Unable to read the file | Unable to read the file | And the body of the 400 rows is application/json and monospace --ui-text-muted below the response text And the 404 body is text/plain "404 page not found" And the 413 and 500 bodies are text/plain And the table has no 409 and no 422 row And the 413 and 500 rows are added although the board lacks them, because the handler returns them, and this is logged for the design pass Scenario: The error sample tab names its content type Then the Errors section sample keeps its single tab "Error response · text/plain" And the image JSON errors are covered only by the Responses table, where their bodies are application/json And no snippet labelled text/plain shows an unknown_variant or unsupported_parameter body Scenario: The page calls only the image variants route When the user opens /integration as an owner or admin Then GET /v1/image-variants is sent once on mount, plus once per "Try again" and once per window focus event And no other /v1 call is made And for any other role no /v1 call is made Rule: The existing Integration surfaces use CodeSnippet Scenario: Authentication example Then the "API key" card shows one CodeSnippet with tabs curl and fetch for GET /v1/auth/me, curl active, using {origin} And there is no outer UTabs wrapper and no label "Shell" or "JavaScript" Scenario: Endpoint card examples When the user expands a card Then one CodeSnippet holds the tabs fetch, curl and Response, fetch active And the tab list uses the underline style, not the UTabs link style And the Copy button copies the active tab and its accessible name is "Copy code" And the "Errors" list below it is unchanged Scenario: Base URL and error sample Then "Base URL" is a label above a CodeSnippet whose only tab is "Base URL" and whose code is {origin} And the Errors sample is a CodeSnippet reading "HTTP/1.1 403 Forbidden", "Content-Type: text/plain", a blank line, "This action needs a real session." Scenario: Copy buttons of the Integration page are independent When the user copies the Base URL Then only the Base URL button reads "Copied"