Feature: Articles list screen As a verified RenewCMS owner I want /article to list every article with its status, let me search and filter it, and let me publish, unpublish or delete a row without leaving the page So that I can manage my whole set of articles from one place, with the list always reflecting what the server actually holds Background: Given /article is a verified route (meta.auth "verified") rendered as ArticlesPage inside DashboardShell, per docs/design/routes.md And ArticlesPage calls GET /v1/article/ once per navigation to load the full list (all articles, online and draft, because the request carries the jwt cookie), and every row action goes over PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish or DELETE /v1/article/{id}; all three endpoints already exist and this slice wires the SPA to them unchanged, except for one persistence-layer change: ArticleRepository.FindByFilters (which backs GET /v1/article/) gains a .Preload("Images") call, mirroring the one ArticleRepository.Get already has, so every article in the list response carries its real Images array instead of coming back empty; no domain or use-case change is needed, since article.Article.Images already exists And the PageHeader shows breadcrumb "Articles" (mobile title "Articles") And search and the active tab live in the URL query string (?q=, ?status=), so the filtered view can be linked and survives a reload; the list is always sorted by last updated, newest first, with no interactive sort control in this slice; search is case-insensitive, ignores accents, matches anywhere in the title, and is debounced 120 ms; tab counts (All, Online, Drafts) are always computed from the full list fetched by GET /v1/article/, so they never change while the user is only searching And the list is rendered as ArticleTable (desktop, md and up) or ArticleCardList (below md), both fed by the same articles array, loading flag and per-row busy state, and both routed through the same row actions And each row shows its title (a real link to /article/:id/edit), an ArticleStatusBadge (online or draft), and its updated date (relative within 7 days, e.g. "5 days ago"; an absolute date beyond that, e.g. "18 Aug 2026", with the full timestamp in a title attribute) And clicking anywhere on a row except its menu button navigates to /article/:id/edit And each row's ellipsis button (or Enter while it is focused) opens that row's menu, named "Actions for {title}", listing Edit (to /article/:id/edit), Publish or Unpublish (whichever applies to that row's current status), Copy API URL, and, below a separator, "Delete…" in brick red, always last And "Copy API URL" writes the absolute URL "{origin}/v1/article/{id}" to the clipboard; on success it raises a success toast (icon circle-check, id "article-link-copied-{id}"): title "Link copied" text "The API URL for \"{title}\" is on your clipboard."; if the clipboard write itself fails, it raises an error toast instead (icon circle-x, id "article-link-copy-failed-{id}"): title "Couldn't copy the link", with no text line And row actions are never optimistic: a row only changes once the server responds; while a publish or unpublish request for a row is in flight, that row alone is disabled and shows a busy label ("Publishing…" or "Unpublishing…"), and every other row stays interactive And a successful publish or unpublish (200) updates that row's ArticleStatusBadge and updated date in place and raises a success toast (icon circle-check, id "article-published-{id}" or "article-unpublished-{id}" per components.md's action-plus-id toast convention): title "Article published" text "\"{title}\" is now online." for a publish, or title "Article unpublished" text "\"{title}\" is now offline." for an unpublish And a 500 or network failure on a publish or unpublish leaves that row exactly as it was before the request and raises an error toast (icon circle-x, duration 0 so it stays until dismissed or retried, id "article-publish-failed-{id}" or "article-unpublish-failed-{id}"): title "Couldn't publish" or "Couldn't unpublish", text "The server didn't respond. Nothing changed.", with an action "Retry" that re-sends the same request And a 404 on any row action (publish, unpublish or delete) closes any open row menu or delete dialog for that row, removes the row from the list, recomputes the tab counts from the remaining articles, and raises an info toast (icon info, id "article-not-found-{id}"): title "That article no longer exists" text "It was deleted somewhere else, so we removed it from the list." And "Delete…" opens ConfirmDialog: title "Delete this article?" text "\"{title}\" will be deleted. Its images stay in your library. This can't be undone." (the second sentence is present only when the article body holds at least one image), "Cancel" and "Delete article" (danger); while the DELETE request is in flight the dialog cannot be dismissed by Cancel, Esc or a backdrop click, both buttons are disabled, the confirm button reads "Deleting…" with a loading indicator, and aria-busy is set And a 500 on the delete request keeps the dialog open and instead shows an inline alert inside the dialog: "Couldn't delete the article, so it's still there. Try again in a moment.", re-enabling "Cancel" and "Delete article" so the user can retry or back out; the article is left untouched And a successful delete (204) closes the dialog, removes the row, moves focus to the next row (or the page heading if the deleted row was last), recomputes the tab counts, and raises a success toast (icon circle-check, id "article-deleted-{id}"): title "Article deleted" text "\"{title}\" is gone."; this is also where a delete initiated from the editor ends up And the list, sorted by last updated with the most recently updated article first, holds these 6 articles by default: id 1 "How we price a bathroom renovation" (online, updated "2 hours ago"), id 2 "Choosing lime plaster for old stone walls" (draft, updated "Yesterday"), id 3 "Before and after: the Rue Verte kitchen" (online, updated "3 days ago"), id 4 "Winter checklist for timber windows" (draft, updated "5 days ago"), id 5 "Why we reuse original floorboards" (online, updated "18 Aug 2026"), id 6 "Our first passive-house retrofit" (online, updated "2 Jul 2026") — 6 total, 4 online, 2 drafts Scenario: Default — the list loads sorted by last updated, newest first Given GET /v1/article/ resolves 200 with the 6-article fixture above When the user navigates to /article Then the breadcrumb reads "Articles" (mobile title "Articles") And the tabs read "All 6" "Online 4" "Drafts 2" And the rows appear in exactly the fixture's order, each with its title link, its ArticleStatusBadge and its updated date And the table (desktop) carries a visually hidden caption "Articles, sorted by last updated, newest first" Scenario: Loading — skeleton rows while the first request, or a retry, is in flight Given GET /v1/article/ has not yet resolved When the user navigates to /article Then 5 skeleton rows replace the table or card list, and the list container is aria-busy="true" When GET /v1/article/ then resolves 200 with the 6-article fixture Then the skeleton rows are replaced by the Default state's rows Scenario: Row menu open shows Edit, the status-appropriate action, Copy API URL and Delete… Given the Default state, with the row for id 4 "Winter checklist for timber windows" (draft, updated "5 days ago") When the user activates that row's ellipsis button Then that row's menu opens, named "Actions for Winter checklist for timber windows", listing Edit, Publish, Copy API URL, and, below a separator, "Delete…" in brick red And every other row is unaffected Scenario: Copy API URL succeeds — the absolute URL is copied and confirmed Given the Default state, with the row for id 4 "Winter checklist for timber windows" When the user chooses "Copy API URL" from id 4's row menu Then "{origin}/v1/article/4" is written to the clipboard And a success toast appears: icon circle-check, id "article-link-copied-4", title "Link copied", text "The API URL for \"Winter checklist for timber windows\" is on your clipboard." Scenario: Copy API URL fails — the clipboard write itself is rejected Given the Default state, with the row for id 4 "Winter checklist for timber windows" When the user chooses "Copy API URL" from id 4's row menu, and the clipboard write fails Then an error toast appears: icon circle-x, id "article-link-copy-failed-4", title "Couldn't copy the link", with no text line Scenario: Empty — zero articles Given GET /v1/article/ resolves 200 with zero articles When the user navigates to /article Then the tabs read "All 0" "Online 0" "Drafts 0" And an EmptyState (variant no-articles) replaces the list: icon file-text, title "No articles yet", text "Write your first one. It stays a draft until you publish it.", and a primary action "New article" When the user activates "New article" Then NewArticleModal opens Scenario: No results — a search matches nothing, but the tab counts still show the full list Given the Default state has loaded When the user types "tiles" into the search field Then, after the 120 ms debounce, an EmptyState (variant no-results) replaces the list: icon search, title "No articles match \"tiles\"", text "Check the spelling, or search for a shorter part of the title.", and an action "Clear search" And the tabs still read "All 6" "Online 4" "Drafts 2" When the user activates "Clear search" Then the search field is cleared, the Default state's rows reappear, and focus returns to the search field Scenario Outline: An empty tab shows its own EmptyState, while the other tab still counts correctly Given GET /v1/article/ resolves 200 with When the user navigates to /article and selects the "" tab Then the tabs read "" And an EmptyState replaces the list: title "" text "<text>" action "<action>" Examples: | fixture | tab | counts | title | text | action | | 4 articles, all online, none drafts | Drafts | All 4, Online 4, Drafts 0 | No drafts | Everything you've written is online. | New article | | 4 articles, all drafts, none online | Online | All 4, Online 0, Drafts 4 | Nothing is online yet | Publish a draft and it will appear on your website. | Show drafts | Scenario: Action in progress — publishing a row disables only that row Given the Default state has loaded When the user chooses "Publish" from id 4 "Winter checklist for timber windows"'s row menu Then PATCH /v1/article/4/publish is sent And that row alone is disabled and shows the busy label "Publishing…", while the other 5 rows stay interactive Scenario: Published — a 200 updates the row in place and raises a toast Given the user chose "Publish" from id 4 "Winter checklist for timber windows"'s row menu, and the request is in flight When PATCH /v1/article/4/publish resolves 200 with the article, is_online true, updated "Just now" Then id 4's row shows an online ArticleStatusBadge and updated date "Just now", no longer busy And a success toast appears: icon circle-check, id "article-published-4", title "Article published", text "\"Winter checklist for timber windows\" is now online." Scenario Outline: Action failed, publish — a 500 or a network error leaves the row unchanged and offers Retry Given the user chose "Publish" from id 4 "Winter checklist for timber windows"'s row menu, and the request is in flight When PATCH /v1/article/4/publish <failure> Then id 4's row stays unchanged (draft, updated "5 days ago", no longer busy), because a row never changes before the server responds And an error toast appears, staying until dismissed or retried: icon circle-x, id "article-publish-failed-4", title "Couldn't publish", text "The server didn't respond. Nothing changed.", action "Retry" When the user activates "Retry" Then PATCH /v1/article/4/publish is sent again Examples: | failure | | resolves with a 500 response | | never reaches the server | Scenario Outline: Action failed, unpublish — a 500 or a network error leaves the row unchanged and offers Retry Given the user chose "Unpublish" from id 3 "Before and after: the Rue Verte kitchen"'s row menu, and the request is in flight When PATCH /v1/article/3/unpublish <failure> Then id 3's row stays unchanged (online, updated "3 days ago", no longer busy), because a row never changes before the server responds And an error toast appears, staying until dismissed or retried: icon circle-x, id "article-unpublish-failed-3", title "Couldn't unpublish", text "The server didn't respond. Nothing changed.", action "Retry" When the user activates "Retry" Then PATCH /v1/article/3/unpublish is sent again Examples: | failure | | resolves with a 500 response | | never reaches the server | Scenario Outline: Row vanished, draft row — a 404 on publish or delete removes the row and shows an info toast Given the Default state has loaded When the user <trigger> for id 2 "Choosing lime plaster for old stone walls" And the request resolves 404 Then id 2's row is removed from the list (and any open menu or dialog for it closes) And the tabs read "All 5" "Online 4" "Drafts 1" And an info toast appears: icon info, id "article-not-found-2", title "That article no longer exists", text "It was deleted somewhere else, so we removed it from the list." Examples: | trigger | | chooses "Publish" from its row menu | | confirms "Delete article" in its ConfirmDialog | Scenario: Row vanished, online row — a 404 on unpublish removes the row and shows an info toast Given the Default state has loaded When the user chooses "Unpublish" from id 3 "Before and after: the Rue Verte kitchen"'s row menu And the request resolves 404 Then id 3's row is removed from the list (and any open menu closes) And the tabs read "All 5" "Online 3" "Drafts 2" And an info toast appears: icon info, id "article-not-found-3", title "That article no longer exists", text "It was deleted somewhere else, so we removed it from the list." Scenario: Delete confirm — opens the dialog with the row's title and image count Given the Default state has loaded, and id 4 "Winter checklist for timber windows" has 3 images When the user chooses "Delete…" from id 4's row menu Then ConfirmDialog opens: title "Delete this article?" text "\"Winter checklist for timber windows\" will be deleted. Its images stay in your library. This can't be undone.", "Cancel" and "Delete article" (danger) Scenario: Delete in progress — the dialog cannot be dismissed while the request is in flight Given ConfirmDialog is open for id 4 "Winter checklist for timber windows" When the user activates "Delete article" Then DELETE /v1/article/4 is sent And both "Cancel" and the confirm button are disabled, the confirm button reads "Deleting…" with a loading indicator, aria-busy is set, and Esc and a backdrop click do not close it Scenario: Delete failed — a 500 shows an inline alert inside the dialog and keeps the article Given ConfirmDialog is open for id 4 "Winter checklist for timber windows", and "Delete article" has been activated When DELETE /v1/article/4 resolves 500 Then the dialog stays open, showing the alert "Couldn't delete the article, so it's still there. Try again in a moment.", with "Cancel" and "Delete article" re-enabled And id 4's row is untouched in the list behind the dialog Scenario: Deleted — a 204 closes the dialog, removes the row, and moves focus to the next row Given ConfirmDialog is open for id 4 "Winter checklist for timber windows", and "Delete article" has been activated When DELETE /v1/article/4 resolves 204 Then the dialog closes, id 4's row is removed from the list, and focus moves to the next row And the tabs read "All 5" "Online 4" "Drafts 1" And a success toast appears: icon circle-check, id "article-deleted-4", title "Article deleted", text "\"Winter checklist for timber windows\" is gone."