Feature: Activity screen, details slide-over and My activity As a verified RenewCMS user I want /activity to read as a plain-language timeline of who did what and when, with filters, a summary strip, CSV export and a details panel for each event So that an owner can audit the site without reading a log, and any other role sees its own history Background: Given /activity is a verified route (meta.auth "verified", meta.title "Activity") rendered as ActivityView (views/ActivityView.vue, the "ActivityPage" of docs/design/components.md) inside DashboardShell, per docs/design/routes.md and docs/design/screens/activity.md And the activity API already exists (features/activity-log-backend.feature and features/activity-log-producers.feature) and this slice wires the SPA to it unchanged, with no Go change And the Home card and the Settings "Retention" card of the same board are not in this slice And where docs/design prose and the board frame ActivityFrame.dc.html differ, the frame is followed And the views read the session role from GET /v1/auth/me: "owner" and "admin" are the full view; any other or absent value is the regular-user view ("My activity"), which no real account can reach today and is covered by Vitest and one visual state only And a full view loads, in parallel, GET /v1/activity?range=7d&page=1&size=25 and GET /v1/activity/summary?range=7d; a regular-user view loads only GET /v1/activity with the same query and never calls /summary or /export, and never sends an actor parameter And the list query always sends range, page and size explicitly, and adds q, actor, category, outcome, security, from and to only when set (outcome "all" is not sent, from and to only with range=custom); the summary query sends only range, from and to; the export query sends the list query without page and size And filters and the page live in component state, not in the URL, and reset when the user leaves /activity And useApi's global handling covers 401 and the unverified 403 (features/api_client_global_error_handling.feature); this screen maps every other failure And DashboardShell's navigation gains "Activity" (icon i-lucide-history, linking to /activity) between "Users" and "Integration", in the sidebar and in the mobile drawer, so it lists Home, Articles, Media, Users, Activity, Integration, Settings in that order, for every verified user; "Activity" has aria-current="page" on /activity; the specs that list the destinations are amended accordingly (named in the QA file) And the PageHeader breadcrumb and the mobile title read the page title: "Activity", or "My activity" for the regular-user view And the page padding is 24 px on desktop and 16 px on mobile with a 16 px gap between the header, the strip, the filter bar, the chips, the table and the pager And the header is the title "Activity" (regular user: "My activity") with the subtitle "Who did what, and when, across your site." (regular user: "Everything you've done on this site."), and, in the full view except Forbidden, an outline button with icon download and the label "Export" and a trailing chevron at the right, opening the menu described in activity_filter_cards_and_export.feature, aligned to the bottom of the title block And sentences are composed in the SPA from the event's action code, its outcome, its params and its target name through the existing i18n (English keys, French in messages.ts), never stored as text; the target name is user content, is inserted untranslated as a semibold segment, and is the last slot of every template, so a missing target name drops the slot with its leading space and colon And the success sentences are, in English and French (the actor sits in its own column, so sentences start with the verb; {0} is the target name): | action | English | French | | article.create | Created the article {0} | Article créé : {0} | | article.edit | Edited the article {0} | Article modifié : {0} | | article.publish | Published the article {0} | Article publié : {0} | | article.unpublish | Unpublished the article {0} | Article dépublié : {0} | | article.delete | Deleted the article {0} | Article supprimé : {0} | | media.upload | Uploaded the image {0} | Image téléversée : {0} | | media.rename | Renamed the image {0} | Image renommée : {0} | | media.delete | Deleted the image {0} | Image supprimée : {0} | | media.merge | Merged duplicates into the image {0} | Doublons fusionnés dans l'image {0} | | media.purge | Purged duplicate images: {0} merged, {1} articles updated | Doublons purgés : {0} fusionnés, {1} articles mis à jour | | system.media_purge | Ran the scheduled duplicate purge: {0} merged, {1} articles updated | Purge automatique des doublons : {0} fusionnés, {1} articles mis à jour | | user.register | Registered the account {0} | Compte créé : {0} | | user.verify | Verified the account {0} | Compte vérifié : {0} | | user.update_profile | Updated the profile of {0} | Profil mis à jour : {0} | | invite.create | Invited {0} | Invitation envoyée à {0} | | invite.cancel | Cancelled the invite for {0} | Invitation annulée : {0} | | invite.renew | Renewed the invite for {0} | Invitation renouvelée : {0} | | invite.accept | Accepted the invite for {0} | Invitation acceptée : {0} | | api_key.create | Created API key {0} | Clé d'API créée : {0} | | api_key.revoke | Revoked API key {0} | Clé d'API révoquée : {0} | | auth.login | Logged in | Connexion | | auth.logout | Logged out | Déconnexion | | auth.password_reset_requested | Requested a password reset | Réinitialisation du mot de passe demandée | | auth.password_reset_completed | Reset the password | Mot de passe réinitialisé | | system.update_checked | Checked for updates | Recherche de mises à jour | | system.update_downloaded | Downloaded the update {0} | Mise à jour téléchargée : {0} | | system.restart_requested (kind restart) | Requested a restart | Redémarrage demandé | | system.restart_requested (kind update) | Restarted to apply the update {0} | Redémarrage pour appliquer la mise à jour {0} | | system.activity_retention_changed | Changed the activity retention from {0} to {1} | Durée de conservation de l'activité modifiée : {0} → {1} | | system.activity_cleanup | Removed {0} old events from the activity log | {0} anciens événements supprimés du journal d'activité | And the failed sentences, used when outcome is "failed", are: | action | English | French | | article.create | Failed to create the article {0} | Échec de la création de l'article {0} | | article.edit | Failed to edit the article {0} | Échec de la modification de l'article {0} | | article.publish | Failed to publish the article {0} | Échec de la publication de l'article {0} | | article.unpublish | Failed to unpublish the article {0} | Échec de la dépublication de l'article {0} | | article.delete | Failed to delete the article {0} | Échec de la suppression de l'article {0} | | media.upload | Failed to upload the image {0} | Échec du téléversement de l'image {0} | | media.rename | Failed to rename the image {0} | Échec du renommage de l'image {0} | | media.delete | Failed to delete the image {0} | Échec de la suppression de l'image {0} | | media.merge | Failed to merge duplicates into the image {0} | Échec de la fusion des doublons dans l'image {0} | | media.purge | Failed to purge duplicate images | Échec de la purge des doublons | | system.media_purge | Failed to run the scheduled duplicate purge | Échec de la purge automatique des doublons | | user.register | Failed to register the account {0} | Échec de la création du compte {0} | | user.verify | Failed to verify the account {0} | Échec de la vérification du compte {0} | | user.update_profile | Failed to update the profile of {0} | Échec de la mise à jour du profil de {0} | | invite.create | Failed to invite {0} | Échec de l'invitation de {0} | | invite.cancel | Failed to cancel the invite for {0} | Échec de l'annulation de l'invitation de {0} | | invite.renew | Failed to renew the invite for {0} | Échec du renouvellement de l'invitation de {0} | | invite.accept | Failed to accept the invite for {0} | Échec de l'acceptation de l'invitation de {0} | | api_key.create | Failed to create API key {0} | Échec de la création de la clé d'API {0} | | api_key.revoke | Failed to revoke API key {0} | Échec de la révocation de la clé d'API {0} | | auth.login_failed | Failed login attempt for {0} | Échec de connexion pour {0} | | system.update_checked | Failed to check for updates | Échec de la recherche de mises à jour | | system.update_downloaded | Failed to download the update {0} | Échec du téléchargement de la mise à jour {0} | | system.restart_requested | Failed to restart | Échec du redémarrage | And an action whose failed sentence is not listed (for example auth.login) renders its success sentence with the Failed badge; an action code that is not in the catalogue renders the raw code (for example "article.archive") and never throws And for auth.login_failed the slot {0} is the actor's name (the attempted username, since the event has no target), while the Actor column reads "Not signed in" And counts in params are shown with the locale's number format; "{0} old events" and "{1} articles updated" use the singular form for 1 ("1 old event", "1 article updated"); a media.purge or system.media_purge event with params.failed_groups appends ", {2} groups failed" ("1 group failed") in English and ", {2} groupes en échec" in French; system.activity_retention_changed shows days as "{n} days" and 0 as "Forever" ("{n} jours", "Toujours") And the category icons are: articles file-text, media image, users users, api_keys key-round, auth shield-check, system server-cog; the category labels are Articles, Media, Users and invites, API keys, Authentication, System And the Actor cell is the actor's name with: a UAvatar of initials (first letters of the first two words, upper case) for type "user"; a key-round icon avatar and an "API key" badge for "api_key"; a cog icon avatar and a "System" badge for "system"; a user-round-x icon avatar and the text "Not signed in" for "anonymous" And the Target cell is "—" (muted) when target is null; when target.exists it is the name as an underlined link with an arrow-up-right icon, plus the target type as a muted second line (Article, Image, User, Invite, API key, Release); when it does not exist the name is struck through, muted, with a "Deleted" badge (icon trash-2) and the type line; the link goes to /article/{id}/edit for article, /media for image, /users for user and invite, /integration/api-keys for api_key, and a target type with no page (release) is plain text And the Outcome cell is a badge with icon and word, never colour alone: "Succeeded" (check, bordered) or "Failed" (circle-x, --rc-error-soft background, --ui-error text) And a row whose event has security true shows a shield-alert icon in --ui-warning after the sentence and a 2 px inset --ui-warning edge on its left side; on mobile it also shows a "Security" badge (shield-alert, --rc-warning-soft, --ui-warning) And the table (md and up) has the columns When, Actor, Action, Target, Outcome in a grid 96px 210px minmax(0,1fr) 230px 120px (regular user: When, Action, Target, Outcome in 96px minmax(0,1fr) 230px 120px), a 36 px header on --ui-bg-elevated with an arrow-down icon on When, day separator rows of 32 px on --ui-bg-muted with the label in 12 px semibold, and rows at least 52 px high with a top border; a selected row (details open) and a hovered row use --ui-bg-elevated And rows are grouped by the user's local calendar day under a separator labelled "Today", "Yesterday" or the date as "3 Oct 2026" (French "3 oct. 2026"), in the order the API returns them (newest first); the When cell shows the time as drawn, relative within today ("2 min ago", "29 min ago", "1 h ago") and the local clock time "HH:mm" on earlier days, and carries the full local time "3 Oct 2026, 14:20:05" as its title attribute And below md the table is replaced by a stacked list of cards (ActivityCardList) with the same separators: a first line with the actor (avatar, name, badge; hidden for the regular user) and the time at the right, the category icon and the sentence (15 px, up to 3 lines), then a line of badges: the Outcome badge, "Security" when flagged, and "Target deleted" when the target is gone; each card is at least 44 px high as a touch target And rows and cards are focusable, open the details on click and on Enter, and a click on the target link navigates to the target without opening the details And the summary strip (ActivitySummaryStrip, full view only) has four cards that are real buttons: "Total actions", "Active people", "Failed actions", "Security events", each with the number (locale-formatted), a sub-line, and a seven-bar sparkline of seven div bars, 4 px wide, 2 px apart, oldest at the left, bar height round(value / max(series, 1) * H) with a 2 px minimum and H of 20 px on desktop and 16 px on mobile, drawn from series.total, series.active, series.failed and series.security And on desktop the strip is a 4-column grid with a 12 px gap; below md it is, as drawn in ActivityFrame.dc.html, a single row of 156 px cards with an 8 px gap that is clipped at the page edge And the Total sub-line is "{pct}% vs previous 7 days" with icon trending-up when total is at least previous_total and trending-down otherwise, pct being the rounded absolute change against previous_total; for the ranges Today, 30 days and Custom the words read "vs previous day", "vs previous 30 days" and "vs previous period"; when previous_total is 0 the sub-line is "No earlier data" and when the two totals are equal it is "No change", both without an icon (French "Aucune donnée antérieure", "Aucun changement") And the Active people number is active_people.users plus active_people.api_keys with the sub-line "{users} people, {api_keys} API keys" ("1 person", "1 API key") And the Failed sub-line is "{pct}% of all actions" (one decimal, "0.5%") with icon circle-x and the --rc-error-soft background and --ui-error number And the Security sub-line is "{n} flagged" (French "{n} signalés", "1 signalé"), n being summary.security, with icon shield-alert and the --rc-warning-soft background and --ui-warning number And a Failed or Security count of 0 reads "0" with "Nothing failed" or "Nothing to flag" and icon circle-check in --ui-success, drops the emphasis fill and draws its bars in --ui-border-accented; with total 0 Total and Active people read "0" with "No data yet" And the strip follows only the date range; q, person, category, outcome and security never change it, and it is not requested again when they change And when the summary request fails with any status or never reaches the server, the strip is not rendered and nothing is shown in its place And the filter bar (ActivityFilterBar) has: a UInput (icon search, clearable, placeholder "Search activity", 240 px) that applies 300 ms after the last keystroke or at once on Enter; a Person USelectMenu (icon user-round, "All people", 168 px, full view only) with the groups users, API keys and "System"; a Category USelectMenu (icon layout-list, "All categories", 176 px); an Outcome button group "All / Succeeded / Failed" (soft is the selected one, outline the others); and the date presets "Today / 7 days / 30 days / Custom" (Custom has icon calendar and opens a date-range popover) at the right; controls are 32 px high And the Person options come from GET /v1/user/ (accepted users, shown by display name or username) and GET /v1/api-key/, requested once, the first time the Person menu opens, so a page load makes no other call than the two above And choosing a Person sends actor=user:{id}, actor=key:{id} or actor=system; a Category sends category=articles|media|users|api_keys|auth|system; Outcome Succeeded sends outcome=success and Failed outcome=failed; Today, 7 days and 30 days send range=today|7d|30d; Custom sends range=custom&from=YYYY-MM-DD&to=YYYY-MM-DD once both dates are chosen; any filter change resets the page to 1 and sends a new list request, and the page size is kept And the active filter chips (UBadge subtle with a trailing ghost x button) read "Search: spring", "Person: Marta Alves", "Category: Authentication", "Outcome: Failed", "Security events", "Dates: 30 days" or "Dates: 1 – 30 Sep 2026" (same month "1 – 30 Sep 2026", across months "28 Sep – 3 Oct 2026"), followed by a link button "Clear all"; the default range 7 days is never a chip; removing a chip resets that filter; "Clear all" resets every filter to the default (range 7d) And below md the bar is a search field and a "Filters" button (icon sliders-horizontal) with a count badge (the number of chips, hidden at 0); it opens a bottom sheet titled "Filters" with Person (full view only), Category, Outcome, Dates (two date fields when Custom is chosen) and a footer "Clear all" (disabled at 0 filters) and "Show {total} results" ("Show 1 result"); the sheet applies each change at once, so the footer count is the current total, and "Show … results" closes the sheet And the pager (full width under the table) reads "Showing {from}–{to} of {total}" with locale-formatted numbers, a UPagination (previous, 1, 2, 3, …, last, next; previous disabled on page 1), and "Rows per page" with a select of 25, 50 and 100; changing the size resets the page to 1; below md it reads "Page {page} of {pages}" above "{from}–{to} of {total}", with previous and next buttons and the size select And while a list request is in flight for longer than 150 ms, 9 skeleton rows (desktop) or 5 skeleton cards (below md) replace the rows and the pager is hidden; the strip shows 4 skeleton cards while the summary request is in flight for longer than 150 ms; nothing is drawn in their place for the first 150 ms And the list is "empty" when total is 0: with no chip it is "No activity yet" (icon history, "Actions on articles, media, users and API keys will appear here.", regular user: "Your actions will appear here once you start working on the site."), with at least one chip it is "No activity matches these filters" (icon search-x, action "Clear filters") And the error, empty and forbidden panels are drawn as in ActivityFrame.dc.html: "Couldn't load activity" is an error alert with icon circle-x, the text and an outline "Try again" button, and "You can't view this activity" (no trailing period) uses icon lock and the action "Back to home" And "Export" is disabled in "No activity yet" and while the list is in the Error state, and enabled otherwise And ActivityDetailsPanel is a USlideover: 480 px wide from the right on desktop, an 88 %-high bottom sheet with a 36×4 px handle below md; its header reads "Activity details" with a close button (and "{position} of {total}" below md); it opens from a row or card, closes with Escape, the close button or the scrim, and focus returns to the row that opened it And the panel body has, in this order: the full sentence (18 px semibold, target in semibold) with the Outcome badge, a "Security" badge when flagged and the category icon and label; an error alert "Why it failed" with failure_reason, only when outcome is "failed" and the reason is present; the grid When, Actor, Source, IP address, Browser; a Target section; a Changes section And When shows the local time as "6 Oct 2026, 10:42:18 (your time)" and under it, muted, the UTC time as "6 Oct 2026, 10:42:18 UTC"; Actor is the avatar, the name and the same badges as the table; Source is "Web app" for web, "API key" for api_key and "System task" for system; IP address (IBM Plex Mono 12 px) and Browser read "Not applicable" (muted) when ip or user_agent is null And the Target section shows the name (a link with arrow-up-right when it exists, otherwise struck through with a "Deleted" badge and the line "This item was deleted. Its name is kept for reference."), and the line "{type} · {id}"; it is absent when target is null And the Changes section (ActivityDiff) is absent when changes is empty; each change is a row headed by its field label: a "text" change shows Before and After boxes (side by side with an arrow-right on desktop, stacked with an arrow-down below md), a "body" change shows only the line "Body edited (+312 characters)" with icon file-pen-line ("-48", "+1 character") and never any text And the field labels are title "Title", online "Publish state", body "Body", filename "File name", display_name "Display name" (French "Titre", "État de publication", "Corps", "Nom du fichier", "Nom d'affichage"), an unknown field shows its raw code, and for online the values "false" and "true" read "Draft" and "Published" ("Brouillon", "Publié") And a Before or After value taller than two lines is clamped to two lines and a "Show more" link button appears under that row; it toggles to "Show less"; shorter values show no toggle And the panel footer has "Previous event" (icon chevron-up) and "Next event" (icon chevron-down) buttons that move through the current filtered list, and on desktop the muted text "Event {position} of {total}" with position = (page − 1) × size + index + 1; below md the two buttons are full width and there is no footer text And when Previous or Next crosses a page boundary the list page follows, and a failed request for the neighbour page keeps the panel on the current event with no message Scenario: Navigation — "Activity" sits between Users and Integration Given the user is on /activity When DashboardShell mounts Then the sidebar and the mobile drawer list Home, Articles, Media, Users, Activity, Integration, Settings in that order And "Activity" has the history icon, links to /activity and has aria-current="page" And the breadcrumb reads "Activity" (mobile title "Activity") Scenario: Default — an owner sees the strip, the filter bar and a day-grouped table Given GET /v1/activity resolves 200 with 25 items and total 1284, and GET /v1/activity/summary resolves 200 with total 1284, previous_total 1146, active_people {users 7, api_keys 2}, failed 6, security 11 and seven-day series, and the session role is "owner" When the user navigates to /activity Then the header shows "Activity", "Who did what, and when, across your site." and an enabled "Export" And the strip reads "1,284" "12% vs previous 7 days" with trending-up; "9" "7 people, 2 API keys"; "6" "0.5% of all actions"; "11" "11 flagged" And the filter bar shows "Search activity", "All people", "All categories", All selected in the Outcome group, and 7 days selected, with no chip And the table rows follow the API order under day separators And the pager reads "Showing 1–25 of 1,284" and "Rows per page" 25 And GET /v1/activity was requested once with range=7d, page=1, size=25 and no other parameter, and GET /v1/activity/summary once with range=7d Scenario Outline: Rows — the Actor cell and the sentence follow the event Given a row for an event with actor type "" named "", action "", outcome "" and security Then the Actor cell shows And the Action cell shows "" with the target name in semibold, and And the Outcome cell shows "" Examples: | type | name | action | outcome | security | actor | sentence | marker | badge | | user | Marta Alves | article.publish | success | false | an avatar "MA" and "Marta Alves" | Published the article Spring release | no shield | Succeeded | | api_key | Website sync | media.upload | success | false | a key icon, "Website sync", "API key" | Uploaded the image north-wall.jpg | no shield | Succeeded | | system | System | system.media_purge | success | false | a cog icon, "System", "System" | Ran the scheduled duplicate purge: 3 merged, 5 articles updated | no shield | Succeeded | | anonymous | alex@example.com | auth.login_failed | failed | true | a user-round-x icon, "Not signed in" | Failed login attempt for alex@example.com | a shield-alert marker | Failed | | user | Inès Duarte | article.publish | failed | false | an avatar "ID" and "Inès Duarte" | Failed to publish the article Spring release | no shield | Failed | | user | Jonas Weber | api_key.revoke | success | true | an avatar "JW" and "Jonas Weber" | Revoked API key Mobile app | a shield-alert marker | Succeeded | Scenario: Rows — a deleted target is struck through, a living one is a link Given an event of target {"type": "image", "id": "9", "name": "old-banner.png", "exists": false} and an event of target {"type": "article", "id": "8", "name": "Spring release", "exists": true} Then the first Target cell shows "old-banner.png" struck through with a "Deleted" badge and "Image" And the second Target cell shows "Spring release" as a link to /article/8/edit and "Article" And clicking that link navigates to /article/8/edit and does not open the details Scenario: Rows — an event with no target shows a dash Given an event whose target is null Then its Target cell shows a muted "—" Scenario: Rows — an unknown action code renders the code Given an event with action "article.archive" Then its Action cell shows "article.archive" and the row still opens the details Scenario Outline: Sentences — English and French come from the same code and params Given an event with action "", outcome "", params and target "" When the language is Then the sentence reads "" with "" untranslated Examples: | action | outcome | params | target | language | sentence | | article.publish | success | {} | Spring release | English | Published the article Spring release | | article.publish | success | {} | Spring release | French | Article publié : Spring release | | article.publish | failed | {} | Spring release | French | Échec de la publication de l'article Spring release | | media.purge | success | {"merged": 3, "articles_updated": 5} | | English | Purged duplicate images: 3 merged, 5 articles updated | | media.purge | success | {"merged": 3, "articles_updated": 5, "failed_groups": 1} | | English | Purged duplicate images: 3 merged, 5 articles updated, 1 group failed | | system.activity_cleanup | success | {"removed": 1} | | English | Removed 1 old event from the activity log | | system.activity_retention_changed | success | {"from": 90, "to": 0} | | English | Changed the activity retention from 90 days to Forever | | system.restart_requested | success | {"kind": "update", "tag": "v1.4.0"} | v1.4.0 | English | Restarted to apply the update v1.4.0 | | media.upload | failed | {} | | English | Failed to upload the image | Scenario: Loading — skeletons after 150 ms while both requests are in flight Given GET /v1/activity and GET /v1/activity/summary have not resolved When the user navigates to /activity Then the header, the filter bar and an enabled "Export" are drawn at once And nothing is drawn in place of the strip and the table for the first 150 ms And after 150 ms 4 skeleton cards replace the strip and 9 skeleton rows (5 cards below md) replace the table, the pager is hidden and the table container is aria-busy="true" When both requests resolve 200 with the Default data Then the skeletons are replaced by the Default state's strip, rows and pager Scenario: Zero-value summary strip — a quiet period shows a calm 0 Given GET /v1/activity/summary resolves 200 with total 214, previous_total 223, active_people {users 4, api_keys 1}, failed 0, security 0 and series of zeros for failed and security And GET /v1/activity resolves 200 with 25 items When the user navigates to /activity Then the Total card reads "214" "4% vs previous 7 days" with trending-down And the Active people card reads "5" "4 people, 1 API key" And the Failed card reads "0" "Nothing failed" with circle-check and no error fill And the Security card reads "0" "Nothing to flag" with circle-check and no warning fill And their bars are 2 px high Scenario Outline: The Total sub-line when there is nothing to compare Given GET /v1/activity/summary resolves 200 with total and previous_total When the user navigates to /activity Then the Total card reads "" and "" without a trend icon Examples: | total | previous | subline | | 40 | 0 | No earlier data | | 40 | 40 | No change | Scenario: Empty, no activity yet — a new site Given GET /v1/activity resolves 200 with items [] and total 0, and GET /v1/activity/summary resolves 200 with every number 0 And no filter chip is shown When the user navigates to /activity Then the strip reads "0" "No data yet" for Total and Active people, "0" "Nothing failed" and "0" "Nothing to flag" And an empty panel with icon history shows "No activity yet" and "Actions on articles, media, users and API keys will appear here." And "Export" is disabled and no table header or pager is shown Scenario: Empty, filters match nothing — the panel offers Clear filters Given the Default state has loaded When the user types "sitemap" in Search, chooses Category "Media" and Outcome "Failed", and GET /v1/activity resolves 200 with items [] and total 0 Then the chips read "Search: sitemap", "Category: Media", "Outcome: Failed" and "Clear all" And an empty panel with icon search-x shows "No activity matches these filters" and "Try a different search or a wider date range." with the action "Clear filters" And "Export" stays enabled and the strip is unchanged When the user activates "Clear filters" Then every filter is reset, GET /v1/activity is sent with range=7d, page=1, size=25 only and the rows come back Scenario Outline: Error with retry — a 500, a 400 or a network failure replaces the table Given GET /v1/activity and GET /v1/activity/summary resolves 200 When the user navigates to /activity Then the strip and the filter bar are shown And an error alert shows "Couldn't load activity" and "Check your connection and try again." with the action "Try again" And "Export" is disabled and no table or pager is shown When the user activates "Try again" Then the Loading state shows for the list and GET /v1/activity is sent again with the same query When it then resolves 200 with the Default data Then the Default rows replace the alert Examples: | failure | | resolves with a 500 response | | resolves with a 400 response | | never reaches the server | Scenario Outline: The summary alone fails — the strip is not drawn and the list still works Given GET /v1/activity resolves 200 with the Default items and GET /v1/activity/summary When the user navigates to /activity Then the strip is not rendered, with no error shown for it And the filter bar, the rows and the pager are shown as in Default Examples: | failure | | resolves with a 500 response | | resolves with a 403 response | | never reaches the server | Scenario: Forbidden (403) — the page itself is refused Given GET /v1/activity resolves 403 and GET /v1/auth/me resolves 200 with a verified user When the user navigates to /activity Then the header shows "Activity" and no "Export" And no strip, filter bar, chips, table or pager is shown And an empty panel with icon lock shows "You can't view this activity" and "Your role doesn't include this page. Ask an owner or admin if you need access." with the action "Back to home" When the user activates "Back to home" Then the router navigates to /home Scenario: An unverified 403 and a 401 are handled globally Given GET /v1/activity resolves 401 (or 403 for an unverified session) When the user navigates to /activity Then useApi redirects to /login?redirect=/activity&reason=expired (or to /register/pending) And no Forbidden or Error panel is shown Scenario Outline: Strip cards — each card applies its filter Given the Default state has loaded with the 7 days range When the user activates the "" card Then And Examples: | card | effect | state | | Total actions | every filter is cleared and GET /v1/activity is sent with range=7d | no card is marked active | | Active people | nothing changes and no request is sent | no card is marked active | | Failed actions | GET /v1/activity is sent with outcome=failed and the page 1 | the chip "Outcome: Failed" and "Clear all" appear and the card has aria-pressed="true" with a --ui-primary border | | Security events | GET /v1/activity is sent with security=true and the page 1 | the chip "Security events" and "Clear all" appear and the card has aria-pressed="true" with a --ui-primary border | Scenario: Filtered, card applied — the Failed card on top of other filters Given the user has chosen Category "Authentication" and 30 days When the user activates the "Failed actions" card and GET /v1/activity resolves 200 with 1 item and total 6 Then the chips read "Category: Authentication", "Outcome: Failed", "Dates: 30 days" and "Clear all" And GET /v1/activity/summary was requested for range=30d and is not requested again And the pager reads "Showing 1–6 of 6" And below md the "Filters" button shows the count badge "3" Scenario Outline: Filter bar — each control sends its query parameter and resets the page Given the user is on page 3 of the Default list When Then GET /v1/activity is sent with , page=1 and size=25 Examples: | action | query | | the user types "spring" and waits 300 ms | q=spring and range=7d | | the user types "spring" and presses Enter at once | q=spring and range=7d | | the user chooses Person "Marta Alves" (user 3) | actor=user:3 and range=7d | | the user chooses Person "Website sync" (API key 7) | actor=key:7 and range=7d | | the user chooses Person "System" | actor=system and range=7d | | the user chooses Category "Users and invites" | category=users and range=7d | | the user chooses Outcome "Succeeded" | outcome=success and range=7d | | the user chooses "Today" | range=today | | the user chooses "30 days" | range=30d | Scenario: Filter bar — Custom opens a date-range popover and applies on the second date Given the Default state has loaded When the user chooses "Custom" and picks 1 Sep 2026 then 30 Sep 2026 in the popover Then GET /v1/activity and GET /v1/activity/summary are sent with range=custom, from=2026-09-01 and to=2026-09-30 And the chip reads "Dates: 1 – 30 Sep 2026" And no request is sent after the first date alone Scenario: Chips — removing one resets only that filter, Clear all resets everything Given the chips "Search: spring", "Outcome: Failed" and "Dates: 30 days" are shown When the user activates the x of "Outcome: Failed" Then GET /v1/activity is sent with q=spring and range=30d and without outcome When the user activates "Clear all" Then GET /v1/activity is sent with range=7d only, and the summary is requested for range=7d And no chip and no "Clear all" is shown Scenario: The Person menu loads its options once, on first open Given the Default state has loaded Then GET /v1/user/ and GET /v1/api-key/ have not been requested When the user opens the Person menu Then both are requested once and the menu lists the users, then the API keys, then "System" And opening it again does not request them again Scenario: Pagination — next page, page size and totals Given the Default state has loaded with total 1284 When the user activates page "2" of the pager Then GET /v1/activity is sent with page=2 and size=25 and the pager reads "Showing 26–50 of 1,284" When the user chooses "50" in "Rows per page" Then GET /v1/activity is sent with page=1 and size=50 and the pager reads "Showing 1–50 of 1,284" And on the last page 52 of size 25 the pager reads "Showing 1,276–1,284 of 1,284" and next is disabled Scenario: Mobile — the list is cards and the controls collapse into a sheet Given the Default state has loaded below md with the filters Category "Authentication" and Outcome "Failed" applied and GET /v1/activity resolves with total 6 Then the page shows the search field and "Filters" with the badge "2", and no Person, Category, Outcome or date control And the table is replaced by cards, each with its actor, time, sentence and badges And the pager reads "Page 1 of 1" with "1–6 of 6" When the user activates "Filters" Then a bottom sheet titled "Filters" shows Person, Category, Outcome, Dates, "Clear all" and "Show 6 results" When the user chooses "Custom" Then two date fields appear in the sheet When the user activates "Show 6 results" Then the sheet closes and the list stays filtered Scenario: Details — a row opens the slide-over and focus returns on close Given the Default state has loaded When the user activates the second row (click, or Enter with the row focused) Then the slide-over opens with the title "Activity details", the row is marked selected and the footer reads "Event 2 of 1,284" When the user presses Escape (or activates the close button, or the scrim) Then the slide-over closes and focus is on that row Scenario: Details — the Edit with a diff event Given the event "Why we reuse original floorboards" has action "article.edit" with changes [{"field": "title", "kind": "text", "before": "Why we reuse floorboards", "after": "Why we reuse original floorboards"}, {"field": "body", "kind": "body", "delta": 312}] When its details open Then the sentence reads "Edited the article Why we reuse original floorboards" And the Changes section shows a "Title" row with Before "Why we reuse floorboards" and After "Why we reuse original floorboards", then a "Body" row with "Body edited (+312 characters)" And no body text appears anywhere in the panel Scenario: Details — long values clamp to two lines with Show more Given an event with a text change whose Before and After are 170-character values, and another text change with short values When its details open and the long values overflow two lines Then the long row's values are clamped and "Show more" appears under that row only When the user activates "Show more" Then the values are fully shown and the link reads "Show less" Scenario: Details — a failed action with a very long target title shows its reason Given an event with action "article.publish", outcome "failed", failure_reason "The article has no cover image. Add one, then publish again." and a 150-character target name When its details open Then the sentence wraps with the title in semibold, the "Failed" badge is shown and an error alert "Why it failed" reads the reason And the Target section wraps the long name Scenario: Details — a deleted target has no diff Given an event of action "media.delete" with target {"type": "image", "id": "9", "name": "old-banner.png", "exists": false} and no changes When its details open Then the Target section shows "old-banner.png" struck through, a "Deleted" badge and "This item was deleted. Its name is kept for reference." And there is no Changes section Scenario: Details — a system-initiated event has no human actor Given an event with actor {"type": "system", "id": null, "name": "System"}, source "system", ip null and user_agent null When its details open Then Actor shows the cog avatar, "System" and a "System" badge, Source reads "System task", and IP address and Browser read "Not applicable" Scenario: Details — a failed login shows the actor as not signed in Given an event with action "auth.login_failed", actor {"type": "anonymous", "id": null, "name": "alex@example.com"}, security true, outcome "failed", failure_reason "Wrong username or password.", ip "198.51.100.77" and user_agent "Chrome 129 on Windows" When its details open Then the sentence reads "Failed login attempt for alex@example.com" with a "Security" badge and a "Failed" badge And Actor reads "Not signed in", the alert "Why it failed" shows the reason, and the IP address and the Browser are shown Scenario: Details — the times are shown in local time and in UTC Given an event created_at "2026-10-06T10:42:18Z" When its details open Then the When line ends with "(your time)" and the muted line below reads "6 Oct 2026, 10:42:18 UTC" Scenario Outline: Details — Source reads the event's source Given an event with source "" When its details open Then Source reads "