feat: SEO SPA Sites, Articles and Integration (4/4)

This commit is contained in:
Florian Sylvain committed 2026-10-18 15:51:28 +02:00
1 parent cb737b7ae3
commit 02fb8751fd
47 files changed
+3218 -227

No files matched your search

@@ -0,0 +1,505 @@
Feature: SEO round, slice 4: Sites defaults, Articles search status, Integration "Search and sharing" and suggestion dismissal
As the owner of a RenewCMS install, and as anyone who edits articles or builds a frontend on the API
I want to set the site-wide SEO defaults, see which articles need attention, dismiss suggestions I do not want, and read how the SEO routes work
So that every article resolves good SEO data and a developer can find the routes that serve it
Background:
Given the screens are SitesView (/settings/sites), ArticleView (/article, also reached as /articles), IntegrationView (/integration) and the Editor SEO panel (/article/:id/edit), inside DashboardShell, per docs/design/routes.md
And the visual spec of record is docs/design/screens/sites.md ("Sites: SEO defaults", 5 states), articles.md ("Articles: search status", 3 states), integration.md ("Search and sharing"), editor.md (SuggestionList), docs/design/components.md (SeoStatusChip, SuggestionList), docs/design/seo-slices.md and docs/design/states.md ("SEO round"). Exact copy is read from those files and is not repeated here unless a scenario needs it
And the board frames of docs/design/source/DirectoryFrame.dc.html (Sites form, SuggestionList), AppFrame.dc.html (Articles rows, cards and toolbar) and IntegrationPage.dc.html (group and index) are the layout tiebreaker
And this slice is SPA only and uses the API of features/seo_data_backend.feature and features/seo_output_backend.feature without change. No Go file changes
And the SPA calls, with relative URLs through useApi only: GET /v1/site/, POST /v1/site/ and PATCH /v1/site/{id} (site_name, title_template, default_description), GET /v1/article/ with the optional seo=suggestions parameter, and PATCH /v1/article/{id} with seo_dismissed. The Integration page makes no request
And an article list item carries seo_status ("ready", "suggestions" or "hidden") and seo_suggestions (a count)
And the SPA never re-implements a suggestion rule or an SEO status. It draws the status and the count the list returned
And the API refuses a title_template without the exact, case-sensitive token %title% with 400 invalid_title_template, and refuses site_name above 100, title_template above 200 and default_description above 320 characters
And a new site that omits the three fields gets site_name "", title_template "%title% | %site%" and default_description ""
And the API refuses a seo_dismissed code outside the 11 known codes with 400, replaces the saved list with the list sent, collapses duplicates, and never reports or counts a dismissed code
And the sample site is Renew (id 1, default, renew.example), the sample article title is "How we price a bathroom renovation", and the sample rows are those of the board frame "Articles: search status"
And the roles are those of features/sites_authors_integration.feature: only the owner reaches the Sites form
And every visible string goes through t(...) with the English text as the key and a French entry in web/src/i18n/messages.ts
And the screens use Nuxt UI components and design tokens only, with no raw colors, and no comment is added anywhere
And a scenario tagged @not-in-design uses copy or behaviour that docs/design does not define: it takes the proposal of this feature, has a French entry, and is listed in the .qa.md to be fed back to the design
Rule: Sites form: Site name, Title template and Default description (owner only)
Scenario: Field order in the form, as the board draws it
Given the owner opens "Edit Renew"
Then the fields appear in this order: Name, Key, Base URL, Default social image, Site name, Title template (with its Example), Default description, Default robots policy, Locale, the default switch
And under the fields come the "Canonical URL preview" block and then the "How articles use these defaults" block
And the slide-over keeps its existing width, header, "Cancel" and "Save changes"
Scenario: Edit: SEO defaults
Given the site Renew has site_name "Renew", title_template "%title% | %site%" and default_description ""
When the owner opens "Edit Renew"
Then "Site name" shows "Renew" with "Shown in titles through the template, and sent as the Open Graph site name."
And "Site name" has no "(optional)" label and an empty value is allowed
And "Title template" shows "%title% | %site%" in the mono face with "Use %title% for the article title and %site% for the site name."
And the "Example" row reads "How we price a bathroom renovation | Renew"
And "Default description" is marked optional, shows the placeholder "One or two sentences about the site." and the counter "0 / 320"
And the helper reads "Used when an article has no description, excerpt or body text."
And the block "How articles use these defaults" lists Title "Article title, then the template.", Description "Used last, after the article's description, excerpt and body.", Image "Used when an article has no cover image." and Robots "Used unless an article is hidden from search engines."
Scenario: Add: defaults
When the owner opens "Add site"
Then "Site name" is empty with the placeholder "e.g. Renew"
And "Title template" holds "%title% | %site%"
And "Default description" is empty with the placeholder "One or two sentences about the site."
And the "How articles use these defaults" block is shown
Scenario Outline: The live example follows the typing
Given the form is open and "Site name" holds "<site_name>" and "Name" holds "<name>"
When the owner types "<template>" in "Title template"
Then the "Example" row reads "<example>"
And no request is sent
Examples:
| name | site_name | template | example |
| Journal | Journal | %title% – %site% | How we price a bathroom renovation – Journal |
| Journal | | %title% – %site% | How we price a bathroom renovation – Journal |
| Journal | Le Journal | %site%: %title% | Le Journal: How we price a bathroom renovation |
| Journal | Journal | %title% | How we price a bathroom renovation |
| Journal | Journal | %site% / %site% | Journal / Journal |
@not-in-design
Scenario: The example uses the site name, then the name, then the placeholder
Given "Site name" and "Name" are both empty
Then the Example row uses "Renew" as the site name
And when "Site name" is empty and "Name" is "Journal", it uses "Journal", as the API does
Scenario Outline: Template: not valid
Given the owner typed "<template>" in "Title template"
When the field loses focus or the owner activates "Save changes"
Then the field shows the error "The template must contain %title%." and the Example row is not shown
And nothing is sent, the slide-over stays open and focus goes to the first invalid field
Examples:
| template |
| just text |
| %site% |
| %TITLE% |
| |
Scenario: A valid template clears the error
Given "Title template" shows "The template must contain %title%."
When the owner types "%title%"
Then the error is gone and the Example row is shown again
Scenario: The API refuses a template the form accepted
When PATCH /v1/site/1 answers 400 "invalid_title_template"
Then "Title template" shows "The template must contain %title%." and the values are kept
Scenario Outline: The inputs stop at the API limits
Given the owner types a long text in <field>
Then the input stops accepting characters at <limit>, so the API 400 for those fields is never produced by typing
Examples:
| field | limit |
| Site name | 100 |
| Title template | 200 |
| Default description | 320 |
Scenario Outline: The description counter
When the owner types <n> characters in "Default description"
Then the counter reads "<n> / 320" and nothing is blocked
Examples:
| n |
| 0 |
| 45 |
| 320 |
Scenario: Saving only sends what changed
Given the owner opened "Edit Renew" and changed "Title template" to "%title% – %site%" and nothing else
When the owner activates "Save changes"
Then exactly one PATCH /v1/site/1 is sent containing title_template "%title% – %site%" only
And on 200 the slide-over closes and the existing saved behaviour of the Sites list applies
Scenario: Several SEO fields at once
Given the owner changed "Site name" to "Renew Journal", "Default description" to "Notes on renewal work." and "Default robots policy" to "Noindex"
When the owner activates "Save changes"
Then exactly one PATCH /v1/site/1 is sent containing site_name, default_description and default_robots
And values are compared and sent trimmed, as the existing Name field is
Scenario: Creating a site with the defaults untouched
Given the owner filled Name, Key and Base URL and left the three SEO fields as they were
When the owner activates "Add site"
Then POST /v1/site/ is sent without site_name, title_template or default_description
And the created site has the API defaults
Scenario: Creating a site with SEO values
Given the owner typed "Renew Shop" as site name, "%title% - %site%" as template and "A shop about renewal." as description
When the owner activates "Add site"
Then POST /v1/site/ holds site_name, title_template and default_description with those values
Scenario: A site without the new fields
Given GET /v1/site/ returned a site with no site_name, title_template or default_description
When the owner opens its edit form
Then "Site name" and "Default description" are empty and "Title template" holds "%title% | %site%"
And "Save changes" with no other change sends no request
Scenario: The new fields count as changes
Given the owner typed in any of the three fields
When the owner closes the slide-over
Then the existing "Discard changes?" dialog is shown
Scenario Outline: Save failed
When the save fails with <failure>
Then the slide-over stays open with the notice "Couldn't save the site. Your changes are still here. Try again in a moment."
And the values of the three fields are kept
Examples:
| failure |
| status 500 |
| a network error |
Scenario: Owner only, as on the rest of Sites
Given the session role is "admin" or "verified"
Then there is no Sites entry and no form
When the user opens /settings/sites or /settings/sites?edit=renew from a bookmark
Then the existing 403 page "You don't have access to Sites" is shown, with nothing about SEO added
Scenario: A 403 owner_only on save is handled like any Sites save
Given the owner's role changed while the form was open
When PATCH /v1/site/1 answers 403 owner_only
Then the existing action toast "Only the owner can manage sites" with "Ask the owner of this site." is shown, as for the other Sites fields
Scenario: A 401 sends the user to login
When PATCH /v1/site/1 answers 401
Then the user lands on /login?redirect=/settings/sites&reason=expired, as on the rest of Sites
Rule: Articles list: the search status chip (SeoStatusChip)
Scenario Outline: The chip follows the status the list returns
Given an article item with seo_status "<status>" and seo_suggestions <count>
Then the Status cell shows <cell>
Examples:
| status | count | cell |
| ready | 0 | its Online or Draft badge and no SEO chip |
| suggestions | 2 | its badge and a warning-soft chip with a lightbulb icon and "2" |
| suggestions | 1 | its badge and a warning-soft chip with a lightbulb icon and "1" |
| hidden | 0 | its badge and an outline chip with an eye-off icon and "Hidden" |
| hidden | 2 | its badge and an outline chip with an eye-off icon and "Hidden", no count |
Scenario: Chip placement on desktop
Then the chip sits in the Status cell, in the same row as the Online or Draft badge, 6 px after it, 22 px high, with a 12 px icon
And the Status cell is wide enough that the chip never wraps below the badge in the board's 1440 px frame
Scenario: Chip placement on mobile cards
Given the viewport is 390 px wide
Then the chip sits in the card's meta row under the title, after the Online or Draft badge and before "Updated …", 20 px high
And the card has no other new element
Scenario Outline: Tooltips and accessible names
Given a chip with <status> and <count>
Then its tooltip and its accessible name read "<text>"
Examples:
| status | count | text |
| suggestions | 1 | 1 suggestion |
| suggestions | 2 | 2 suggestions |
| suggestions | 3 | 3 suggestions |
| hidden | 0 | Hidden from search engines |
@not-in-design
Scenario: A touch device has no hover, so the name is read
Given the viewport is 390 px wide
Then the chip carries its text for assistive technology, and the visible mobile chip is the same as on desktop
@not-in-design
Scenario Outline: Items the chip cannot draw show nothing
Given an article item <item>
Then no SEO chip is shown and nothing breaks
Examples:
| item |
| without seo_status |
| with seo_status "suggestions" and seo_suggestions 0 |
| with an unknown seo_status |
@not-in-design
Scenario: A row action keeps the chip
Given a row with a chip
When the user publishes or unpublishes it and the PATCH answers 200
Then the row updates in place and the chip is unchanged
And the chip is refreshed by the next list load
Scenario: The chip changes nothing else in the row
Then the sort, the row menu, the Authors and Site columns, the busy state and the delete flow behave as before
Rule: Articles list: the "Needs attention" toggle
Scenario: The toggle sits next to the search field
Then on desktop a 32 px "Needs attention" switch with a lightbulb icon and a count sits directly left of the search field, on the toolbar baseline
And on mobile a 44 px "Needs attention" switch with the same content is part of the toolbar stack, above the Site filter when it exists
And off, it has the default background, an accented border and the toned text colour
And on, it uses --rc-sand as fill and border, --rc-on-sand as text and weight 500
And it is a switch: role "switch" with aria-checked, reachable by Tab, toggled by Space and Enter
Scenario: Turning it on
Given the URL is /articles?page=3&size=50&q=plaster&status=online&sort=title:asc&site=journal
When the user turns "Needs attention" on
Then the URL becomes /articles?size=50&q=plaster&status=online&sort=title:asc&site=journal&seo=suggestions with page omitted
And GET /v1/article/ is requested with seo=suggestions, size 50, q "plaster", status online, sort title:asc, site journal and page 1
And the tab counts are unchanged
Scenario: Turning it off
Given the URL holds ?seo=suggestions&page=2
When the user turns the switch off
Then seo leaves the URL, the page resets to 1, and q, status, sort, site and size are kept
And GET /v1/article/ is requested without seo
Scenario Outline: Other changes keep the filter
Given the URL holds ?seo=suggestions
When the user <action>
Then ?seo=suggestions stays in the URL
Examples:
| action |
| searches for "plaster" |
| changes the tab |
| changes the sort |
| chooses a site |
| goes to page 2 |
| changes the page size |
Scenario Outline: Changes made while the filter is on reset the page, as everywhere else
Given the URL holds ?seo=suggestions&page=2
When the user <action>
Then the page resets to 1 and ?seo=suggestions stays
Examples:
| action |
| searches for "plaster" |
| changes the tab |
| changes the sort |
| chooses a site |
| changes the page size |
Scenario: Opening a URL with the filter
When the user opens /articles?seo=suggestions&page=2
Then the switch is on and GET /v1/article/?seo=suggestions&page=2 is requested
Scenario Outline: Unknown seo values are dropped silently
Given the URL holds <query>
Then seo is removed from the URL with no notice, the switch is off and the unfiltered list is requested
Examples:
| query |
| ?seo=maybe |
| ?seo= |
| ?seo=READY |
| ?seo=ready |
| ?seo=hidden |
| ?seo=suggestions,x |
Scenario: Needs attention on
Given the filter is on and the response holds the three articles with suggestions
Then the list shows only those three articles, each with its chip
And hidden-only articles are not in the list, because the API does not return them for seo=suggestions
And the tab counts still show the full counts of the unfiltered list
Scenario: Same behaviour on a single-site install
Given GET /v1/site/ returned one site
Then the Site column and the Site filter are not rendered
And the switch, the chips and ?seo=suggestions behave exactly as with several sites
Scenario Outline: The count on the switch
Given the count request answers <answer>
Then the switch reads <label>
Examples:
| answer | label |
| total 3 | "Needs attention" with the count "3" in the mono face |
| total 0 | "Needs attention" with the count "0" |
| still pending | "Needs attention" with no count |
| status 500 or a network error | "Needs attention" with no count and no notice |
@not-in-design
Scenario: Where the count comes from
Given the switch is off
Then one extra GET /v1/article/?seo=suggestions&page=1&size=5 is sent with the current q, status and site, and its total is the count
And the count is read from the main response total when the switch is on, with no extra request
And the count request is refreshed whenever q, tab or site change, and never when only the page, the size or the sort change
And a failing count request never blocks the list, never shows a notice and never changes the URL
Scenario: Needs attention: none
Given ?seo=suggestions, no search, the tab "All", and the response is empty
Then the state shows "Nothing needs attention", "Every article has a clean check. Suggestions are optional, so this is just a bonus." and "Show all articles"
And the paginator and summary are hidden
When the user activates "Show all articles"
Then seo leaves the URL, the page resets to 1, the other query values are kept and the list is requested without seo
@not-in-design
Scenario Outline: Zero results with the filter and a search or another tab
Given ?seo=suggestions and <condition>
And the response is empty
Then the existing <state> is shown and the filter stays on
Examples:
| condition | state |
| a search for "tiles" | "No results" state |
| the tab "Draft" | "No drafts" state |
| the tab "Online" | "Nothing is online yet" state |
@not-in-design
Scenario: Zero results with the filter and a site
Given ?seo=suggestions&site=archive, no search, the tab "All" and the response is empty
Then "Nothing needs attention" is shown rather than "No articles on "Archive""
Scenario: Existing states keep working with the filter
Then Loading, Empty, row actions, delete, the Site filter and pagination behave as in docs/design/screens/articles.md with the filter applied
And an error loading a page keeps the previous rows and shows its Retry notice as before
Rule: Editor SEO panel: dismissing a suggestion (deferred from slice 3)
Scenario: The dismiss column and the footnote, as the board draws them
Given the SEO panel is open and the check answers title_long and cover_alt
Then each suggestion row ends with a dismiss "×" button, as in the board frame
And under the list the footnote reads "Suggestions never block saving or publishing. Dismiss one and it stays hidden for this article."
And the compact suggestion list of the open section keeps no dismiss button and no footnote, as drawn
Scenario: Accessible name and keyboard
Given the dismiss button of the row "The meta title is 74 characters. Search engines may cut it off after about 60."
Then its accessible name reads "Dismiss suggestion: The meta title is 74 characters. Search engines may cut it off after about 60." and it has a visible 2 px focus ring
And Tab reaches it after the row's "Go to meta title" link, and Enter or Space activates it
And the name has a French entry with the message interpolated
Scenario: Dismiss succeeds
Given the article saved seo_dismissed is ["no_au"] and the check answers title_long, cover_alt and no_au was already filtered out
When the user activates the dismiss button of title_long
Then exactly one PATCH /v1/article/12 is sent containing seo_dismissed ["no_au", "title_long"] and no other field
And the row stays and the button is disabled with a busy state until the PATCH answers
And the unsaved draft, the panel values and the editor status are not touched by the request
Scenario: Row removal and count update
When the PATCH answers 200
Then the title_long row leaves the list and the remaining rows keep the order of the check
And one new POST /v1/article/12/seo/check is sent so that the list, the section summary line and the article's status come from the server
And the summary reads "1 suggestion" when one suggestion remains, and "Ready" with "All good" when none remains
And the "Articles" chip count of this article is the one of the next list load
Scenario: Dismissing the last suggestion
Given one suggestion is left
When it is dismissed and the PATCH answers 200
Then "All good" is shown with "{n} dismissed suggestions are hidden." (the board's copy, with the French plural forms) and the link "Show dismissed"
And focus moves to the "Show dismissed" link
Scenario Outline: Focus after a row leaves
Given the list holds three rows and the user dismisses the <position> one
Then focus moves to the dismiss button of <target>
Examples:
| position | target |
| first | the new first row |
| second | the row that took its place |
| last | the new last row |
Scenario Outline: Dismiss failed
When the PATCH fails with <failure>
Then the row stays, the button is enabled again and an error toast shows "Couldn't dismiss the suggestion" with "Try again in a moment."
And the panel, the draft and the editor status are unchanged and no check is sent
Examples:
| failure |
| status 500 |
| a network error |
Scenario Outline: A session problem during the dismissal follows the existing rules
When the PATCH answers <status>
Then <result>
Examples:
| status | result |
| 401 | the user lands on /login?redirect=/article/12/edit&reason=expired, the draft is kept |
| 404 | the "Not found" editor state is shown |
@not-in-design
Scenario: Show dismissed
Given the article has seo_dismissed ["no_au", "short"]
Then "All good" or the list footer shows "2 dismissed suggestions are hidden." and "Show dismissed"
When the user activates "Show dismissed"
Then exactly one PATCH /v1/article/12 is sent with seo_dismissed [] and, on 200, a new check is sent and the restored suggestions appear
Scenario: Dismissal is per article and never blocks anything
Then a dismissed code stays hidden for this article only and for every later check of it
And Save, "Save and publish", Publish and Ctrl or Cmd S stay enabled whatever the dismissed list is
Scenario: Who can dismiss
Given the session role is "owner", "admin" or "verified"
Then the dismiss button is available, since anyone who can edit the article edits its SEO fields
Rule: Integration: the "Search and sharing" group
Scenario: The group sits after Articles
Then web/src/data/apiCatalogue.ts has a group "Search and sharing" (key "seo") placed directly after "Articles" in API_GROUPS
And the group description reads "RenewCMS doesn't render pages or add meta tags. These endpoints give your frontend the resolved SEO data and the list of published URLs."
And the group shows "2 routes"
Scenario: The two cards
Then the group holds GET /v1/article/{id}/seo and GET /v1/site/{key}/sitemap, both with the badge "Public" and "API key OK", collapsed by default
And GET /v1/article/{id}/seo is removed from the "Articles" group and GET /v1/site/{key}/sitemap is removed from the "Sites" group, so no route appears twice
And POST /v1/article/{id}/seo/check stays in "Articles"
Scenario: Article SEO card
When the user expands GET /v1/article/{id}/seo
Then the card describes the resolved SEO object: title with the site template applied, description, canonical_url, robots, og{type,title,description,url,site_name,image,image_alt,locale}, twitter{card,title,description,image,image_alt}, image{url,alt,source}, authors[{name,url,same_as}], published_at, modified_at, fallbacks and json_ld
And it says that drafts answer 404 to anonymous callers
And it shows "No request body.", the fetch, curl and Response tabs in one CodeSnippet with Copy, and the error rows 400, 401 and 404 of the real API
Scenario: Sitemap card
When the user expands GET /v1/site/{key}/sitemap
Then the card shows the query rows page ("A whole number from 1. Default 1.") and size ("25, 50, 100 or 500. Default 100."), the item shape { url, modified_at, noindex } and that the total is in the X-Total-Count header
And the path in its examples uses the sample site key
And it shows the error rows of the real API: 400 with the body-mismatch message, 401, and 404 "site_not_found"
Scenario: Sidebar index and Jump to select
Then the sticky section index lists, in this order, Authentication, Articles, Search and sharing, Media, Users and invites, Settings, Sites, Authors, Images and variants, Session only, Errors, Notes
And the mobile "Jump to" select lists the same options in the same order
When the user activates "Search and sharing" in the index
Then the page scrolls to the group and the item has aria-current while it is in view
And the group has an anchor and a deep link #seo
Scenario: Counts stay in sync
Then the cards per group are Articles 8, Search and sharing 2, Media 10, Users and invites 6, Settings 1
And the group "Sites" shows "1 route" and its description no longer mentions the sitemap
And the total number of endpoint cards in API_GROUPS is unchanged
And web/src/data/__tests__/apiCatalogue.spec.ts and web/src/views/__tests__/IntegrationView.spec.ts use these numbers and the new section list
Scenario: The PATCH reference lists the new fields
Then PATCH /v1/article/{id} lists the optional fields slug, excerpt, seo_title, seo_description, cover_image_id, cover_image_alt, social_title, social_description, social_image_id, canonical_url, noindex and seo_dismissed, with the rules of the API
And that is asserted by apiCatalogue.spec.ts and no field is added twice
Scenario: The 403 row of the status table
Then the 403 row of the Errors table also states, in a note under its messages, that site SEO defaults are owner only and that article SEO fields need only the right to edit the article
And the plain-text messages of that row are unchanged
Scenario: Nothing changes for the other groups
Then the Session only group, the Errors table, the Images and variants section, the Notes and the sample error response are as before, apart from the 403 note
Scenario: The page still makes no request
Then opening and expanding the new cards sends no /v1 request
Rule: French, layering and visual conformance
Scenario: French
Given the language is "fr"
Then every new string of the four screens, including "Needs attention", "Hidden", the tooltips, "Nothing needs attention", the Sites form copy, the dismiss button name, footnote and toast, the new group title, description, card descriptions and query rows, the 403 note, and the @not-in-design copy, is the French message
And a missing French entry fails the i18n spec
Scenario: French plurals of the tooltip
Given the language is "fr"
Then a chip with 1 suggestion has the tooltip "1 suggestion" and with 2 or more "{n} suggestions" in French
Scenario: Every call goes through useApi
Then no component calls fetch directly and every URL is relative
Scenario: Slice 3 leftovers
Then a grep of web/src for hard-coded English in templates and for t(...) keys without a French entry finds none, or every finding is reported in the qa report with its file and line
Scenario: Visual conformance
Then `task visual:check -- articles`, `-- sites`, `-- integration`, `-- editor`, `-- settings`, `-- users`, `-- profile` and `-- home` pass every frame at 1440x900 and 390x844, in light and dark
And that includes the 5 new "Sites: SEO defaults" frames, the 3 new "Articles: search status" frames and every existing frame of those screens
And the slice 3 mobile exceptions for the dismiss column are removed from web/visual/states.ts and the Editor panel frames pass without any exception
And no per-state exception is added; a failure is reported to the user instead
@@ -0,0 +1,104 @@
# QA: SEO round, slice 4 (Sites defaults, Articles search status, Integration, suggestion dismissal)
SPA slice only. No Go change: the API of `features/seo_data_backend.feature` and `features/seo_output_backend.feature` already exists, so this slice has no httptest part. A wrong contract is reported as a backend defect.
Roles: the domain defines only `owner` and `admin`. "verified" is a verified account without either role.
## Automated SPA (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi`'s `request` per case: GET/POST/PATCH /v1/site/..., GET /v1/article/ (items with `seo_status` and `seo_suggestions`, `counts`, `total`), PATCH /v1/article/{id}, POST /v1/article/{id}/seo/check, and the media routes of the shared picker. Mount SitesView, ArticleView, IntegrationView and EditorView inside DashboardShell with a resolved session and a test router. Mock matchMedia for desktop and mobile. Use fake timers for the search debounce. Do not freeze the system date. The rendering of "Updated …" is not asserted. Report the web tests as run or NOT RUN, never as passing when not run. One spec file per numbered group, under `web/src/views/__tests__/` (`SeoSites*.spec.ts`, `SeoArticles*.spec.ts`, `SeoDismiss*.spec.ts`, `SeoIntegration*.spec.ts`).
Regression: every existing Sites, Articles (ArticlesPagination, ArticlesPageSizeMemory, ArticlesSitesAuthors), Integration and EditorSeo* spec now sees the toolbar count request, the new fields and the dismiss buttons. Give them default mocks (count request answers total 3 on the request carrying `seo=suggestions`; sites with the three SEO fields) rather than loosening unmocked-call checks, and show that they still pass.
1. Sites form fields: order, copy of states 1 and 2 (edit and add), placeholders, helpers, the counter, no "(optional)" on Site name, the "How articles use these defaults" block and its placement under the preview block.
2. Live example: the Examples table (site name, name fallback, placeholder "Renew" fallback, `%title%` only, two `%site%`), updated on each keystroke, no request sent; global replace, not first occurrence.
3. Template validation: the four invalid values (`just text`, `%site%`, `%TITLE%`, empty) on blur and on submit, nothing sent, focus on the first invalid field, example hidden, error cleared on a valid value, 400 `invalid_title_template` from the server shows the same message.
4. Limits: maxlength 100, 200 and 320; the counter at 0, 45 and 320.
5. Save payloads: PATCH holds only changed fields (assert exact JSON) for template only and for name + description + robots; POST omits the three fields when untouched and sends them when changed; the discard dialog on a changed SEO field; a site without the fields opens with defaults and "Save changes" sends nothing.
6. Save failure and sessions: 500 and network failure keep the panel and the notice; 403 `owner_only` gives the existing toast; 401 goes to `/login?redirect=/settings/sites&reason=expired`; admin and verified users get the 403 page and no form.
7. SeoStatusChip: the status table (ready, suggestions 1 and 2, hidden with and without a count), placement in the Status cell and in the card meta row, tooltip/accessible name (singular and plural), the unusable items (no seo_status, suggestions with 0, unknown status), the row action that keeps the chip.
8. Toggle and URL: parse and write `seo` (`parseArticleQuery`, `toRouteQuery`, `sameArticleQuery`), page reset, q/status/sort/site/size kept, other changes keep seo, reset-to-1 table, opened with `?seo=suggestions&page=2`, unknown values dropped silently via replace (the six values), the request carries `seo=suggestions` only when on, tab counts unchanged.
9. Count: total 3, total 0, pending, failure (no count, no notice, no URL change); the extra request (`seo=suggestions&page=1&size=5`, with q, status and site) is made only while off, only when q/tab/site change, and the main response total is used while on.
10. Empty states: "Nothing needs attention" with its action (clears seo only), the not-in-design combinations (search, Draft, Online, a site), paginator hidden, Retry notice still works.
11. Single-site install: the Site column and filter are absent and everything above behaves the same.
12. Dismiss ×, rendering: one button per row of the panel list, none in the compact section list; the footnote; accessible name with the message interpolated; Tab order (after "Go to …"); Enter and Space; visible focus class.
13. Dismiss ×, behaviour: success sends exactly one PATCH with `seo_dismissed` equal to the saved list plus the code (assert exact JSON, no other field, no draft field); the button is busy and the row stays until 200; the row leaves; one new check follows; summary line and "All good" follow the check; the draft, the panel values and the editor status are untouched; focus table (first, second, last, and the last remaining row going to "Show dismissed"); 500 and network failure keep the row and show the toast, send no check; 401 mirrors the draft and redirects; 404 shows "Not found"; the three roles; Save and Publish stay enabled; "Show dismissed" sends `seo_dismissed: []` then a check.
14. Integration group: position after Articles, group description, 2 routes, both cards "Public" and "API key OK", expanded content (fields, query rows, tabs, error rows with the real strings), no duplicate route in Articles or Sites, `POST …/seo/check` still in Articles, index and Jump to order, deep link #seo, counts 8/2/10/6/1 and "1 route" for Sites, the 403 note, no /v1 request.
15. Catalogue spec: update the group list, the per-group counts, the Sites description, the "Public" assertions (they currently list `GET /v1/article/{id}/seo` and the sitemap with the Articles and Sites groups) and the 403 note.
16. i18n: every new `t()` key has a French entry (extend the i18n spec), the French plurals, one French walk through the four screens.
## Visual (`task visual:check -- <screen>`, desktop and mobile, light and dark)
Setups go in `web/visual/states.ts`, one per state, with mocked /v1 and the board's own sample data. Hard gate: every capture within 10 %, no per-state exception. Reference frames: `DirectoryFrame.dc.html` (Sites form, SuggestionList), `AppFrame.dc.html` (Articles) and `IntegrationPage.dc.html`.
- Sites: SEO defaults (5): Edit: SEO defaults (Renew, `f.sname`, template `%title% | %site%`, example "How we price a bathroom renovation | Renew"), Add: defaults, Template: custom (site name Journal, template `%title% – %site%`), Template: not valid, Save failed. The existing Sites form frames (7) now contain the three fields and the new block: update their setups with the board values.
- Articles: search status (3): Status chips (rows: 2 suggestions, hidden, 3 suggestions, 1 suggestion), Needs attention on, Needs attention: none. Every existing Articles and pagination frame now shows the toggle with the count 3 and the chips of the board rows, so each setup mocks the count request with total 3. States whose "Updated" column or text is clock-sensitive follow what the existing setups already do (`skip: clock-sensitive`), and state 8 "Published" stays skipped.
- Integration (8 existing states): the index and the mobile select now contain "Search and sharing", and the page has the new group (collapsed). Mock nothing new. The Navigation state's open select must list the new option. If the 403 note makes a capture exceed 10 %, report it to the user; do not add an exception.
- Editor: the dismiss column is now implemented. Remove the five `withMobileException(..., dismissColumnReason)` wrappers (and the `dismissColumnReason` constant, `withMobileException` if nothing else uses it) in `web/visual/states.ts`, covering 'Panel: defaults', 'Panel: outside the soft ranges', 'Panel: cover without alt', 'Panel: choose cover' and the fifth state at line ~5828. Those states must pass `task visual:check -- editor` at the default 10 % in both viewports. If one still fails, report it; do not restore an exception.
- Re-run Settings, Users, Profile and Home: report any capture above 10 %. Any failure needs the user's decision (an `approvedBy` exception or a design update).
## Final pass: slice 3 leftovers (required in the qa report)
1. Grep `web/src` (excluding `__tests__`) for text in templates not passed through `t(...)`, and for `title="..."`, `placeholder="..."` and `aria-label="..."` attributes with English literals, in the files of slice 3 (`SeoPanel.vue`, `SearchSharingSection.vue`, `SuggestionList.vue`, `ApiPreviewDialog.vue`, `SearchPreview.vue`, `SocialCardPreview.vue`, `SeoDisclosure.vue`, `EditorView.vue`, `seoCopy.ts`, `seoApiPreview.ts`, `seoPreview.ts`).
2. Extract every `t('…')` key used by those files and by the files of this slice, and confirm each has an entry in `web/src/i18n/messages.ts` (the i18n spec must cover them). Spot check on the current tree: no missing entry found among the editor strings sampled; the new ones for this slice are still to add.
3. Report findings by file and line. Fix those that belong to this slice. Anything else is reported to the user, not fixed silently.
## Exit conditions
- All Articles, Sites and Integration frames (existing and the 8 new ones) pass `task visual:check`, plus Editor, Settings, Users, Profile and Home re-run with no regression above 10 %.
- The slice 3 dismiss-column exceptions are gone from `web/visual/states.ts` and `grep -n dismissColumnReason web/visual/states.ts` finds nothing; the Editor panel frames pass without exception.
- Vitest specs reported honestly, run or NOT RUN.
- `bun lint` clean. No comment added. No Go file changed.
## Divergences between the design and the real API (feed back to docs/design)
- Sitemap errors: the design says 404 "Site not found" and 400 "Invalid page or size."; the API answers 404 `site_not_found` and 400 with the generic body-mismatch message (`apperr.BodyErrorMessage`). The Integration cards use the real strings.
- Article list: the design (routes.md) describes an array body with `X-Total-Count`; the code reads `{items, total, page, size, counts}`. Unchanged by this slice.
- The 403 note under the Errors table has no layout in the design.
- The 403 note under the Errors table is a design gap: it pushes the following rows down, so Integration "6. Errors" carries an approved exception (16 % desktop, 22 % mobile).
- Hidden chip: the board wraps the "Hidden" chip under the Online badge in the 110 px Status column; the feature says it never wraps, and the app follows the feature.
- Add: defaults: the board's Example row always falls back to "Renew"; the app falls back to the Name, then "Renew", as the feature requires.
- Articles row 5: the design text says row 5 has 1 suggestion, but the board data draws no chip on it; the visual setup follows the board data.
- Picker debt: on mobile the shared media picker sheet is taller and shows more tiles than the board, so Editor "Panel: choose cover" carries an approved mobile exception (29 %). The picker was not changed in this slice.
## Copy and behaviour not in the design (feed back to docs/design later)
Each has a French entry in `web/src/i18n/messages.ts`.
1. Source of the "Needs attention" count (extra request, `total`).
2. Zero count shown as "0", no count while pending or on failure.
3. Empty-state precedence when `seo=suggestions` is combined with a search, a tab or a site.
4. Chips refresh only with the next list load after a row action.
5. Items without a usable status show no chip.
6. Example fallback to the name, then to "Renew" as site name.
7. Accessible name of the chip on touch.
8. The 403 row note on the Errors table.
9. Dismiss ×: accessible name "Dismiss suggestion: <message>", the failure toast "Couldn't dismiss the suggestion" / "Try again in a moment.", the busy state, the focus rules, and "Show dismissed" restoring all codes.
## Proposed French (for review, in `web/src/i18n/messages.ts`)
| English key | French |
| --- | --- |
| Needs attention | À traiter |
| Hidden | Masqué |
| Hidden from search engines | Masqué des moteurs de recherche |
| Nothing needs attention | Rien à traiter |
| Every article has a clean check. Suggestions are optional, so this is just a bonus. | Tous les articles passent la vérification. Les suggestions sont facultatives, c'est un petit plus. |
| Show all articles | Afficher tous les articles |
| Site name | Nom du site |
| Title template | Modèle de titre |
| Use %title% for the article title and %site% for the site name. | Utilisez %title% pour le titre de l'article et %site% pour le nom du site. |
| The template must contain %title%. | Le modèle doit contenir %title%. |
| Example | Exemple |
| Default description | Description par défaut |
| How articles use these defaults | Comment les articles utilisent ces valeurs par défaut |
| Dismiss suggestion: {0} | Ignorer la suggestion : {0} |
| Couldn't dismiss the suggestion | Impossible d'ignorer la suggestion |
| Show dismissed | Afficher les suggestions ignorées |
| Search and sharing | Recherche et partage (already present) |
## Manual checks screenshots cannot cover
- Keyboard: tab order through the Sites form (the new fields sit between the image and the robots policy), Space and Enter on "Needs attention", focus on the first invalid field, the dismiss button order and focus after a row leaves, the index and the Jump to select (Enter, Escape).
- Hover and touch: the chip tooltips on desktop, the chip text read by a screen reader, the toggle's pressed state.
- Real `task dev`: the owner sets a template and sees the title change in "What the frontend gets"; dismiss a suggestion, reload, and see it stay hidden and the article's chip count drop; the Articles filter agrees with `curl '…/v1/article/?seo=suggestions'`; the sitemap card matches `curl` on `/v1/site/renew/sitemap` and its X-Total-Count; read the screens in French.