diff --git a/features/dashboard_shell_branding.feature b/features/dashboard_shell_branding.feature new file mode 100644 index 0000000..1a3c8db --- /dev/null +++ b/features/dashboard_shell_branding.feature @@ -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 + RenewCMS 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 + Log out 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 "" + When the user activates the "" checkbox item + Then useColorMode().preference is set to "" + And the "" item is checked and the "" 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 diff --git a/features/dashboard_shell_branding.qa.md b/features/dashboard_shell_branding.qa.md new file mode 100644 index 0000000..5763605 --- /dev/null +++ b/features/dashboard_shell_branding.qa.md @@ -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 -- ` 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. diff --git a/features/home.feature b/features/home.feature new file mode 100644 index 0000000..176fa19 --- /dev/null +++ b/features/home.feature @@ -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/ + 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 + When the user views /home + Then the greeting above the subtitle reads ", marta" + + Examples: + | hour | greeting | + | 0 | Good morning | + | 11 | Good morning | + | 12 | Good afternoon | + | 17 | Good afternoon | + | 18 | Good evening | + | 23 | Good evening | diff --git a/features/home.qa.md b/features/home.qa.md new file mode 100644 index 0000000..8ebcad8 --- /dev/null +++ b/features/home.qa.md @@ -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. diff --git a/web/bun.lock b/web/bun.lock index d325f85..48782f8 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@nuxt/ui": "^4.11.2", "@tailwindcss/vite": "^4.1.18", + "@vueuse/core": "14.4.0", "pinia": "^3.0.4", "tailwindcss": "^4.1.18", "vue": "^3.5.25", diff --git a/web/package.json b/web/package.json index ad2f280..40633af 100644 --- a/web/package.json +++ b/web/package.json @@ -18,6 +18,7 @@ "dependencies": { "@nuxt/ui": "^4.11.2", "@tailwindcss/vite": "^4.1.18", + "@vueuse/core": "14.4.0", "pinia": "^3.0.4", "tailwindcss": "^4.1.18", "vue": "^3.5.25", diff --git a/web/src/components/ArticleStatusBadge.vue b/web/src/components/ArticleStatusBadge.vue new file mode 100644 index 0000000..9cca609 --- /dev/null +++ b/web/src/components/ArticleStatusBadge.vue @@ -0,0 +1,27 @@ + + + diff --git a/web/src/components/DashboardShell.vue b/web/src/components/DashboardShell.vue index 58b7d67..10626c2 100644 --- a/web/src/components/DashboardShell.vue +++ b/web/src/components/DashboardShell.vue @@ -1,13 +1,18 @@