feat: home screen and navigation design

This commit is contained in:
fsylvain committed 2026-09-26 17:46:42 +02:00
1 parent 30a4df47b1
commit 930b836241
24 files changed
+1774 -19

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
Feature: DashboardShell wordmark, sidebar collapse and the UserMenu
As the maintainer of RenewCMS
I want DashboardShell built from the exact primitives docs/design/components.md specifies —
AppLogo plus the "RenewCMS" wordmark in the sidebar header, and a UserMenu (UDropdownMenu on a
ghost UButton with a UAvatar) in the sidebar footer, with a collapsed rail that hides text but
never drops an accessible name
So that every verified route's sidebar matches the visual spec of record and stays usable when
collapsed, instead of the current plain-text "RenewCMS" header and "Log out" footer
Background:
Given DashboardShell is the layout for every verified route, per docs/design/routes.md
And DashboardShell composes a UDashboardGroup storage="local" with a UDashboardSidebar
collapsible :resizable="false" :default-size="14" :collapsed-size="3.5", per
docs/design/components.md's DashboardShell section
And the sidebar's #header slot renders AppLogo (the same reusable inline-SVG brand mark
AuthShell uses) followed by the "RenewCMS" wordmark text, not the plain
<span class="font-semibold">RenewCMS</span> the shell renders today
And the sidebar's #default slot is a UNavigationMenu orientation="vertical" :collapsed bound
to the sidebar's own collapsed state, with items Home, Articles and Integration
And the sidebar's #footer slot renders a UserMenu component, not the plain
<span>Log out</span> text the shell renders today
And UserMenu is a UDropdownMenu :content="{ side: 'top', align: 'start' }" anchored to a
ghost UButton block containing a UAvatar and the session's username
And the UAvatar's initials are the first two characters of the username, upper-cased (for
example "marta" gives "MA"), and the avatar is aria-hidden because the username is shown
next to it
And UserMenu's dropdown groups, top to bottom, are: an account label (the username); a
checkbox group of System, Light and Dark bound to useColorMode().preference, with the
current preference's item checked; then Log out, in its own trailing group
And Log out calls POST /v1/auth/logout inside a try/finally with a loading flag (no catch),
then calls session.clear() and navigates to /login — the exact same shape PendingView.vue
and VerifyView.vue already use for their own log-out actions, not a new resilience pattern
And per docs/design/components.md's DashboardShell a11y note, in the collapsed rail the
UNavigationMenu's items show tooltips and keep their aria-label; the wordmark and UserMenu
apply that same spirit when the sidebar collapses to :collapsed-size="3.5"
Scenario: The sidebar header renders AppLogo and the "RenewCMS" wordmark
Given the user is on any verified route with the sidebar expanded
When DashboardShell mounts
Then the sidebar's #header slot renders an AppLogo before the text "RenewCMS"
And the wordmark text "RenewCMS" is visible next to the logo
And neither the logo nor the wordmark is a link, since there is nothing to navigate to from
the shell itself
Scenario: The sidebar collapse trigger toggles the rail and exposes aria-expanded
Given the user is on any verified route with the sidebar expanded
When the user activates UDashboardSidebarCollapse in the navbar's leading slot
Then the sidebar transitions to its :collapsed-size="3.5" icon-only rail
And UDashboardSidebarCollapse carries aria-expanded, reflecting the sidebar's new collapsed
state
When the user activates UDashboardSidebarCollapse again
Then the sidebar returns to its :default-size="14" expanded width
And aria-expanded reflects the expanded state
Scenario: UserMenu's dropdown lists the account label, color-mode checkboxes, then Log out
Given the session's username is "marta"
When the user activates the UserMenu trigger in the sidebar footer
Then the dropdown opens with a UAvatar showing initials "MA" and the trigger's own username
text "marta"
And the first group is the account label showing "marta"
And the second group is a checkbox group with items System, Light and Dark, and exactly the
item matching the current useColorMode().preference is checked
And the third and final group contains only "Log out", separated from the color-mode group
Scenario Outline: Selecting a color-mode item updates the preference and its checked state
Given the user has opened the UserMenu dropdown
And the current useColorMode().preference is "<from>"
When the user activates the "<to>" checkbox item
Then useColorMode().preference is set to "<to>"
And the "<to>" item is checked and the "<from>" item is no longer checked
Examples:
| from | to |
| system | light |
| light | dark |
| dark | system |
Scenario: A successful logout clears the session and navigates to /login
Given the user has opened the UserMenu dropdown
When the user activates "Log out"
Then a POST request is made to /v1/auth/logout
And once it resolves, the local session is cleared
And the user is navigated to /login
Scenario: Collapsed sidebar hides the wordmark and username text but keeps an icon-only rail
Given the user is on any verified route
When the sidebar is collapsed to its :collapsed-size="3.5" icon-only rail
Then the AppLogo icon in the sidebar header remains visible, but the "RenewCMS" wordmark text
is hidden, not merely visually truncated
And the header keeps an accessible name for the brand mark (an aria-label or sr-only text
reading "RenewCMS" on the logo or its wrapper), so collapsing the rail is not a pure a11y
regression
And the UserMenu trigger in the sidebar footer still shows the UAvatar, but the username text
next to it is hidden
And the UserMenu trigger keeps an accessible name that includes the username (an aria-label
such as "marta — account menu", or a tooltip plus aria-label), matching the same
tooltip/aria-label spirit the collapsed rail's UNavigationMenu items already use per
docs/design/components.md's DashboardShell a11y note
And activating the collapsed UserMenu trigger still opens the same dropdown, with the same
account label, color-mode checkbox group and Log out item as the expanded state
+65
View File
@@ -0,0 +1,65 @@
# QA: DashboardShell branding (wordmark, sidebar collapse, UserMenu)
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mount DashboardShell (or a verified route rendered inside it) with a resolved session (username
"marta") and a test router. Mock `useApi`'s `/v1/auth/logout` call per case; no real backend, no
httptest server. Stub `useColorMode()` so its `preference` can be asserted and set.
1. **Header renders AppLogo and the wordmark** — mount DashboardShell expanded; assert the
sidebar's #header slot renders an AppLogo followed by the text "RenewCMS"; assert neither is a
link.
2. **Sidebar collapse trigger toggles the rail and aria-expanded** — mount DashboardShell
expanded; activate UDashboardSidebarCollapse; assert the sidebar reaches its
:collapsed-size="3.5" state and the trigger's aria-expanded reflects it; activate it again; assert
the sidebar returns to :default-size="14" and aria-expanded flips back.
3. **UserMenu dropdown groups and order** — mount with username "marta" and
`useColorMode().preference` set to each of "system", "light", "dark" in turn; open the UserMenu
dropdown; assert the avatar shows initials "MA"; assert the trigger shows "marta"; assert the
first group is the account label "marta"; assert the second group has System, Light and Dark
checkbox items with exactly the current preference's item checked; assert the third, separate
group contains only "Log out".
4. **Selecting a color-mode item updates the preference** — Examples-driven: (system → light),
(light → dark), (dark → system). Open the dropdown, activate the target item, assert
`useColorMode().preference` is set to the target and its checkbox item is checked while the
previous one is not.
5. **Successful logout clears the session and navigates** — mock `POST /v1/auth/logout` resolving
200; open the UserMenu dropdown; activate "Log out"; assert the mock was called; assert the
session store's `clear()` is called once it resolves; assert the router navigates to /login.
6. **Collapsed sidebar hides wordmark/username text but keeps an accessible name** — mount
DashboardShell and collapse the sidebar to :collapsed-size="3.5"; assert the AppLogo icon is still
present but the "RenewCMS" text node is not rendered (or is visually hidden, e.g. sr-only) while
an accessible name for the logo (aria-label or sr-only text reading "RenewCMS") is still present
in the accessibility tree; assert the UserMenu trigger still renders the UAvatar but not the
visible "marta" text node, while the trigger's accessible name still includes "marta" (via
aria-label or a tooltip plus aria-label); activate the collapsed trigger and assert the same
dropdown (account label, color-mode group, Log out) opens as in the expanded state.
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/components.md`'s DashboardShell section and
`docs/design/source/RenewCMS Screens Back-office.dc.html`. Run `task visual:check -- <a verified
screen, e.g. home>` for each state and compare the sidebar header/footer against the board frame.
- [ ] Expanded sidebar: AppLogo plus "RenewCMS" wordmark in the header; UserMenu in the footer
shows the avatar and username, matching the board exactly at desktop and mobile.
- [ ] UserMenu dropdown: opens above the trigger (`side: 'top'`), left-aligned (`align: 'start'`),
with the account label, the System/Light/Dark checkbox group and Log out visually separated
as in the board.
- [ ] Color mode selection: choosing Light/Dark/System visibly re-themes the app and moves the
checkmark to the selected item.
- [ ] Collapsed sidebar (icon-only rail): the AppLogo icon and the avatar remain visible; the
"RenewCMS" wordmark and the username text are hidden; nothing overflows or clips awkwardly at
the :collapsed-size="3.5" width, matching the board's collapsed rail treatment.
- [ ] Keyboard/focus: Tab reaches UDashboardSidebarCollapse, then the UserMenu trigger; the
dropdown opens with Enter/Space, arrow keys move through the checkbox group, and Esc closes
it and returns focus to the trigger, in both the expanded and collapsed rail.
## Manual verification
- **Real POST /v1/auth/logout against a running server** — Vitest only mocks `useApi`; confirm the
real 200 round-trip against `task dev` with a live SQLite file, since these automated tests
never exercise the actual Go handler.
- **Real useColorMode persistence across reload** — confirm a selected preference survives a full
page reload via the plugin's `colorMode: true` storage, since Vitest only asserts the in-memory
preference value.
+124
View File
@@ -0,0 +1,124 @@
Feature: Home screen
As a logged-in RenewCMS owner
I want /home to summarize how many articles I have, how many are online or drafts, and my five
most recently updated articles, all from the single GET /v1/article/ call the route already
makes
So that I land somewhere useful after logging in, instead of an empty dashboard or a screen that
silently fails
Background:
Given /home is a verified route (meta.auth "verified") rendered as HomePage inside
DashboardShell, per docs/design/routes.md
And HomePage calls GET /v1/article/ once per navigation; there is no separate stats endpoint,
so the three stat tiles and the recent list are both derived from that one response
And the PageHeader shows breadcrumb "Home" (mobile title "Home") and a primary action "New
article" opening NewArticleModal
And a "Connect your website" card is always rendered on the page, in every state (Loading,
Default, Empty and Error alike): icon plug, title "Connect your website", text "Your site
reads published articles from RenewCMS. Share this guide with whoever builds it.", and a
link "Open the guide" navigating to /integration
And a greeting reading "{greeting}, {username}" sits above the subtitle in every state
(Loading, Default, Empty and Error alike); it is computed from the session's username and
the device's local hour via Date.getHours(), needs no request of its own since the session
is already resolved by the app-boot flow before HomePage ever renders, and so never has its
own loading or error state
And hours 0 through 11 give the greeting "Good morning", hours 12 through 17 give "Good
afternoon", and hours 18 through 23 give "Good evening"
And once the list resolves with at least one article, the subtitle reads "Here's where things
stand on your site.", three stat tiles show the total article count labelled "articles", the
online count labelled "online" and the draft count labelled "drafts", and a recent list shows
the 5 most recently updated articles, each with its title, an ArticleStatusBadge (online or
draft) and its updated date, linking to /article/:id/edit
And while the list request is in flight, skeleton placeholders replace the stat tiles and the
recent list rows, but only after 150 ms, so a fast response never flashes a skeleton
And when the list resolves with zero articles, the subtitle reads "Welcome to RenewCMS.", and
the stat tiles and recent list are replaced by an empty state: icon file-text, title "Write
your first article", text "It starts as a draft, so nothing appears on your site until you
publish it.", and a single action "New article" that opens NewArticleModal
And when the list request fails with a 500 or a network error, the breadcrumb, greeting and
subtitle stay visible, the subtitle stays "Here's where things stand on your site.", and the
stat tiles and recent list are replaced by an inline role="alert" banner: icon circle-x,
title "Couldn't load your articles", text "Check your connection and try again.", and a
single action "Try again"
Scenario: Skeletons appear only after 150 ms, never for a fast response
Given the user navigates to /home for the first time in this session
When GET /v1/article/ has not resolved 150 ms after the navigation
Then skeleton placeholders replace the three stat tiles and the five recent-list rows
And the recent list container is aria-busy="true"
When GET /v1/article/ then resolves 200 with a non-empty list of articles
Then the skeletons are replaced by the Default state's stat tiles and recent list
Given a second, separate navigation to /home
When GET /v1/article/ resolves in under 150 ms with a non-empty list of articles
Then no skeleton is ever shown, and the Default state's content renders directly
Scenario: Default — the list loaded with a mix of online and draft articles
Given GET /v1/article/ resolves 200 with 6 articles, 4 online and 2 drafts, most recently
updated first: "How we price a bathroom renovation" (online), "Winter checklist for timber
windows" (online), "Choosing tiles for a small bathroom" (draft), "A guide to underfloor
heating" (online), "When to reseal a stone worktop" (draft), and "Small bathroom lighting
ideas" (online, least recently updated)
When the user navigates to /home
Then the subtitle reads "Here's where things stand on your site."
And the stat tiles read "6" "articles", "4" "online" and "2" "drafts"
And the recent list shows exactly the first 5 articles above, in that order, each with its
ArticleStatusBadge and updated date, and each row links to its /article/:id/edit
And "Small bathroom lighting ideas" does not appear in the recent list
And the "Connect your website" card's "Open the guide" link navigates to /integration, and
the header's "New article" action opens NewArticleModal
Scenario: Empty, first run — zero articles
Given GET /v1/article/ resolves 200 with zero articles
When the user navigates to /home
Then the subtitle reads "Welcome to RenewCMS."
And no stat tiles are shown
And an empty state is shown in place of the recent list: icon file-text, title "Write your
first article", text "It starts as a draft, so nothing appears on your site until you
publish it.", and a single action "New article"
When the user activates "New article"
Then NewArticleModal opens
Scenario Outline: Error — a 500 or a network failure shows the same inline alert
Given GET /v1/article/ <failure>
When the user navigates to /home
Then the breadcrumb, greeting and subtitle remain visible, and the subtitle stays "Here's
where things stand on your site."
And no stat tiles or recent list are shown
And an inline alert with role="alert" is shown in their place: icon circle-x, title
"Couldn't load your articles", text "Check your connection and try again.", and a single
action "Try again"
Examples:
| failure |
| fails with a 500 response |
| never reaches the server |
Scenario: Retry re-issues the request and recovers into the Default state
Given the user is on /home in the Error state, because GET /v1/article/ failed
When the user activates "Try again"
Then GET /v1/article/ is called again
When it resolves 200 with 6 articles, 4 online and 2 drafts
Then the Alert is replaced by the stat tiles reading "6" "articles", "4" "online", "2" "drafts",
and the recent list
Scenario: The list is fetched exactly once per navigation
Given the user navigates to /home
Then GET /v1/article/ is called exactly once
And both the stat tiles and the recent list are derived from that single response, with no
second request made for either
Scenario Outline: The greeting matches the hour of day
Given the session's username is "marta"
And the device's local hour is <hour>
When the user views /home
Then the greeting above the subtitle reads "<greeting>, marta"
Examples:
| hour | greeting |
| 0 | Good morning |
| 11 | Good morning |
| 12 | Good afternoon |
| 17 | Good afternoon |
| 18 | Good evening |
| 23 | Good evening |
+101
View File
@@ -0,0 +1,101 @@
# QA: Home screen
## Implementation note for coder (informational, not a QA assertion)
The greeting requires `useApi.getMe()` and the `Session` type/store
(`web/src/composables/useApi.ts`, `web/src/stores/session.ts`) to also expose `username`, which
`GET /v1/auth/me` already returns in its JSON body. Today `getMe()` only extracts `is_verified` and
discards the rest, and `Session` is just `{ verified: boolean }`. Widen `MeResult` and `Session` to
carry `username: string` as well; this is a same-layer frontend response-type widening (the wire
contract doesn't change), not a backend change.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi`'s `GET /v1/article/` call per case; no real backend, no httptest server. Mount
HomePage inside DashboardShell with a resolved session (username "marta") and a test router at
/home.
1. **Skeleton only after 150 ms** — mock `GET /v1/article/` with an unresolved promise; assert no
skeleton is present before 150 ms elapse (use fake timers); advance past 150 ms; assert skeleton
placeholders replace the stat tiles and the 5 recent-list rows, and the list container has
aria-busy="true"; resolve the promise with 200 and a non-empty list; assert the skeletons are
gone and the Default content is shown. Separately, mock a promise that resolves in under 150 ms;
assert no skeleton ever renders.
2. **Default state** — mock `GET /v1/article/` resolving 200 with the 6-article fixture (4 online,
2 drafts, ordered most-recently-updated first as in the feature); assert subtitle "Here's where
things stand on your site."; assert stat tiles "6" "articles", "4" "online", "2" "drafts"; assert
the recent list shows exactly the first 5 articles in order, each with its ArticleStatusBadge and
updated date, each row linking to /article/:id/edit; assert the 6th (least recently updated)
article is absent from the recent list; assert the PageHeader's "New article" action opens
NewArticleModal.
3. **Empty, first run** — mock `GET /v1/article/` resolving 200 with `[]`; assert subtitle "Welcome
to RenewCMS."; assert no stat tiles render; assert an empty state renders in place of the recent
list with icon file-text, title "Write your first article", text "It starts as a draft, so
nothing appears on your site until you publish it.", and action "New article"; click it; assert
NewArticleModal opens.
4. **Error state** — Examples-driven: (a) `GET /v1/article/` resolves 500, (b) it rejects with a
network error. In each: assert the breadcrumb, greeting and subtitle remain visible and the
subtitle stays "Here's where things stand on your site."; assert no stat tiles or recent list
render; assert an inline alert with role="alert" renders in their place, with icon circle-x,
title "Couldn't load your articles", text "Check your connection and try again.", and a single
action "Try again"; assert the alert text is identical between the 500 case and the network-
failure case — there is no separate wording per cause.
5. **Retry recovers into Default** — start from the Error state (mocked 500); click "Try again"; assert
`GET /v1/article/` is called a second time; resolve it 200 with the 6-article fixture; assert the
Alert is replaced by the stat tiles "6"/"4"/"2" and the recent list.
6. **Single request per navigation** — mock `GET /v1/article/` resolving 200 with the 6-article
fixture; mount /home; assert the mock was called exactly once, and both the stat tiles and the
recent list read from that one resolved value (no second call fires for either).
7. **Greeting matches the hour of day, at every state, for the session username** — stub the clock
(e.g. `vi.setSystemTime` or a mocked `Date.now`/`Date.prototype.getHours`) to each boundary hour
in turn (0, 11, 12, 17, 18, 23) and mount with session username "marta"; assert the greeting
above the subtitle reads exactly "Good morning, marta" for hours 0 and 11, "Good afternoon,
marta" for hours 12 and 17, and "Good evening, marta" for hours 18 and 23. Then, fixing the hour
at 9 ("Good morning, marta"), assert the same exact greeting text is shown above the subtitle in
all four states: while `GET /v1/article/` is unresolved past 150 ms (Loading), once it resolves
with the 6-article fixture (Default), once it resolves with `[]` (Empty), and once it rejects
with a 500 (Error).
8. **"Connect your website" card renders in every state** — assert the static card (icon plug,
title "Connect your website", text "Your site reads published articles from RenewCMS. Share
this guide with whoever builds it.", link "Open the guide" navigating to /integration) is
present and identical while `GET /v1/article/` is unresolved past 150 ms (Loading), once it
resolves with the 6-article fixture (Default), once it resolves with `[]` (Empty), and once it
rejects with a 500 (Error). The card is not part of the PageHeader's actions.
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/screens/home.md` and `docs/design/source/RenewCMS Screens Back-office.dc.html`,
group "Home". Run `task visual:check -- home` for each state and compare against the board frame.
- [ ] Loading state: skeleton stat tiles and 5 skeleton recent rows, shaped like the real layout,
matching the board.
- [ ] Default state: stat tiles, recent list with ArticleStatusBadge per row, PageHeader primary
action "New article", breadcrumb "Home", and the "Connect your website" card below — matches
the board exactly at desktop and mobile.
- [ ] Empty, first run state: the empty state replaces the stats and recent list, matching the
board's icon tile, title "Write your first article", text, and single "New article" action.
- [ ] Error state: the inline alert's icon (circle-x), title "Couldn't load your articles", text
"Check your connection and try again.", and "Try again" action match the board; the
breadcrumb, greeting and subtitle stay visible above it.
- [ ] "Connect your website" card: icon plug, title, text and "Open the guide" link match the
board, and the card renders identically in every state (Loading, Default, Empty, Error).
- [ ] Greeting placement: the greeting sits above the subtitle in the PageHeader area (not the note
board's fixture text, since the greeting is new content — position and type scale should read
as a lightweight line above the description, not competing with the h1).
- [ ] Keyboard/focus: Tab order reaches "New article" in the header, then the "Connect your
website" card's "Open the guide" link, before the recent list; the Error state's "Try again"
button is reachable and, on activation, focus stays sensible (not lost) while the request
re-runs; the recent list's rows are reachable and each activates its /article/:id/edit link
with Enter.
- [ ] Motion: the skeleton pulse runs at 1.6 s and stops under reduced motion, per the Skeleton
component spec.
## Manual verification
- **Real GET /v1/article/ against a running server** — Vitest only mocks `useApi`; confirm the real
200/500 round-trip and the real article ordering (by updated_at) against `task dev` with a live
SQLite file, since these automated tests never exercise the actual Go handler or the
`ListArticles` use case.
- **Real session username on a live app-boot** — confirm `GET /v1/auth/me`'s `username` field flows
through the real session store into the greeting, since Vitest only ever supplies a mocked
session.