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.
+1
View File
@@ -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",
+1
View File
@@ -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",
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
withDefaults(
defineProps<{
online: boolean
size?: 'sm' | 'md'
}>(),
{
size: 'md',
},
)
</script>
<template>
<UBadge v-if="online" color="neutral" variant="solid" icon="i-lucide-globe" :size="size">
Online
</UBadge>
<UBadge
v-else
color="neutral"
variant="outline"
icon="i-lucide-pencil-line"
:size="size"
class="px-2"
>
Draft
</UBadge>
</template>
+87 -11
View File
@@ -1,13 +1,18 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useSessionStore } from '@/stores/session'
import { useApi } from '@/composables/useApi'
import AppBoot from '@/components/AppBoot.vue'
import AppLogo from '@/components/AppLogo.vue'
import ErrorView from '@/components/ErrorView.vue'
import OfflineBanner from '@/components/OfflineBanner.vue'
import UserMenu from '@/components/UserMenu.vue'
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const api = useApi()
const navItems = [
{ label: 'Home', to: '/home', icon: 'i-lucide-home' },
@@ -16,39 +21,111 @@ const navItems = [
]
const pageTitle = computed(() => (route.meta.title as string) ?? '')
const sidebarCollapsed = ref(false)
const loggingOut = ref(false)
function onRetry() {
session.retry()
}
async function onLogout() {
loggingOut.value = true
try {
await api.request('/v1/auth/logout', { method: 'POST' })
} finally {
loggingOut.value = false
}
session.clear()
await router.push('/login')
}
</script>
<template>
<UDashboardGroup unit="rem">
<a href="#dashboard-content" class="sr-only focus:not-sr-only">Skip to content</a>
<UDashboardSidebar collapsible :resizable="false" :default-size="16" :collapsed-size="4">
<UDashboardSidebar
v-model:collapsed="sidebarCollapsed"
collapsible
:resizable="false"
:default-size="14"
:collapsed-size="3.5"
:ui="{
root: 'min-w-14 p-3',
header: 'mb-3 h-10 gap-2.5 px-1.5',
body: 'gap-0.5 p-0',
footer: 'p-0',
}"
>
<template #header>
<span class="font-semibold">RenewCMS</span>
<AppLogo :size="26" />
<span
v-if="!sidebarCollapsed"
aria-hidden="true"
class="text-base font-semibold text-highlighted"
>RenewCMS</span
>
</template>
<UNavigationMenu orientation="vertical" :items="navItems" aria-label="Main" />
<UNavigationMenu
orientation="vertical"
:items="navItems"
:collapsed="sidebarCollapsed"
aria-label="Main"
:ui="{
list: 'flex flex-col gap-0.5',
link: 'h-8 gap-2.5 px-2.5 py-0 text-sm',
linkLeadingIcon: 'size-4',
}"
/>
<template #footer>
<span>Log out</span>
<UserMenu
:username="session.session?.username ?? ''"
:collapsed="sidebarCollapsed"
@logout="onLogout"
/>
</template>
</UDashboardSidebar>
<UDashboardPanel>
<UDashboardPanel
:ui="{ body: 'gap-0 px-4 pt-5 pb-22 sm:gap-0 sm:px-4 sm:pt-5 sm:pb-22 lg:p-8' }"
>
<template #header>
<UDashboardNavbar :title="pageTitle">
<UDashboardNavbar
:toggle="{ ui: { leadingIcon: 'size-5' } }"
:title="pageTitle"
:ui="{
root: 'h-14 gap-1 bg-default px-1.5 sm:px-1.5 lg:h-12 lg:gap-2 lg:px-6',
left: 'min-w-0 flex-1 gap-1 lg:gap-2',
title: 'text-lg lg:sr-only',
toggle: 'size-11 justify-center',
}"
>
<template #leading>
<UDashboardSidebarCollapse />
<UDashboardSidebarCollapse
color="neutral"
variant="ghost"
size="sm"
class="-mx-1.5 text-muted"
:aria-expanded="!sidebarCollapsed"
data-testid="sidebar-collapse"
/>
<span
aria-hidden="true"
class="mx-1.5 hidden h-4 w-px bg-(--ui-border) lg:block"
/>
<UBreadcrumb
:items="[{ label: pageTitle }]"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
</template>
<template #right>
<slot name="navbar-right" />
</template>
</UDashboardNavbar>
<UBreadcrumb :items="[{ label: pageTitle }]" />
</template>
<template #body>
@@ -62,7 +139,6 @@ function onRetry() {
@retry="onRetry"
/>
<div v-else>
<h1>{{ pageTitle }}</h1>
<slot />
</div>
</div>
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { ref } from 'vue'
const open = defineModel<boolean>('open', { default: false })
const title = ref('')
const body = ref('')
function onCancel() {
open.value = false
}
</script>
<template>
<UModal v-model:open="open" title="New article">
<template #body>
<UForm :state="{ title, body }" class="space-y-4">
<UFormField label="Title" name="title">
<UInput v-model="title" placeholder="Article title" class="w-full" />
</UFormField>
<UFormField label="Body" name="body">
<UTextarea v-model="body" placeholder="Start writing…" class="w-full" />
</UFormField>
</UForm>
</template>
<template #footer>
<div class="flex w-full justify-end gap-2">
<UButton color="neutral" variant="outline" @click="onCancel">Cancel</UButton>
<UButton disabled>Create article</UButton>
</div>
</template>
</UModal>
</template>
+80
View File
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useColorMode, type ColorModePreference } from '@/composables/useColorMode'
const props = defineProps<{
username: string
collapsed: boolean
}>()
const emit = defineEmits<{
logout: []
}>()
const colorMode = useColorMode()
const initials = computed(() => {
const parts = props.username.split(/[^a-zA-Z0-9]+/).filter(Boolean)
if (parts.length >= 2) {
return ((parts[0] ?? '').charAt(0) + (parts[1] ?? '').charAt(0)).toUpperCase()
}
return (parts[0] ?? '').slice(0, 2).toUpperCase()
})
const triggerAriaLabel = computed(() =>
props.collapsed ? `${props.username} — account menu` : undefined,
)
const colorModeOptions: { label: string; value: ColorModePreference }[] = [
{ label: 'System', value: 'system' },
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' },
]
const items = computed(() => [
[{ type: 'label' as const, label: props.username }],
colorModeOptions.map((option) => ({
type: 'checkbox' as const,
label: option.label,
checked: colorMode.preference === option.value,
onUpdateChecked: (checked: boolean) => {
if (checked) {
colorMode.preference = option.value
}
},
})),
[
{
label: 'Log out',
onSelect: () => emit('logout'),
},
],
])
</script>
<template>
<UDropdownMenu :items="items" :content="{ side: 'top', align: 'start' }">
<UButton
color="neutral"
variant="ghost"
block
class="h-10 justify-start gap-2.5 px-1.5 font-normal text-highlighted"
trailing-icon="i-lucide-chevrons-up-down"
:ui="{ trailingIcon: 'ms-auto size-3.5 text-default' }"
:aria-label="triggerAriaLabel"
>
<template #leading>
<UAvatar
aria-hidden="true"
:text="initials"
size="sm"
:ui="{
root: 'size-6.5 bg-secondary-300',
fallback: 'text-[0.6875rem] font-semibold text-primary-900',
}"
/>
</template>
<span v-if="!collapsed" class="truncate">{{ username }}</span>
</UButton>
</UDropdownMenu>
</template>
@@ -14,11 +14,15 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
}
})
async function mountShell(path: string) {
async function mountShell(path: string, username = 'marta') {
setActivePinia(createPinia())
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn().mockResolvedValue({ verified: true }) })
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username }),
request: vi.fn().mockResolvedValue(undefined),
})
await router.push(path)
const wrapper = mount(DashboardShell, {
attachTo: document.body,
global: {
plugins: [router, uiPlugin],
},
@@ -29,6 +33,7 @@ async function mountShell(path: string) {
afterEach(async () => {
await router.push('/login')
document.body.innerHTML = ''
})
describe('DashboardShell renders the three navigation destinations', () => {
@@ -66,3 +71,86 @@ describe('DashboardShell shows the offline banner when the browser reports offli
onLineSpy.mockRestore()
})
})
describe('The sidebar header renders AppLogo and the "RenewCMS" wordmark', () => {
it('renders the logo before the wordmark text, neither as a link', async () => {
const wrapper = await mountShell('/home')
const header = wrapper.find('[data-slot="header"]')
expect(header.find('svg[role="img"][aria-label="RenewCMS"]').exists()).toBe(true)
expect(header.text()).toContain('RenewCMS')
expect(header.find('a').exists()).toBe(false)
const headerHtml = header.html()
expect(headerHtml.indexOf('<svg')).toBeLessThan(headerHtml.indexOf('RenewCMS</span>'))
})
})
describe('The sidebar collapse trigger toggles the rail and exposes aria-expanded', () => {
it('flips the rail and aria-expanded on repeated activation', async () => {
await mountShell('/home')
const collapseButton = () =>
document.body.querySelector('[data-testid="sidebar-collapse"]') as HTMLButtonElement
expect(collapseButton().getAttribute('aria-expanded')).toBe('true')
collapseButton().click()
await flushPromises()
expect(collapseButton().getAttribute('aria-expanded')).toBe('false')
collapseButton().click()
await flushPromises()
expect(collapseButton().getAttribute('aria-expanded')).toBe('true')
})
})
describe('A successful logout clears the session and navigates to /login', () => {
it('calls POST /v1/auth/logout, clears the session and navigates to /login', async () => {
await mountShell('/home')
const logOutItem = () =>
Array.from(document.body.querySelectorAll('[role="menuitem"]')).find(
(el) => el.textContent?.trim() === 'Log out',
) as HTMLElement
const trigger = document.body.querySelector('button[aria-haspopup]') as HTMLButtonElement
trigger.click()
await flushPromises()
logOutItem().click()
await flushPromises()
expect(vi.mocked(useApi)().request).toHaveBeenCalledWith(
'/v1/auth/logout',
expect.objectContaining({ method: 'POST' }),
)
expect(router.currentRoute.value.path).toBe('/login')
})
})
describe('Collapsed sidebar hides the wordmark and username text but keeps an accessible name', () => {
it('hides visible text but keeps accessible names for the logo and the user menu', async () => {
await mountShell('/home')
const collapseButton = document.body.querySelector(
'[data-testid="sidebar-collapse"]',
) as HTMLButtonElement
collapseButton.click()
await flushPromises()
const header = document.body.querySelector('[data-slot="header"]') as HTMLElement
expect(header.querySelector('svg[role="img"][aria-label="RenewCMS"]')).toBeTruthy()
expect(header.textContent).not.toContain('RenewCMS')
expect(header.querySelector('span[aria-hidden="true"]')).toBeNull()
const footer = document.body.querySelector('[data-slot="footer"]') as HTMLElement
expect(footer.textContent).not.toContain('marta')
const userMenuTrigger = footer.querySelector('button[aria-haspopup]') as HTMLButtonElement
expect(userMenuTrigger.getAttribute('aria-label')).toBe('marta — account menu')
expect(footer.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
})
})
@@ -0,0 +1,155 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import UserMenu from '../UserMenu.vue'
import { useColorMode } from '@/composables/useColorMode'
function mountMenu(props: { username: string; collapsed: boolean }) {
return mount(UserMenu, {
attachTo: document.body,
props,
global: {
plugins: [uiPlugin],
},
})
}
function findTriggerButton() {
return document.body.querySelector('button')
}
async function openMenu() {
findTriggerButton()?.click()
await flushPromises()
}
function findMenuItemsByRole(role: string) {
return Array.from(document.body.querySelectorAll(`[role="${role}"]`))
}
function findCheckboxItem(label: string) {
return findMenuItemsByRole('menuitemcheckbox').find((el) => el.textContent?.trim() === label)
}
beforeEach(() => {
localStorage.clear()
document.documentElement.classList.remove('dark', 'light')
})
afterEach(() => {
document.body.innerHTML = ''
})
describe("UserMenu's dropdown lists the account label, color-mode checkboxes, then Log out", () => {
it('shows avatar initials MA, the username, and the three ordered groups', async () => {
const wrapper = mountMenu({ username: 'marta', collapsed: false })
const avatar = document.body.querySelector('[data-slot="fallback"]')
expect(avatar?.textContent).toBe('MA')
expect(avatar?.getAttribute('aria-hidden')).toBe('true')
expect(wrapper.text()).toContain('marta')
await openMenu()
const groups = findMenuItemsByRole('group')
expect(groups.length).toBe(3)
expect(groups[0].textContent).toContain('marta')
const checkboxLabels = findMenuItemsByRole('menuitemcheckbox').map((el) =>
el.textContent?.trim(),
)
expect(checkboxLabels).toEqual(['System', 'Light', 'Dark'])
expect(groups[2].textContent?.trim()).toBe('Log out')
wrapper.unmount()
})
it('derives initials from the first letter of the first two words for a multi-word username', () => {
const wrapper = mountMenu({ username: 'jane doe', collapsed: false })
const avatar = document.body.querySelector('[data-slot="fallback"]')
expect(avatar?.textContent).toBe('JD')
wrapper.unmount()
})
it('checks exactly the item matching the current color-mode preference', async () => {
const colorMode = useColorMode()
colorMode.preference = 'dark'
const wrapper = mountMenu({ username: 'marta', collapsed: false })
await openMenu()
expect(findCheckboxItem('Dark')?.getAttribute('aria-checked')).toBe('true')
expect(findCheckboxItem('Light')?.getAttribute('aria-checked')).toBe('false')
expect(findCheckboxItem('System')?.getAttribute('aria-checked')).toBe('false')
wrapper.unmount()
})
})
describe('Selecting a color-mode item updates the preference and its checked state', () => {
it.each([
['system', 'light'],
['light', 'dark'],
['dark', 'system'],
])('moves from %s to %s', async (from, to) => {
const colorMode = useColorMode()
colorMode.preference = from as 'system' | 'light' | 'dark'
const wrapper = mountMenu({ username: 'marta', collapsed: false })
await openMenu()
const label = to.charAt(0).toUpperCase() + to.slice(1)
const fromLabel = from.charAt(0).toUpperCase() + from.slice(1)
findCheckboxItem(label)?.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
findCheckboxItem(label)?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
expect(colorMode.preference).toBe(to)
await openMenu()
expect(findCheckboxItem(label)?.getAttribute('aria-checked')).toBe('true')
expect(findCheckboxItem(fromLabel)?.getAttribute('aria-checked')).toBe('false')
wrapper.unmount()
})
})
describe('A successful logout clears the session and navigates to /login', () => {
it('emits logout when the user activates Log out', async () => {
const wrapper = mountMenu({ username: 'marta', collapsed: false })
await openMenu()
const logOutItem = Array.from(document.body.querySelectorAll('[role="menuitem"]')).find(
(el) => el.textContent?.trim() === 'Log out',
)
;(logOutItem as HTMLElement)?.click()
await flushPromises()
expect(wrapper.emitted('logout')).toBeTruthy()
wrapper.unmount()
})
})
describe('Collapsed sidebar hides the wordmark and username text but keeps an accessible name', () => {
it('hides the visible username but keeps it in the accessible name, and opens the same dropdown', async () => {
const wrapper = mountMenu({ username: 'marta', collapsed: true })
expect(wrapper.text()).not.toContain('marta')
expect(findTriggerButton()?.getAttribute('aria-label')).toBe('marta — account menu')
expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
await openMenu()
const groups = findMenuItemsByRole('group')
expect(groups.length).toBe(3)
expect(groups[0].textContent).toContain('marta')
wrapper.unmount()
})
})
+3 -2
View File
@@ -21,6 +21,7 @@ export class NetworkError extends Error {
export interface MeResult {
verified: boolean
username?: string
}
export type OnUnauthorized = 'redirect' | 'passthrough'
@@ -99,10 +100,10 @@ export function useApi() {
}
async function getMe(): Promise<MeResult> {
const body = await request<{ is_verified: boolean }>('/v1/auth/me', {
const body = await request<{ is_verified: boolean; username?: string }>('/v1/auth/me', {
onUnauthorized: 'passthrough',
})
return { verified: Boolean(body.is_verified) }
return { verified: Boolean(body.is_verified), username: body.username }
}
return { getMe, request }
+26
View File
@@ -0,0 +1,26 @@
import { useApi } from '@/composables/useApi'
export interface ArticleImage {
id: number
url: string
}
export interface Article {
id: number
title: string
body: string
images: ArticleImage[]
is_online: boolean
created_at: string
updated_at: string
}
export function useArticles() {
const { request } = useApi()
function listArticles(): Promise<Article[]> {
return request<Article[]>('/v1/article/')
}
return { listArticles }
}
+19
View File
@@ -0,0 +1,19 @@
import { useColorMode as useVueUseColorMode } from '@vueuse/core'
export type ColorModePreference = 'system' | 'light' | 'dark'
export function useColorMode() {
const { store, system } = useVueUseColorMode()
return {
get preference(): ColorModePreference {
return store.value === 'auto' ? 'system' : (store.value as ColorModePreference)
},
set preference(value: ColorModePreference) {
store.value = value === 'system' ? 'auto' : value
},
get value() {
return store.value === 'auto' ? system.value : store.value
},
}
}
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { formatRelativeDate } from '../relativeDate'
const now = new Date('2026-08-25T12:00:00Z')
describe('formatRelativeDate', () => {
it('formats under an hour in minutes', () => {
expect(formatRelativeDate('2026-08-25T11:45:00Z', now)).toBe('15 minutes ago')
})
it('clamps a sub-minute difference to "1 minute ago"', () => {
expect(formatRelativeDate('2026-08-25T11:59:45Z', now)).toBe('1 minute ago')
})
it('uses the singular for exactly one minute ago', () => {
expect(formatRelativeDate('2026-08-25T11:59:00Z', now)).toBe('1 minute ago')
})
it('formats under a day in hours', () => {
expect(formatRelativeDate('2026-08-25T10:00:00Z', now)).toBe('2 hours ago')
})
it('uses the singular for exactly one hour ago', () => {
expect(formatRelativeDate('2026-08-25T11:00:00Z', now)).toBe('1 hour ago')
})
it('formats exactly one day back as Yesterday', () => {
expect(formatRelativeDate('2026-08-24T11:00:00Z', now)).toBe('Yesterday')
})
it('formats within the week in days', () => {
expect(formatRelativeDate('2026-08-22T12:00:00Z', now)).toBe('3 days ago')
})
it('formats exactly 7 days ago as an absolute date, not "7 days ago"', () => {
expect(formatRelativeDate('2026-08-18T12:00:00Z', now)).toBe('18 Aug 2026')
})
it('formats a week or more ago as an absolute date', () => {
expect(formatRelativeDate('2026-08-10T12:00:00Z', now)).toBe('10 Aug 2026')
})
})
+45
View File
@@ -0,0 +1,45 @@
const MINUTE_MS = 60 * 1000
const HOUR_MS = 60 * MINUTE_MS
const DAY_MS = 24 * HOUR_MS
const RELATIVE_CUTOFF_DAYS = 7
const MONTH_NAMES = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
]
function formatAbsoluteDate(date: Date) {
return `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}`
}
export function formatRelativeDate(iso: string, now: Date = new Date()) {
const date = new Date(iso)
const diffMs = now.getTime() - date.getTime()
if (diffMs < HOUR_MS) {
const minutes = Math.max(1, Math.floor(diffMs / MINUTE_MS))
return minutes === 1 ? '1 minute ago' : `${minutes} minutes ago`
}
if (diffMs < DAY_MS) {
const hours = Math.floor(diffMs / HOUR_MS)
return hours === 1 ? '1 hour ago' : `${hours} hours ago`
}
const days = Math.floor(diffMs / DAY_MS)
if (days === 1) {
return 'Yesterday'
}
if (days < RELATIVE_CUTOFF_DAYS) {
return `${days} days ago`
}
return formatAbsoluteDate(date)
}
+2 -2
View File
@@ -75,13 +75,13 @@ describe('Nuxt UI shell foundation and router guards', () => {
await flushPromises()
expect(app.find('[aria-label="Loading"]').exists()).toBe(true)
expect(app.text()).not.toContain('Home placeholder')
expect(app.text()).not.toContain('Connect your website')
resolveMe({ verified: true })
await flushPromises()
expect(app.find('[aria-label="Loading"]').exists()).toBe(false)
expect(app.text()).toContain('Home placeholder')
expect(app.text()).toContain('Connect your website')
expect(router.currentRoute.value.path).toBe('/home')
})
+1
View File
@@ -6,6 +6,7 @@ export type SessionStatus = 'idle' | 'checking' | 'ready' | 'error'
export interface Session {
verified: boolean
username?: string
}
export const useSessionStore = defineStore('session', () => {
+26 -1
View File
@@ -39,6 +39,14 @@ export const renewUi = {
slots: {
base: 'rounded-none font-medium transition-colors duration-(--duration-fast) ease-(--ease-standard)',
},
variants: {
size: {
md: {
leadingIcon: 'size-4',
trailingIcon: 'size-4',
},
},
},
defaultVariants: {
size: 'md',
},
@@ -98,6 +106,14 @@ export const renewUi = {
slots: {
base: 'rounded-none font-medium',
},
variants: {
size: {
md: {
base: 'h-5.5 gap-1 px-1.75 py-0 text-xs',
leadingIcon: 'size-3',
},
},
},
},
kbd: {
base: 'rounded-none font-mono',
@@ -181,7 +197,16 @@ export const renewUi = {
orientation: 'vertical',
active: true,
class: {
link: 'bg-(--ui-color-secondary-200) text-(--ui-color-primary-900) dark:bg-(--ui-color-secondary-300) before:hidden',
link: 'bg-(--ui-color-secondary-200) text-(--ui-color-primary-900) dark:bg-(--ui-color-secondary-300) font-medium before:hidden',
linkLeadingIcon: 'text-(--ui-color-primary-900)',
},
},
{
orientation: 'vertical',
active: false,
class: {
link: 'text-toned font-normal',
linkLeadingIcon: 'text-toned',
},
},
],
+267 -1
View File
@@ -1,9 +1,275 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import DashboardShell from '@/components/DashboardShell.vue'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import NewArticleModal from '@/components/NewArticleModal.vue'
import { useArticles, type Article } from '@/composables/useArticles'
import { useSessionStore } from '@/stores/session'
import { formatRelativeDate } from '@/lib/relativeDate'
const SKELETON_DELAY_MS = 150
const RECENT_LIST_SIZE = 5
type LoadState = 'loading' | 'default' | 'empty' | 'error'
const session = useSessionStore()
const { listArticles } = useArticles()
const loadState = ref<LoadState>('loading')
const articles = ref<Article[]>([])
const showSkeleton = ref(false)
const newArticleModalOpen = ref(false)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
function greetingForHour(hour: number) {
if (hour < 12) {
return 'Good morning'
}
if (hour < 18) {
return 'Good afternoon'
}
return 'Good evening'
}
const greeting = computed(() => {
const timeOfDayGreeting = greetingForHour(new Date().getHours())
const username = session.session?.username
return username ? `${timeOfDayGreeting}, ${username}` : timeOfDayGreeting
})
const subtitle = computed(() =>
loadState.value === 'empty'
? 'Welcome to RenewCMS.'
: "Here's where things stand on your site.",
)
const stats = computed(() => {
const online = articles.value.filter((article) => article.is_online).length
return {
total: articles.value.length,
online,
drafts: articles.value.length - online,
}
})
const statTiles = computed(() => [
{ label: 'articles', value: stats.value.total },
{ label: 'online', value: stats.value.online },
{ label: 'drafts', value: stats.value.drafts },
])
const recentArticles = computed(() =>
[...articles.value]
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
.slice(0, RECENT_LIST_SIZE),
)
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
skeletonTimer = null
}
}
async function loadArticles() {
loadState.value = 'loading'
showSkeleton.value = false
clearSkeletonTimer()
skeletonTimer = setTimeout(() => {
showSkeleton.value = true
}, SKELETON_DELAY_MS)
try {
const result = await listArticles()
articles.value = result
loadState.value = result.length === 0 ? 'empty' : 'default'
} catch {
loadState.value = 'error'
} finally {
clearSkeletonTimer()
showSkeleton.value = false
}
}
function openNewArticleModal() {
newArticleModalOpen.value = true
}
onMounted(loadArticles)
</script>
<template>
<DashboardShell>
<p>Home placeholder.</p>
<template #navbar-right>
<UButton
icon="i-lucide-plus"
color="neutral"
variant="ghost"
class="size-11 justify-center text-highlighted sm:hidden"
:ui="{ leadingIcon: 'size-5' }"
aria-label="New article"
@click="openNewArticleModal"
/>
</template>
<div class="flex max-w-244 flex-col gap-5 lg:gap-7">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-50 flex-1 flex-col gap-1">
<h1 data-testid="greeting" class="text-2xl font-semibold text-highlighted">
{{ greeting }}
</h1>
<p class="text-base/5 text-muted">{{ subtitle }}</p>
</div>
<UButton
icon="i-lucide-plus"
class="hidden px-3 sm:flex"
@click="openNewArticleModal"
>
New article
</UButton>
</div>
<template v-if="(loadState === 'loading' && showSkeleton) || loadState === 'default'">
<div class="grid grid-cols-3 gap-3">
<div
v-for="tile in statTiles"
:key="tile.label"
data-testid="stat-tile"
class="flex flex-col gap-1 border border-default bg-default p-4"
>
<template v-if="loadState === 'loading'">
<USkeleton class="h-7 w-10 rounded-none bg-accented" />
<USkeleton class="h-3 w-17.5 rounded-none bg-accented" />
</template>
<template v-else>
<span class="text-2xl font-semibold text-highlighted">{{
tile.value
}}</span>
<span class="text-muted">{{ tile.label }}</span>
</template>
</div>
</div>
<section class="flex flex-col gap-2.5">
<div class="flex items-baseline justify-between">
<h2 class="text-lg font-semibold text-highlighted">Recently updated</h2>
<RouterLink to="/article" class="text-toned underline underline-offset-3">
All articles
</RouterLink>
</div>
<div
v-if="loadState === 'loading'"
aria-busy="true"
class="border border-default bg-default"
>
<div
v-for="n in RECENT_LIST_SIZE"
:key="n"
class="flex min-h-13 items-center gap-3 border-t border-muted px-3 py-2 lg:min-h-11"
>
<USkeleton class="h-3 max-w-[60%] flex-1 rounded-none bg-accented" />
<USkeleton class="h-5 w-14 rounded-none bg-accented" />
</div>
</div>
<ul v-else data-testid="recent-list" class="border border-default bg-default">
<li v-for="article in recentArticles" :key="article.id">
<RouterLink
:to="`/article/${article.id}/edit`"
class="flex min-h-13 items-center gap-3 border-t border-muted px-3 py-2 lg:min-h-11"
>
<span class="min-w-0 flex-1 truncate font-medium text-highlighted">
{{ article.title }}
</span>
<ArticleStatusBadge :online="article.is_online" />
<span class="hidden w-27.5 text-right text-muted lg:inline">
{{ formatRelativeDate(article.updated_at) }}
</span>
</RouterLink>
</li>
</ul>
</section>
</template>
<UEmpty
v-else-if="loadState === 'empty'"
variant="outline"
icon="i-lucide-file-text"
title="Write your first article"
description="It starts as a draft, so nothing appears on your site until you publish it."
:ui="{
root: 'gap-3 rounded-none border border-default px-6 py-12 ring-0 sm:px-6 sm:py-12 lg:px-6 lg:py-12',
header: 'max-w-none gap-3',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-xl text-toned',
title: 'text-[1.125rem] font-semibold',
description: 'max-w-85 text-base text-wrap text-muted',
body: 'max-w-none',
}"
>
<template #actions>
<UButton
icon="i-lucide-plus"
class="h-11 px-4 lg:h-8"
@click="openNewArticleModal"
>
New article
</UButton>
</template>
</UEmpty>
<UAlert
v-else-if="loadState === 'error'"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
title="Couldn't load your articles"
description="Check your connection and try again."
:ui="{
root: 'gap-2.5 px-3.5 py-3',
icon: 'mt-0.5 size-4',
title: 'text-sm font-semibold',
description: 'mt-0.5 text-sm text-toned opacity-100',
actions: 'mt-2',
}"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-7 bg-default px-2.5 text-xs text-highlighted"
:ui="{ leadingIcon: 'size-3.5' }"
@click="loadArticles"
>
Try again
</UButton>
</template>
</UAlert>
<div class="flex items-start gap-3.5 border border-default bg-default p-4">
<div
class="flex size-9 shrink-0 items-center justify-center bg-secondary-200 text-primary-900 dark:bg-secondary-300"
>
<UIcon name="i-lucide-plug" class="size-4.5" />
</div>
<div class="flex flex-1 flex-col gap-1">
<h2 class="text-base/5 font-semibold text-highlighted">Connect your website</h2>
<p class="text-pretty text-muted">
Your site reads published articles from RenewCMS. Share this guide with
whoever builds it.
</p>
<RouterLink
to="/integration"
class="mt-1 self-start font-medium text-highlighted underline underline-offset-3"
>
Open the guide
</RouterLink>
</div>
</div>
</div>
<NewArticleModal v-model:open="newArticleModalOpen" />
</DashboardShell>
</template>
+375
View File
@@ -0,0 +1,375 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import HomeView from '../HomeView.vue'
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: (name: string) => headers[name] ?? null },
json: async () => body,
} as unknown as Response
}
function routeFor(url: string, init?: RequestInit) {
const method = init?.method ?? 'GET'
return `${method} ${url}`
}
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
if (!handler) {
throw new Error(`Unexpected fetch call: ${key}`)
}
return handler()
})
}
const sixArticles: Article[] = [
{
id: 1,
title: 'How we price a bathroom renovation',
body: '',
images: [],
is_online: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-06T00:00:00Z',
},
{
id: 2,
title: 'Winter checklist for timber windows',
body: '',
images: [],
is_online: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-05T00:00:00Z',
},
{
id: 3,
title: 'Choosing tiles for a small bathroom',
body: '',
images: [],
is_online: false,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-04T00:00:00Z',
},
{
id: 4,
title: 'A guide to underfloor heating',
body: '',
images: [],
is_online: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-03T00:00:00Z',
},
{
id: 5,
title: 'When to reseal a stone worktop',
body: '',
images: [],
is_online: false,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-02T00:00:00Z',
},
{
id: 6,
title: 'Small bathroom lighting ideas',
body: '',
images: [],
is_online: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
},
]
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountHome(handlers: Record<string, () => Response | Promise<Response>>) {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
global.fetch = fetchRouter(handlers) as unknown as typeof fetch
await router.push('/home')
const wrapper = mount(HomeView, {
attachTo: document.body,
global: {
plugins: [router, uiPlugin],
},
})
currentWrapper = wrapper
await flushPromises()
return wrapper
}
function findButton(wrapper: Awaited<ReturnType<typeof mount>>, label: string) {
return wrapper.findAll('button').find((button) => button.text() === label)
}
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
vi.useRealTimers()
await router.push('/login')
})
describe('Skeletons appear only after 150 ms, never for a fast response', () => {
it('shows no skeleton before 150 ms, a skeleton after, then Default once resolved', async () => {
vi.useFakeTimers()
let resolveList: (() => void) | null = null
const wrapper = await mountHome({
'GET /v1/article/': () =>
new Promise((resolve) => {
resolveList = () => resolve(mockResponse(200, sixArticles))
}),
})
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
await vi.advanceTimersByTimeAsync(150)
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true)
resolveList?.()
await vi.advanceTimersByTimeAsync(0)
await flushPromises()
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
expect(wrapper.text()).toContain('6')
expect(wrapper.text()).toContain('articles')
})
it('never shows a skeleton when the response resolves in under 150 ms', async () => {
vi.useFakeTimers()
const wrapper = await mountHome({
'GET /v1/article/': () => Promise.resolve(mockResponse(200, sixArticles)),
})
await vi.advanceTimersByTimeAsync(0)
await flushPromises()
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
expect(wrapper.text()).toContain('6')
})
})
describe('Default — the list loaded with a mix of online and draft articles', () => {
it('shows the subtitle, stat tiles and the first 5 articles in order', async () => {
const wrapper = await mountHome({
'GET /v1/article/': () => mockResponse(200, sixArticles),
})
expect(wrapper.text()).toContain("Here's where things stand on your site.")
const rows = wrapper.find('[data-testid="recent-list"]').findAll('li')
expect(rows).toHaveLength(5)
expect(rows[0].text()).toContain('How we price a bathroom renovation')
expect(rows[4].text()).toContain('When to reseal a stone worktop')
expect(wrapper.text()).not.toContain('Small bathroom lighting ideas')
expect(rows[0].find('a').attributes('href')).toBe('/article/1/edit')
expect(rows[0].text()).toContain('Online')
expect(rows[2].text()).toContain('Draft')
expect(wrapper.text()).toContain('6')
expect(wrapper.text()).toContain('articles')
expect(wrapper.text()).toContain('4')
expect(wrapper.text()).toContain('online')
expect(wrapper.text()).toContain('2')
expect(wrapper.text()).toContain('drafts')
const guideLink = wrapper.findAll('a').find((a) => a.text() === 'Open the guide')
expect(guideLink?.attributes('href')).toBe('/integration')
await findButton(wrapper, 'New article')?.trigger('click')
await flushPromises()
expect(document.body.querySelector('[role="dialog"]')).toBeTruthy()
})
})
describe('Empty, first run — zero articles', () => {
it('shows the empty state and opens NewArticleModal', async () => {
const wrapper = await mountHome({
'GET /v1/article/': () => mockResponse(200, []),
})
expect(wrapper.text()).toContain('Welcome to RenewCMS.')
expect(wrapper.text()).toContain('Write your first article')
expect(wrapper.text()).toContain(
'It starts as a draft, so nothing appears on your site until you publish it.',
)
expect(wrapper.find('[data-testid="stat-tile"]').exists()).toBe(false)
await findButton(wrapper, 'New article')?.trigger('click')
await flushPromises()
expect(document.body.querySelector('[role="dialog"]')).toBeTruthy()
})
})
describe('Error — a 500 or a network failure shows the same inline alert', () => {
it.each([
{ name: 'a 500 response', handler: () => mockResponse(500) },
{
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('shows the alert with identical copy for $name', async ({ handler }) => {
const wrapper = await mountHome({
'GET /v1/article/': handler,
})
expect(wrapper.text()).toContain("Here's where things stand on your site.")
expect(wrapper.text()).toContain('Good')
const alert = wrapper.find('[role="alert"]')
expect(alert.exists()).toBe(true)
expect(alert.text()).toContain("Couldn't load your articles")
expect(alert.text()).toContain('Check your connection and try again.')
expect(findButton(wrapper, 'Try again')).toBeTruthy()
})
})
describe('Retry re-issues the request and recovers into the Default state', () => {
it('replaces the alert with the stat tiles and recent list once the retry succeeds', async () => {
const listCall = vi
.fn()
.mockResolvedValueOnce(mockResponse(500))
.mockResolvedValueOnce(mockResponse(200, sixArticles))
const wrapper = await mountHome({
'GET /v1/article/': listCall,
})
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
await findButton(wrapper, 'Try again')?.trigger('click')
await flushPromises()
expect(listCall).toHaveBeenCalledTimes(2)
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
expect(wrapper.text()).toContain('6')
expect(wrapper.text()).toContain('articles')
})
})
describe('The list is fetched exactly once per navigation', () => {
it('calls GET /v1/article/ exactly once and derives both stats and the list from it', async () => {
const listCall = vi.fn(() => mockResponse(200, sixArticles))
const wrapper = await mountHome({
'GET /v1/article/': listCall,
})
expect(listCall).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('6')
expect(wrapper.find('[data-testid="recent-list"]').findAll('li')).toHaveLength(5)
})
})
describe('The greeting matches the hour of day', () => {
it.each([
{ hour: 0, greeting: 'Good morning' },
{ hour: 11, greeting: 'Good morning' },
{ hour: 12, greeting: 'Good afternoon' },
{ hour: 17, greeting: 'Good afternoon' },
{ hour: 18, greeting: 'Good evening' },
{ hour: 23, greeting: 'Good evening' },
])('reads "$greeting, marta" at hour $hour', async ({ hour, greeting }) => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 0, 1, hour, 0, 0))
const wrapper = await mountHome({
'GET /v1/article/': () => mockResponse(200, sixArticles),
})
expect(wrapper.text()).toContain(`${greeting}, marta`)
})
})
describe('The greeting is identical across every load state at a fixed hour', () => {
it('reads "Good morning, marta" while loading, in Default, Empty and Error', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 0, 1, 9, 0, 0))
const loadingWrapper = await mountHome({
'GET /v1/article/': () => new Promise(() => {}),
})
await vi.advanceTimersByTimeAsync(150)
expect(loadingWrapper.text()).toContain('Good morning, marta')
const defaultWrapper = await mountHome({
'GET /v1/article/': () => mockResponse(200, sixArticles),
})
expect(defaultWrapper.text()).toContain('Good morning, marta')
const emptyWrapper = await mountHome({
'GET /v1/article/': () => mockResponse(200, []),
})
expect(emptyWrapper.text()).toContain('Good morning, marta')
const errorWrapper = await mountHome({
'GET /v1/article/': () => mockResponse(500),
})
expect(errorWrapper.text()).toContain('Good morning, marta')
})
})
describe('The greeting handles a missing username', () => {
it('renders the plain greeting without a stray comma when the session has no username', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 0, 1, 9, 0, 0))
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true }
session.status = 'ready'
global.fetch = fetchRouter({
'GET /v1/article/': () => mockResponse(200, sixArticles),
}) as unknown as typeof fetch
await router.push('/home')
const wrapper = mount(HomeView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
currentWrapper = wrapper
await flushPromises()
const heading = wrapper.find('[data-testid="greeting"]')
expect(heading.text()).toBe('Good morning')
expect(heading.text()).not.toContain('undefined')
expect(heading.text()).not.toMatch(/,\s*$/)
})
})
describe('The "Connect your website" card renders in every state', () => {
it('renders the same card while loading, in Default, Empty and Error', async () => {
vi.useFakeTimers()
const wrapper = await mountHome({
'GET /v1/article/': () => new Promise(() => {}),
})
await vi.advanceTimersByTimeAsync(150)
expect(wrapper.text()).toContain('Connect your website')
expect(wrapper.text()).toContain(
'Your site reads published articles from RenewCMS. Share this guide with whoever builds it.',
)
vi.useRealTimers()
const defaultWrapper = await mountHome({
'GET /v1/article/': () => mockResponse(200, sixArticles),
})
expect(defaultWrapper.text()).toContain('Connect your website')
const emptyWrapper = await mountHome({
'GET /v1/article/': () => mockResponse(200, []),
})
expect(emptyWrapper.text()).toContain('Connect your website')
const errorWrapper = await mountHome({
'GET /v1/article/': () => mockResponse(500),
})
expect(errorWrapper.text()).toContain('Connect your website')
})
})
+4
View File
@@ -283,6 +283,7 @@ async function captureApp(
viewport: { width, height },
colorScheme: theme,
reducedMotion: 'reduce',
...(setup.now ? { timezoneId: 'UTC' } : {}),
})
await serveIconsLocally(context)
const unmocked = new Set<string>()
@@ -301,6 +302,9 @@ async function captureApp(
await route.fulfill(fulfilment(mock))
})
const page = await context.newPage()
if (setup.now) {
await page.clock.setFixedTime(new Date(setup.now))
}
await page.goto(`${origin}${setup.path}`)
await Promise.race([page.waitForLoadState('networkidle'), page.waitForTimeout(2000)])
await setup.steps?.(page)
+99
View File
@@ -9,6 +9,7 @@ export interface ApiMock {
export interface VisualState {
path: string
now?: string
api?: Record<string, ApiMock>
steps?: (page: Page) => Promise<void>
}
@@ -80,7 +81,105 @@ async function confirmCancelDialog(page: Page) {
.click()
}
const boardNow = '2026-09-10T09:00:00Z'
const sixArticlesFixture = [
{
id: 1,
title: 'How we price a bathroom renovation',
body: '',
images: [],
is_online: true,
created_at: '2026-03-12T09:00:00Z',
updated_at: '2026-09-10T07:00:00Z',
},
{
id: 2,
title: 'Choosing lime plaster for old stone walls',
body: '',
images: [],
is_online: false,
created_at: '2026-09-09T08:00:00Z',
updated_at: '2026-09-09T09:00:00Z',
},
{
id: 3,
title: 'Before and after: the Rue Verte kitchen',
body: '',
images: [],
is_online: true,
created_at: '2026-08-28T09:00:00Z',
updated_at: '2026-09-07T09:00:00Z',
},
{
id: 4,
title: 'Winter checklist for timber windows',
body: '',
images: [],
is_online: false,
created_at: '2026-09-02T09:00:00Z',
updated_at: '2026-09-05T09:00:00Z',
},
{
id: 5,
title: 'Why we reuse original floorboards',
body: '',
images: [],
is_online: true,
created_at: '2026-06-14T09:00:00Z',
updated_at: '2026-08-18T09:00:00Z',
},
{
id: 6,
title: 'Our first passive-house retrofit',
body: '',
images: [],
is_online: true,
created_at: '2026-04-03T09:00:00Z',
updated_at: '2026-07-02T09:00:00Z',
},
]
const verifiedSession: ApiMock = {
status: 200,
body: { is_verified: true, username: 'marta' },
}
export const visualStates: Record<string, Record<string, VisualState>> = {
home: {
Default: {
path: '/home',
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
},
},
Loading: {
path: '/home',
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/article/': { status: 200, body: sixArticlesFixture, hang: true },
},
},
'Empty, first run': {
path: '/home',
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/article/': { status: 200, body: [] },
},
},
Error: {
path: '/home',
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/article/': { status: 500 },
},
},
},
'log-in': {
Default: { path: '/login', api: { 'GET /v1/auth/registration': registrationClosed } },
'Fresh install': { path: '/login', api: { 'GET /v1/auth/registration': registrationOpen } },