feat: articles list screen

This commit is contained in:
fsylvain committed 2026-09-27 17:12:23 +02:00
1 parent ca3fe20519
commit f6d0d01092
14 files changed
+2353 -25

No files matched your search

+259
View File
@@ -0,0 +1,259 @@
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}\" and its
{n} images will be deleted. This can't be undone." (a components.md convention: the row's
title plus its image count, "and its image" for exactly 1 and no image clause at all for 0),
"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}\" and its images are 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 <fixture>
When the user navigates to /article and selects the "<tab>" tab
Then the tabs read "<counts>"
And an EmptyState replaces the list: title "<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\" and its 3 images will be deleted. 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\" and its images are gone."
+176
View File
@@ -0,0 +1,176 @@
# QA: Articles list screen
## Implementation note for coder (informational, not a QA assertion)
`internal/infrastructure/persistence/repositories/articleRepository.go`'s `FindByFilters` gains
`.Preload("Images")`, mirroring `Get`, so `GET /v1/article/` carries real `Images` arrays for the
delete dialog's count. This is a persistence-layer change asserted by the existing
`var _ domain.Repository = &ArticleRepository{}`; it needs no domain or use-case change, since
`article.Article.Images` already exists.
Toast ids follow components.md's action-plus-id convention: "article-published-{id}",
"article-unpublished-{id}", "article-publish-failed-{id}", "article-unpublish-failed-{id}",
"article-not-found-{id}", "article-deleted-{id}", "article-link-copied-{id}",
"article-link-copy-failed-{id}".
## HTTP-level (Go, `test/`, `task test`)
Build `article.NewHandler(...)` over the real use cases wired to a fake `article.Repository` (in
the style of `test/article_test.go`'s `mockArticleRepo`), mounted on a fresh `chi.NewRouter()`
mirroring `NewArticleRouter` in `api/router.go` (the outer group, no auth, for `GET /`). Call
`validator.InitValidator()` first.
1. **GET /v1/article/ returns each article's images array populated** — seed the fake repository
with an article whose `Images` slice holds two `image.Image` entries (ids 10 and 11), send
`GET /v1/article/` with `httptest`, decode the JSON array, and assert the matching entry's
`images` field is a 2-element array containing those ids (not `null` and not empty), proving
the handler's response reflects `FindByFilters`'s `Images` data. Because the fake repository's
`FindByFilters` is written by this test rather than GORM, it can't exercise the real
`.Preload("Images")` SQL by itself — this test only proves the handler passes the repository's
`Images` data through to JSON unchanged. The `.Preload` call itself against a real SQLite file is
covered under Manual verification below.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi`'s calls for `GET /v1/article/`, `PATCH /v1/article/{id}/publish`,
`PATCH /v1/article/{id}/unpublish`, `DELETE /v1/article/{id}`, and the clipboard write (e.g.
`navigator.clipboard.writeText`), per case; no real backend, no httptest server. Mount
ArticlesPage inside DashboardShell with a resolved verified session and a test router at
/article. Sample data throughout is the Background's 6-article fixture (ids 1-6, titles and dates
as listed), unless a scenario's Examples row substitutes a smaller fixture.
1. **Default** — mock `GET /v1/article/` resolving 200 with the 6-article fixture; assert
breadcrumb "Articles"; assert tabs "All 6" "Online 4" "Drafts 2"; assert the rows render in
fixture order with title link, ArticleStatusBadge and updated date; assert the table's visually
hidden caption reads "Articles, sorted by last updated, newest first".
2. **Loading** — mock `GET /v1/article/` with an unresolved promise; assert 5 skeleton rows and
`aria-busy="true"` on the list container; resolve 200 with the fixture; assert the Default
state's rows replace the skeletons.
3. **Row menu open** — from Default, click id 4's ellipsis button; assert the menu opens named
"Actions for Winter checklist for timber windows" with items Edit, Publish, Copy API URL, and a
separator then "Delete…"; assert no other row is affected.
4. **Copy API URL succeeds** — from Default, mock the clipboard write to resolve, choose "Copy API
URL" from id 4's menu; assert the clipboard write was called with
`"{origin}/v1/article/4"` (using the test's `window.location.origin`); assert a toast was
raised with id "article-link-copied-4", icon circle-check, title "Link copied", text "The API
URL for \"Winter checklist for timber windows\" is on your clipboard."
5. **Copy API URL fails** — from Default, mock the clipboard write to reject, choose "Copy API
URL" from id 4's menu; assert a toast was raised with id "article-link-copy-failed-4", icon
circle-x, title "Couldn't copy the link", and no description text.
6. **Empty** — mock `GET /v1/article/` resolving 200 with `[]`; assert tabs "All 0" "Online 0"
"Drafts 0"; assert the no-articles EmptyState (icon file-text, title "No articles yet", text,
action "New article"); click it; assert NewArticleModal opens.
7. **No results** — from Default, type "tiles" into the search field; advance past the 120 ms
debounce (fake timers); assert the no-results EmptyState (icon search, title "No articles match
\"tiles\"", text, action "Clear search"); assert tabs still read "All 6" "Online 4" "Drafts 2";
click "Clear search"; assert the Default rows reappear and the search field has focus.
8. **Empty tab, Examples-driven** — (a) mock `GET /v1/article/` with 4 online-only articles, select
the Drafts tab, assert tabs "All 4" "Online 4" "Drafts 0" and the no-drafts EmptyState (icon
pencil-line, title "No drafts", text "Everything you've written is online.", action "New
article"); (b) mock 4 draft-only articles, select the Online tab, assert tabs "All 4" "Online 0"
"Drafts 4" and the EmptyState titled "Nothing is online yet", text "Publish a draft and it will
appear on your website.", action "Show drafts" (icon per the board, confirmed in the visual
check below).
9. **Action in progress** — from Default, choose "Publish" from id 4's menu; assert
`PATCH /v1/article/4/publish` is called; assert id 4's row is disabled with busy label
"Publishing…" and the other 5 rows stay enabled.
10. **Published** — continuing from 9, resolve the PATCH 200 with `is_online: true`, updated "Just
now"; assert id 4's row shows the online badge and "Just now", no longer busy; assert a toast
was raised with id "article-published-4", icon circle-check, title "Article published", text
"\"Winter checklist for timber windows\" is now online."
11. **Action failed, publish, Examples-driven** — for each of a 500 response and a network
rejection: choose "Publish" from id 4's menu, resolve/reject the mocked PATCH accordingly;
assert id 4's row stays exactly unchanged (draft, "5 days ago", not busy) throughout — the row
is never touched before the (failed) response, so there is nothing to revert; assert a toast
with id "article-publish-failed-4", icon circle-x, title "Couldn't publish", text "The server
didn't respond. Nothing changed.", action "Retry"; click "Retry"; assert the same PATCH is
called again.
12. **Action failed, unpublish, Examples-driven** — for each of a 500 response and a network
rejection: choose "Unpublish" from id 3's menu (an online row), resolve/reject the mocked
PATCH accordingly; assert id 3's row stays exactly unchanged (online, "3 days ago", not busy);
assert a toast with id "article-unpublish-failed-3", icon circle-x, title "Couldn't
unpublish", text "The server didn't respond. Nothing changed.", action "Retry"; click "Retry";
assert the same PATCH is called again.
13. **Row vanished, draft row, Examples-driven** — for each of (publish 404), (delete-confirm
404) on id 2 (a draft row): trigger the action, resolve the mocked call 404; assert id 2's row
is removed; assert tabs read "All 5" "Online 4" "Drafts 1"; assert a toast with id
"article-not-found-2", icon info, title "That article no longer exists", text "It was deleted
somewhere else, so we removed it from the list."
14. **Row vanished, online row** — choose "Unpublish" from id 3's menu, resolve the mocked PATCH
404; assert id 3's row is removed; assert tabs read "All 5" "Online 3" "Drafts 2"; assert a
toast with id "article-not-found-3", icon info, title "That article no longer exists", text
"It was deleted somewhere else, so we removed it from the list."
15. **Delete confirm** — from Default (with id 4 carrying 3 images in the mocked
`GET /v1/article/` response), choose "Delete…" from id 4's menu; assert ConfirmDialog opens
with title "Delete this article?", text "\"Winter checklist for timber windows\" and its 3
images will be deleted. This can't be undone.", "Cancel" and "Delete article".
16. **Delete in progress** — from the open dialog, click "Delete article"; assert
`DELETE /v1/article/4` is called; assert both buttons are disabled, the confirm button reads
"Deleting…", `aria-busy` is set, and Esc / backdrop click do not close it.
17. **Delete failed** — continuing from 16, resolve the DELETE 500; assert the dialog stays open
with the inline alert "Couldn't delete the article, so it's still there. Try again in a
moment.", both buttons re-enabled; assert id 4's row is unchanged in the list behind the
dialog.
18. **Deleted** — continuing from 16 (a fresh attempt), resolve the DELETE 204; assert the dialog
closes, id 4's row is removed, focus moves to the next row; assert tabs read "All 5" "Online
4" "Drafts 1"; assert a toast with id "article-deleted-4", icon circle-check, title "Article
deleted", text "\"Winter checklist for timber windows\" and its images are gone."
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/screens/articles.md` and `docs/design/source/RenewCMS Screens
Back-office.dc.html`, group "Articles". Run `task visual:check -- articles` for each state below
and compare against the board frame; add each state's mock setup to `web/visual/states.ts` first,
reusing the 6-article fixture (ids/titles/dates above) as sample data. This screen shows relative
dates ("2 hours ago", "5 days ago"), so fix `now` to the value the board's own script assumes for
those relative strings to render identically (derive it from "2 hours ago" against "12 Mar 2026"
and the absolute dates shown, e.g. "18 Aug 2026", per the board's `base` fixture in the source
file).
- [ ] Default: tabs "All 6/Online 4/Drafts 2", 6 rows in fixture order, title/status/updated
columns (confirm against the board frame directly whether a Created column is also shown —
components.md's prose and the rendered board table disagree on this).
- [ ] Row menu open: id 4's menu open with Edit/Publish/Copy API URL/Delete…, matching the board's
dropdown placement and the brick-red Delete… treatment.
- [ ] Loading: 5 skeleton rows shaped like the real layout.
- [ ] Empty: EmptyState icon file-text, title, text, single "New article" action.
- [ ] No results: EmptyState icon search, title with the quoted search term, text, "Clear search".
- [ ] No drafts / Nothing online: both empty-tab EmptyStates, including the "Nothing is online
yet" state's icon (confirm it directly against the board frame, since it isn't given in the
prose extraction read for this slice).
- [ ] Action in progress: id 4's row at 50% opacity with a spinner in the status cell and
"aria-busy".
- [ ] Published / Action failed toasts: colors, icons and the "Retry" action match the board.
- [ ] Copy API URL: no board state depicts its toasts; verify their copy and icons directly in the
app against this spec rather than a board frame.
- [ ] Row vanished: the info toast and the row's removal match the board.
- [ ] Delete confirm / in progress / failed: the dialog's three states match the board exactly,
including the inline alert's placement in the failed state.
- [ ] Deleted: toast copy matches, and focus visibly lands on the next row.
- [ ] Keyboard/focus: roving tabindex across the tab list (Tab enters on the selected tab, ←/→
move and activate); each row's title link is reachable and Enter opens the editor; each
row's menu opens with Enter/Space/↓, arrow keys and type-ahead navigate it, Home/End jump to
the ends, Esc closes it and returns focus to the ellipsis button; ConfirmDialog traps focus,
starts on Cancel, and Enter does not confirm by default; on a successful delete, focus lands
on the next row (or the page heading if none).
- [ ] Motion: skeleton pulse and modal open/close follow the standard timings and stop under
reduced motion.
## Manual verification
- **Real GET/PATCH/DELETE round trips against a running server** — Vitest only mocks `useApi`;
confirm the real 200/404/500 responses, and the real `updated_at`-based ordering, against
`task dev` with a live SQLite file, since these automated tests never exercise the actual Go
handlers or use cases.
- **Real .Preload("Images") behavior against a live SQLite file** — confirm `GET /v1/article/`
returns accurate, non-empty `images` arrays for articles with real uploaded images, and `[]` for
articles with none, against a running server, since the HTTP-level Go test above only proves the
handler forwards whatever the (fake) repository returns, not that the real GORM query populates
it correctly.
- **Real image-count accuracy in the delete dialog** — confirm the delete dialog's count matches
an article's actual attached images end to end (upload images, then delete), since Vitest only
ever supplies a mocked count.
- **Toast placement and stacking on a live app, including the three-toast cap** — confirm against
a running app, since Vitest only asserts `useToast().add` was called with the right arguments.
- **Real clipboard write on a live app** — confirm `navigator.clipboard.writeText` actually places
the URL on the system clipboard in a real browser, since Vitest only mocks the clipboard API.