Compare commits

..
16 Commits
Author SHA1 Message Date
fsylvain ad55137883 feat: integration screen 2026-09-27 18:04:43 +02:00
fsylvain a9a208b80a fix: workflow now ignores time senstive visual checks 2026-09-27 17:15:22 +02:00
fsylvain f6d0d01092 feat: articles list screen 2026-09-27 17:12:23 +02:00
fsylvain ca3fe20519 fix: browser was running two copies of ProseMirror's state module (prosemirror-state) at once 2026-09-27 06:31:45 +02:00
fsylvain c47d33eaa9 feat: article creation modal 2026-09-27 06:26:41 +02:00
fsylvain a4734b2c91 feat: article editor core 2026-09-27 02:11:38 +02:00
fsylvain d493a181e1 fix: visuals reference matching overhaul and better workflow 2026-09-26 21:04:51 +02:00
fsylvain 930b836241 feat: home screen and navigation design 2026-09-26 17:46:42 +02:00
fsylvain 30a4df47b1 feat: new emails design 2026-09-26 05:41:34 +02:00
fsylvain 4e41d60142 feat: register T1 (pending, verify) 2026-09-26 04:56:48 +02:00
fsylvain 3c3f7c6194 feat: register T0 2026-09-26 02:48:16 +02:00
fsylvain a5873f8886 fix: changed frontend workflow and fixed login reference matching for real 2026-09-25 22:31:44 +02:00
fsylvain f1e22f7073 fix: login match design reference 2026-09-25 21:28:12 +02:00
fsylvain 001f9f4774 feat: login page T0 2026-09-25 20:15:29 +02:00
fsylvain 12faab7f92 feat: typed API client (useApi) and session store (useSession) with the global 401, unverified-403, 429 and offline handling from states.md 2026-09-25 17:33:40 +02:00
fsylvain eed661bb24 feat: PrimeVue replaced by Nuxt UI using token files from web/src/theme, with AuthShell, DashboardShell, AppBoot and the route guards from routes.md 2026-09-25 17:09:34 +02:00
146 changed files with 18512 additions and 201 deletions

No files matched your search

+3 -2
View File
@@ -1,9 +1,10 @@
PUBLIC_URL=http://localhost:5173 PUBLIC_URL=http://localhost:5173
JWT_SECRET=change-me-to-a-long-random-string JWT_SECRET=change-me-to-a-long-random-string
SMTP_EMAIL=a@b.c
SMTP_PASSWORD=12341234
SMTP_HOST=mail.service.abc SMTP_HOST=mail.service.abc
SMTP_PORT=465 SMTP_PORT=465
SMTP_LOGIN=a@b.c
SMTP_PASSWORD=12341234
SMTP_SENDER=a@b.c
ENVIRONMENT=development ENVIRONMENT=development
PORT=8080 PORT=8080
+7
View File
@@ -66,6 +66,13 @@ tasks:
cmds: cmds:
- bun run vitest run - bun run vitest run
visual:check:
desc: Screenshot a design screen's states next to the built SPA (Playwright) → tmp/visual/<screen>/
dir: web
deps: [web:install]
cmds:
- bun visual/check.ts {{.CLI_ARGS}}
lint: lint:
desc: gofmt check, go vet, eslint (--fix) and vue-tsc desc: gofmt check, go vet, eslint (--fix) and vue-tsc
cmds: cmds:
+2 -1
View File
@@ -32,7 +32,8 @@ var possibleEnvFileLocations = []string{".env", "../.env"}
var requiredEnvVars = []string{ var requiredEnvVars = []string{
"PUBLIC_URL", "PUBLIC_URL",
"JWT_SECRET", "JWT_SECRET",
"SMTP_EMAIL", "SMTP_LOGIN",
"SMTP_SENDER",
"SMTP_PASSWORD", "SMTP_PASSWORD",
"SMTP_HOST", "SMTP_HOST",
"SMTP_PORT", "SMTP_PORT",
+2 -1
View File
@@ -12,7 +12,8 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
|-----------------|---------------------------------------------------------------------------------------------------------------------|----------------------------| |-----------------|---------------------------------------------------------------------------------------------------------------------|----------------------------|
| `PUBLIC_URL` | Public base URL of the site, including scheme and no trailing slash. Used for links in emails and in the startup log. | `https://cms.example.com` | | `PUBLIC_URL` | Public base URL of the site, including scheme and no trailing slash. Used for links in emails and in the startup log. | `https://cms.example.com` |
| `JWT_SECRET` | Secret used to sign the auth JWT. Use a long random value; changing it logs everyone out. | `openssl rand -hex 32` | | `JWT_SECRET` | Secret used to sign the auth JWT. Use a long random value; changing it logs everyone out. | `openssl rand -hex 32` |
| `SMTP_EMAIL` | Sender address and SMTP username. | `noreply@example.com` | | `SMTP_LOGIN` | SMTP username used to authenticate with the server. Some providers (e.g. Brevo) issue a login distinct from the sender address. | `noreply@example.com` |
| `SMTP_SENDER` | Sender address used in the `From` header. Must be an address/domain the SMTP provider allows that login to send as. | `noreply@example.com` |
| `SMTP_PASSWORD` | SMTP password. | | | `SMTP_PASSWORD` | SMTP password. | |
| `SMTP_HOST` | SMTP server host. | `smtp.example.com` | | `SMTP_HOST` | SMTP server host. | `smtp.example.com` |
| `SMTP_PORT` | SMTP server port. | `465` | | `SMTP_PORT` | SMTP server port. | `465` |
+2 -1
View File
@@ -47,7 +47,8 @@ PORT=8080
DB_FILE=/var/lib/renewcms/renewcms.db DB_FILE=/var/lib/renewcms/renewcms.db
UPLOAD_DIR=/var/lib/renewcms/uploads UPLOAD_DIR=/var/lib/renewcms/uploads
JWT_SECRET=<openssl rand -hex 32> JWT_SECRET=<openssl rand -hex 32>
SMTP_EMAIL=noreply@example.com SMTP_LOGIN=noreply@example.com
SMTP_SENDER=noreply@example.com
SMTP_PASSWORD=... SMTP_PASSWORD=...
SMTP_HOST=smtp.example.com SMTP_HOST=smtp.example.com
SMTP_PORT=465 SMTP_PORT=465
+19 -1
View File
@@ -57,6 +57,8 @@ task --list # everything available
task test # Go tests; extra args pass through: task test -- -run TestCreateArticle -v task test # Go tests; extra args pass through: task test -- -run TestCreateArticle -v
task cover # coverage of the use cases → tmp/coverage.out task cover # coverage of the use cases → tmp/coverage.out
task mutate # gremlins mutation testing → tmp/gremlins.json task mutate # gremlins mutation testing → tmp/gremlins.json
task test:web # SPA component tests (Vitest)
task visual:check -- log-in # screenshot design vs SPA for a screen → tmp/visual/log-in/
task lint # gofmt check, go vet, eslint --fix, vue-tsc task lint # gofmt check, go vet, eslint --fix, vue-tsc
task fmt # gofmt -w + prettier task fmt # gofmt -w + prettier
task run # build the production binary and run it (SPA embedded) on :8080 task run # build the production binary and run it (SPA embedded) on :8080
@@ -67,7 +69,23 @@ task clean # remove bin/, tmp/ and the built SPA
- All Go tests live in [`test/`](../test) (package `test`, black-box). They use in-memory fakes of the domain `Repository` interfaces, so they need no database, SMTP server or network. - All Go tests live in [`test/`](../test) (package `test`, black-box). They use in-memory fakes of the domain `Repository` interfaces, so they need no database, SMTP server or network.
- HTTP-level tests use `net/http/httptest` against a chi router. Call `validator.InitValidator()` before invoking a handler. - HTTP-level tests use `net/http/httptest` against a chi router. Call `validator.InitValidator()` before invoking a handler.
- The frontend has no test runner yet. `task lint:web` runs eslint and a type check. - SPA component tests use Vitest + Vue Test Utils (`task test:web`). `task lint:web` runs eslint and a type check.
## Visual check against the design
`task visual:check -- <screen> [state]` compares the SPA with the Claude Design boards in [`docs/design/source`](design/source). The screen is a file name from [`docs/design/screens`](design/screens) (`log-in`, `home`, …), and the state is one of its headings.
- It builds the SPA into `tmp/visual/dist` and serves both the build and the boards locally, so no API or dev server is needed.
- For every state it screenshots:
- the board's frame for that state;
- the SPA in the same state, at 1440×900 and 390×844, in light and dark.
- It saves `design-*.png`, `app-*.png` and `diff-*.png` (differing pixels in red) to `tmp/visual/<screen>/<state>/`, and a summary to `tmp/visual/<screen>/report.md`.
- [`web/visual/states.ts`](../web/visual/states.ts) says how to reach each state in the SPA: the route, the mocked `/v1` responses (keyed `'METHOD /v1/path'`) and any user steps. A state without an entry is captured on the design side only, and marked "design only" in the report.
- Narrow a run with `--viewport=desktop|mobile` and `--theme=light|dark`.
- Lucide icons are served from the local `@iconify-json/lucide` set, for both the boards and the SPA. api.iconify.design rate-limits repeated runs.
- The first time on a machine, run `bunx playwright install chromium` in `web/`.
The pixel percentage only points at where to look, because font anti-aliasing always differs a little. Judge by comparing the image pairs.
## Data ## Data
@@ -0,0 +1,121 @@
Feature: Typed API client and session store with global 401, unverified-403, 429 and offline handling
As the maintainer of RenewCMS
I want useApi to expose one generic authenticated request method that every future screen call
site builds on, that turns a mid-session 401 into a redirect to /login with the expired reason
and clears the session, turns a 403 from a known-unverified session into a redirect to
/register/pending, surfaces the Retry-After seconds from a 429 so screens can show a countdown,
and marks the app offline on a network failure
So that later screen slices (login, articles, editor, ...) get consistent global session and
connectivity handling instead of each reimplementing 401/403/429/offline handling ad hoc
Background:
Given useApi exports a generic request(path, options) method, used by getMe and by every future
endpoint call, that always sends credentials: 'include' and parses a JSON response body on
success
And a non-2xx response makes request(...) throw an ApiError carrying the numeric status; a 429
response also carries retryAfter, the integer number of seconds parsed from the response's
Retry-After header, or undefined if the header is absent or not a plain integer
And a fetch that rejects instead of resolving (no connectivity, DNS failure, the request never
reaches a server) makes request(...) throw a NetworkError instead of an ApiError, so callers
can tell "the server answered with an error" apart from "the request never got an answer" with
instanceof
And useApi exports a shared reactive isOffline ref, module-scoped so every caller of useApi() and
every component observes the same value: a NetworkError sets it true; it is also true whenever
navigator.onLine is false; a request that completes (2xx or a non-network ApiError) while
navigator.onLine is true sets it back to false
And getMe() calls request('/v1/auth/me', { onUnauthorized: 'passthrough' }): a 401 from this one
call still throws a plain ApiError with status 401, exactly as before, and does not trigger the
redirect below, because this call only ever runs as part of session.check() during router
navigation (features/nuxt_ui_shell_foundation.feature), and the router's own guard already
redirects to /login?redirect=<target>&reason=expired for a "no session" boot result
And every other call to request(...) uses the default onUnauthorized: 'redirect': on a 401,
before the ApiError reaches the caller, useApi calls the session store's clear() action (session
= null, status stays 'ready') and navigates the router to
/login?redirect=<currentPath>&reason=expired, where <currentPath> is
router.currentRoute.value.fullPath at the moment the 401 is received
And on a 403, useApi reads the session store: if it holds a session whose verified is false, this
is the global "unverified account" case from docs/design/routes.md and states.md, so useApi
navigates the router to /register/pending; the ApiError still reaches the caller as a plain 403,
and the session store is left untouched, since it already correctly records verified: false; any
other 403 (no session, or a session whose verified is true) is not the global case: useApi does
nothing extra and the ApiError propagates to the caller unchanged, for the screen to interpret
(wrong-credentials at login, already-verified at cancel-registration, an expired verify or reset
link, and registration-closed are all screen-level, out of scope here)
And the session store gains a clear() action (session = null, status = 'ready', independent of
check()/retry()) used by the global 401 handling above
Scenario: A successful authenticated request returns the parsed body
Given a mocked fetch that resolves 200 with JSON body {"id": 5}
When useApi().request('/v1/article/5') is called
Then the returned value is {"id": 5}
And isOffline is false
Scenario: A 401 received mid-session redirects to login with the expired reason and clears the session
Given the session store holds session {"verified": true} and status "ready"
And the current route is /article/12/edit
And a mocked fetch that resolves 401
When useApi().request('/v1/article/12') is called
Then the call rejects with an ApiError whose status is 401
And the session store's session becomes null and its status stays "ready"
And the router navigates to "/login?redirect=/article/12/edit&reason=expired"
Scenario: The boot call to getMe does not trigger the global 401 redirect
Given the session store holds session null and status "checking"
And a mocked fetch that resolves 401
When useApi().getMe() is called directly, outside of router navigation
Then the call rejects with an ApiError whose status is 401
And the router does not navigate anywhere
Scenario: A 403 while the session is known to be unverified redirects to the pending page
Given the session store holds session {"verified": false} and status "ready"
And a mocked fetch that resolves 403
When useApi().request('/v1/article/') is called
Then the call rejects with an ApiError whose status is 403
And the router navigates to "/register/pending"
And the session store's session is still {"verified": false}
Scenario Outline: A 403 that is not the global unverified case passes through untouched
Given the session store holds session <session> and status "ready"
And a mocked fetch that resolves 403
When useApi().request('/v1/some/endpoint') is called
Then the call rejects with an ApiError whose status is 403
And the router does not navigate anywhere
And the session store is unchanged
Examples:
| session |
| null |
| {"verified": true} |
Scenario: A 429 on a rate-limited endpoint exposes the Retry-After seconds
Given a mocked fetch that resolves 429 with a Retry-After header of "30"
When useApi().request('/v1/auth/login', { method: 'POST' }) is called
Then the call rejects with an ApiError whose status is 429 and whose retryAfter is 30
Scenario: A 429 with no Retry-After header exposes an undefined retryAfter
Given a mocked fetch that resolves 429 with no Retry-After header
When useApi().request('/v1/auth/password-reset', { method: 'POST' }) is called
Then the call rejects with an ApiError whose status is 429 and whose retryAfter is undefined
Scenario: A network failure throws a NetworkError and marks the app offline
Given navigator.onLine is true
And a mocked fetch that rejects with a TypeError, as a real fetch does when the network is
unreachable
When useApi().request('/v1/article/') is called
Then the call rejects with a NetworkError, not an ApiError
And isOffline becomes true
Scenario: A later successful request clears the offline flag
Given isOffline is true from a previous network failure
And navigator.onLine is true
And a mocked fetch that resolves 200 with JSON body {}
When useApi().request('/v1/article/') is called
Then isOffline becomes false
Scenario: The browser's own online/offline events also drive the shared flag
Given isOffline is false
When the browser fires a "offline" event on window
Then isOffline becomes true
When the browser fires a "online" event on window
Then isOffline becomes false
@@ -0,0 +1,76 @@
# QA: Typed API client and session store with global 401, unverified-403, 429 and offline handling
This is a composable/store-only slice with no new screen and no new `/v1` handler, so there is no
Go `httptest` procedure. Verification is entirely Vitest (`task test:web`), unit-testing
`web/src/composables/useApi.ts` and `web/src/stores/session.ts` directly, plus a small check that
the existing router guard suite still passes unmodified.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `global.fetch` per case (`vi.fn()`), install a Pinia instance (`createPinia()` /
`setActivePinia`) before each test, and import the real `router` singleton from `@/router` so
`request(...)`'s navigation calls are observable on `router.currentRoute`. No real backend, no
httptest server, no live SMTP.
1. **Successful request returns parsed JSON and leaves isOffline false** — mock fetch resolving
`{ ok: true, status: 200, json: async () => ({ id: 5 }) }`; call
`useApi().request('/v1/article/5')`; assert it resolves to `{ id: 5 }`, assert `fetch` was called
with `credentials: 'include'`, and assert the exported `isOffline` ref is `false`.
2. **401 mid-session redirects and clears the session** — seed the session store with
`session = { verified: true }`, `status = 'ready'`; navigate the router to `/article/12/edit`
first so `router.currentRoute.value.fullPath` matches; mock fetch resolving status 401; call
`useApi().request('/v1/article/12')`; assert the promise rejects with an `ApiError` whose
`status === 401`; assert `session.session === null` and `session.status === 'ready'`; assert
`router.currentRoute.value.fullPath` is
`/login?redirect=%2Farticle%2F12%2Fedit&reason=expired` (or the equivalent decoded query object).
3. **getMe's own 401 does not trigger the global redirect** — seed
`session = null, status = 'checking'`; mock fetch resolving status 401; record
`router.currentRoute.value.fullPath` before the call; call `useApi().getMe()`; assert it rejects
with `ApiError` status 401; assert the router's current route is unchanged from before the call.
4. **403 with a known-unverified session redirects to /register/pending** — seed
`session = { verified: false }, status = 'ready'`; mock fetch resolving status 403; call
`useApi().request('/v1/article/')`; assert the promise rejects with `ApiError` status 403; assert
`router.currentRoute.value.path === '/register/pending'`; assert
`session.session` is still `{ verified: false }`.
5. **403 with no session, or a verified session, passes through untouched** — two cases,
`session = null` and `session = { verified: true }`, both `status = 'ready'`; mock fetch resolving
status 403; navigate the router to a known path first; call `useApi().request(...)`; assert the
promise rejects with `ApiError` status 403; assert the router's current route did not change;
assert the session store's state object is unchanged (`toEqual` the pre-call snapshot).
6. **429 exposes retryAfter parsed from the Retry-After header** — mock fetch resolving
`{ ok: false, status: 429, headers: new Headers({ 'Retry-After': '30' }) }`; call
`useApi().request('/v1/auth/login', { method: 'POST' })`; assert the rejection is an `ApiError`
with `status === 429` and `retryAfter === 30`.
7. **429 with no Retry-After header exposes retryAfter as undefined** — same as above with no header
set; assert `retryAfter === undefined`.
8. **Network failure throws NetworkError, not ApiError, and sets isOffline true** — set
`Object.defineProperty(navigator, 'onLine', { value: true, configurable: true })`; mock fetch to
`mockRejectedValue(new TypeError('Failed to fetch'))`; call `useApi().request('/v1/article/')`;
assert the rejection is instanceof `NetworkError` and `not instanceof ApiError`; assert `isOffline`
is `true` after the rejection settles.
9. **A later successful request clears isOffline** — starting from `isOffline === true` (reuse the
state from test 8, or set it directly if the module exposes a test seam), keep
`navigator.onLine === true`, mock fetch resolving 200; call `useApi().request('/v1/article/')`;
assert `isOffline` becomes `false` after the call resolves.
10. **window online/offline events drive isOffline directly** — dispatch
`window.dispatchEvent(new Event('offline'))`; assert `isOffline === true`; dispatch
`window.dispatchEvent(new Event('online'))`; assert `isOffline === false`. If `useApi`'s offline
tracking is implemented as a composable that needs to be "activated" (e.g. listeners attached on
first `useApi()` call or app mount), call `useApi()` once before dispatching the events.
11. **Regression: existing getMe/session/router-guard suites stay green** — run
`web/src/router/__tests__/routerGuard.spec.ts` (and any existing `useApi`/`session` spec files)
unmodified alongside the new tests in the same `task test:web` run; assert no existing test's
assertions changed and all still pass. This is not a new scenario to author, just a check that the
new request()/isOffline/clear() additions don't regress boot, session caching or the
public/session/verified/any redirect matrix already covered by
`features/nuxt_ui_shell_foundation.feature`.
## Manual verification
- **Real network offline test** — disable the machine's network adapter (or use browser devtools'
"Offline" throttling) against a `bun dev` instance, trigger a real authenticated call, and confirm
`isOffline` flips and a consuming banner-style UI would show, since Vitest only simulates a
rejected `fetch`, never a real dropped connection.
- **429 countdown UI** — this slice only proves `useApi` surfaces `retryAfter`; the actual countdown
display (`useRetryAfter`, per `docs/design/components.md`) belongs to the login and
password-reset screen slices and is out of scope here.
+259
View File
@@ -0,0 +1,259 @@
Feature: Articles list screen
As a verified RenewCMS owner
I want /article to list every article with its status, let me search and filter it, and let me
publish, unpublish or delete a row without leaving the page
So that I can manage my whole set of articles from one place, with the list always reflecting
what the server actually holds
Background:
Given /article is a verified route (meta.auth "verified") rendered as ArticlesPage inside
DashboardShell, per docs/design/routes.md
And ArticlesPage calls GET /v1/article/ once per navigation to load the full list (all
articles, online and draft, because the request carries the jwt cookie), and every row
action goes over PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish or
DELETE /v1/article/{id}; all three endpoints already exist and this slice wires the SPA to
them unchanged, except for one persistence-layer change: ArticleRepository.FindByFilters
(which backs GET /v1/article/) gains a .Preload("Images") call, mirroring the one
ArticleRepository.Get already has, so every article in the list response carries its real
Images array instead of coming back empty; no domain or use-case change is needed, since
article.Article.Images already exists
And the PageHeader shows breadcrumb "Articles" (mobile title "Articles")
And search and the active tab live in the URL query string (?q=, ?status=), so the filtered
view can be linked and survives a reload; the list is always sorted by last updated, newest
first, with no interactive sort control in this slice; search is case-insensitive, ignores
accents, matches anywhere in the title, and is debounced 120 ms; tab counts (All, Online,
Drafts) are always computed from the full list fetched by GET /v1/article/, so they never
change while the user is only searching
And the list is rendered as ArticleTable (desktop, md and up) or ArticleCardList (below md),
both fed by the same articles array, loading flag and per-row busy state, and both routed
through the same row actions
And each row shows its title (a real link to /article/:id/edit), an ArticleStatusBadge (online
or draft), and its updated date (relative within 7 days, e.g. "5 days ago"; an absolute date
beyond that, e.g. "18 Aug 2026", with the full timestamp in a title attribute)
And clicking anywhere on a row except its menu button navigates to /article/:id/edit
And each row's ellipsis button (or Enter while it is focused) opens that row's menu, named
"Actions for {title}", listing Edit (to /article/:id/edit), Publish or Unpublish (whichever
applies to that row's current status), Copy API URL, and, below a separator, "Delete…" in
brick red, always last
And "Copy API URL" writes the absolute URL "{origin}/v1/article/{id}" to the clipboard; on
success it raises a success toast (icon circle-check, id "article-link-copied-{id}"): title
"Link copied" text "The API URL for \"{title}\" is on your clipboard."; if the clipboard
write itself fails, it raises an error toast instead (icon circle-x, id
"article-link-copy-failed-{id}"): title "Couldn't copy the link", with no text line
And row actions are never optimistic: a row only changes once the server responds; while a
publish or unpublish request for a row is in flight, that row alone is disabled and shows a
busy label ("Publishing…" or "Unpublishing…"), and every other row stays interactive
And a successful publish or unpublish (200) updates that row's ArticleStatusBadge and updated
date in place and raises a success toast (icon circle-check, id
"article-published-{id}" or "article-unpublished-{id}" per components.md's action-plus-id
toast convention): title "Article published" text "\"{title}\" is now online." for a
publish, or title "Article unpublished" text "\"{title}\" is now offline." for an unpublish
And a 500 or network failure on a publish or unpublish leaves that row exactly as it was
before the request and raises an error toast (icon circle-x, duration 0 so it stays until
dismissed or retried, id "article-publish-failed-{id}" or "article-unpublish-failed-{id}"):
title "Couldn't publish" or "Couldn't unpublish", text "The server didn't respond. Nothing
changed.", with an action "Retry" that re-sends the same request
And a 404 on any row action (publish, unpublish or delete) closes any open row menu or delete
dialog for that row, removes the row from the list, recomputes the tab counts from the
remaining articles, and raises an info toast (icon info, id "article-not-found-{id}"): title
"That article no longer exists" text "It was deleted somewhere else, so we removed it from
the list."
And "Delete…" opens ConfirmDialog: title "Delete this article?" text "\"{title}\" and its
{n} images will be deleted. This can't be undone." (a components.md convention: the row's
title plus its image count, "and its image" for exactly 1 and no image clause at all for 0),
"Cancel" and "Delete article" (danger); while the DELETE request is in flight the dialog
cannot be dismissed by Cancel, Esc or a backdrop click, both buttons are disabled, the
confirm button reads "Deleting…" with a loading indicator, and aria-busy is set
And a 500 on the delete request keeps the dialog open and instead shows an inline alert inside
the dialog: "Couldn't delete the article, so it's still there. Try again in a moment.",
re-enabling "Cancel" and "Delete article" so the user can retry or back out; the article is
left untouched
And a successful delete (204) closes the dialog, removes the row, moves focus to the next row
(or the page heading if the deleted row was last), recomputes the tab counts, and raises a
success toast (icon circle-check, id "article-deleted-{id}"): title "Article deleted" text
"\"{title}\" and its images are gone."; this is also where a delete initiated from the editor
ends up
And the list, sorted by last updated with the most recently updated article first, holds
these 6 articles by default: id 1 "How we price a bathroom renovation" (online, updated "2
hours ago"), id 2 "Choosing lime plaster for old stone walls" (draft, updated "Yesterday"),
id 3 "Before and after: the Rue Verte kitchen" (online, updated "3 days ago"), id 4 "Winter
checklist for timber windows" (draft, updated "5 days ago"), id 5 "Why we reuse original
floorboards" (online, updated "18 Aug 2026"), id 6 "Our first passive-house retrofit"
(online, updated "2 Jul 2026") — 6 total, 4 online, 2 drafts
Scenario: Default — the list loads sorted by last updated, newest first
Given GET /v1/article/ resolves 200 with the 6-article fixture above
When the user navigates to /article
Then the breadcrumb reads "Articles" (mobile title "Articles")
And the tabs read "All 6" "Online 4" "Drafts 2"
And the rows appear in exactly the fixture's order, each with its title link, its
ArticleStatusBadge and its updated date
And the table (desktop) carries a visually hidden caption "Articles, sorted by last updated,
newest first"
Scenario: Loading — skeleton rows while the first request, or a retry, is in flight
Given GET /v1/article/ has not yet resolved
When the user navigates to /article
Then 5 skeleton rows replace the table or card list, and the list container is aria-busy="true"
When GET /v1/article/ then resolves 200 with the 6-article fixture
Then the skeleton rows are replaced by the Default state's rows
Scenario: Row menu open shows Edit, the status-appropriate action, Copy API URL and Delete…
Given the Default state, with the row for id 4 "Winter checklist for timber windows" (draft,
updated "5 days ago")
When the user activates that row's ellipsis button
Then that row's menu opens, named "Actions for Winter checklist for timber windows", listing
Edit, Publish, Copy API URL, and, below a separator, "Delete…" in brick red
And every other row is unaffected
Scenario: Copy API URL succeeds — the absolute URL is copied and confirmed
Given the Default state, with the row for id 4 "Winter checklist for timber windows"
When the user chooses "Copy API URL" from id 4's row menu
Then "{origin}/v1/article/4" is written to the clipboard
And a success toast appears: icon circle-check, id "article-link-copied-4", title "Link
copied", text "The API URL for \"Winter checklist for timber windows\" is on your
clipboard."
Scenario: Copy API URL fails — the clipboard write itself is rejected
Given the Default state, with the row for id 4 "Winter checklist for timber windows"
When the user chooses "Copy API URL" from id 4's row menu, and the clipboard write fails
Then an error toast appears: icon circle-x, id "article-link-copy-failed-4", title "Couldn't
copy the link", with no text line
Scenario: Empty — zero articles
Given GET /v1/article/ resolves 200 with zero articles
When the user navigates to /article
Then the tabs read "All 0" "Online 0" "Drafts 0"
And an EmptyState (variant no-articles) replaces the list: icon file-text, title "No articles
yet", text "Write your first one. It stays a draft until you publish it.", and a primary
action "New article"
When the user activates "New article"
Then NewArticleModal opens
Scenario: No results — a search matches nothing, but the tab counts still show the full list
Given the Default state has loaded
When the user types "tiles" into the search field
Then, after the 120 ms debounce, an EmptyState (variant no-results) replaces the list: icon
search, title "No articles match \"tiles\"", text "Check the spelling, or search for a
shorter part of the title.", and an action "Clear search"
And the tabs still read "All 6" "Online 4" "Drafts 2"
When the user activates "Clear search"
Then the search field is cleared, the Default state's rows reappear, and focus returns to the
search field
Scenario Outline: An empty tab shows its own EmptyState, while the other tab still counts correctly
Given GET /v1/article/ resolves 200 with <fixture>
When the user navigates to /article and selects the "<tab>" tab
Then the tabs read "<counts>"
And an EmptyState replaces the list: title "<title>" text "<text>" action "<action>"
Examples:
| fixture | tab | counts | title | text | action |
| 4 articles, all online, none drafts | Drafts | All 4, Online 4, Drafts 0 | No drafts | Everything you've written is online. | New article |
| 4 articles, all drafts, none online | Online | All 4, Online 0, Drafts 4 | Nothing is online yet | Publish a draft and it will appear on your website. | Show drafts |
Scenario: Action in progress — publishing a row disables only that row
Given the Default state has loaded
When the user chooses "Publish" from id 4 "Winter checklist for timber windows"'s row menu
Then PATCH /v1/article/4/publish is sent
And that row alone is disabled and shows the busy label "Publishing…", while the other 5 rows
stay interactive
Scenario: Published — a 200 updates the row in place and raises a toast
Given the user chose "Publish" from id 4 "Winter checklist for timber windows"'s row menu, and
the request is in flight
When PATCH /v1/article/4/publish resolves 200 with the article, is_online true, updated "Just
now"
Then id 4's row shows an online ArticleStatusBadge and updated date "Just now", no longer busy
And a success toast appears: icon circle-check, id "article-published-4", title "Article
published", text "\"Winter checklist for timber windows\" is now online."
Scenario Outline: Action failed, publish — a 500 or a network error leaves the row unchanged and offers Retry
Given the user chose "Publish" from id 4 "Winter checklist for timber windows"'s row menu, and
the request is in flight
When PATCH /v1/article/4/publish <failure>
Then id 4's row stays unchanged (draft, updated "5 days ago", no longer busy), because a row
never changes before the server responds
And an error toast appears, staying until dismissed or retried: icon circle-x, id
"article-publish-failed-4", title "Couldn't publish", text "The server didn't respond.
Nothing changed.", action "Retry"
When the user activates "Retry"
Then PATCH /v1/article/4/publish is sent again
Examples:
| failure |
| resolves with a 500 response |
| never reaches the server |
Scenario Outline: Action failed, unpublish — a 500 or a network error leaves the row unchanged and offers Retry
Given the user chose "Unpublish" from id 3 "Before and after: the Rue Verte kitchen"'s row
menu, and the request is in flight
When PATCH /v1/article/3/unpublish <failure>
Then id 3's row stays unchanged (online, updated "3 days ago", no longer busy), because a row
never changes before the server responds
And an error toast appears, staying until dismissed or retried: icon circle-x, id
"article-unpublish-failed-3", title "Couldn't unpublish", text "The server didn't respond.
Nothing changed.", action "Retry"
When the user activates "Retry"
Then PATCH /v1/article/3/unpublish is sent again
Examples:
| failure |
| resolves with a 500 response |
| never reaches the server |
Scenario Outline: Row vanished, draft row — a 404 on publish or delete removes the row and shows an info toast
Given the Default state has loaded
When the user <trigger> for id 2 "Choosing lime plaster for old stone walls"
And the request resolves 404
Then id 2's row is removed from the list (and any open menu or dialog for it closes)
And the tabs read "All 5" "Online 4" "Drafts 1"
And an info toast appears: icon info, id "article-not-found-2", title "That article no longer
exists", text "It was deleted somewhere else, so we removed it from the list."
Examples:
| trigger |
| chooses "Publish" from its row menu |
| confirms "Delete article" in its ConfirmDialog |
Scenario: Row vanished, online row — a 404 on unpublish removes the row and shows an info toast
Given the Default state has loaded
When the user chooses "Unpublish" from id 3 "Before and after: the Rue Verte kitchen"'s row
menu
And the request resolves 404
Then id 3's row is removed from the list (and any open menu closes)
And the tabs read "All 5" "Online 3" "Drafts 2"
And an info toast appears: icon info, id "article-not-found-3", title "That article no longer
exists", text "It was deleted somewhere else, so we removed it from the list."
Scenario: Delete confirm — opens the dialog with the row's title and image count
Given the Default state has loaded, and id 4 "Winter checklist for timber windows" has 3 images
When the user chooses "Delete…" from id 4's row menu
Then ConfirmDialog opens: title "Delete this article?" text "\"Winter checklist for timber
windows\" and its 3 images will be deleted. This can't be undone.", "Cancel" and "Delete
article" (danger)
Scenario: Delete in progress — the dialog cannot be dismissed while the request is in flight
Given ConfirmDialog is open for id 4 "Winter checklist for timber windows"
When the user activates "Delete article"
Then DELETE /v1/article/4 is sent
And both "Cancel" and the confirm button are disabled, the confirm button reads "Deleting…"
with a loading indicator, aria-busy is set, and Esc and a backdrop click do not close it
Scenario: Delete failed — a 500 shows an inline alert inside the dialog and keeps the article
Given ConfirmDialog is open for id 4 "Winter checklist for timber windows", and "Delete
article" has been activated
When DELETE /v1/article/4 resolves 500
Then the dialog stays open, showing the alert "Couldn't delete the article, so it's still
there. Try again in a moment.", with "Cancel" and "Delete article" re-enabled
And id 4's row is untouched in the list behind the dialog
Scenario: Deleted — a 204 closes the dialog, removes the row, and moves focus to the next row
Given ConfirmDialog is open for id 4 "Winter checklist for timber windows", and "Delete
article" has been activated
When DELETE /v1/article/4 resolves 204
Then the dialog closes, id 4's row is removed from the list, and focus moves to the next row
And the tabs read "All 5" "Online 4" "Drafts 1"
And a success toast appears: icon circle-check, id "article-deleted-4", title "Article
deleted", text "\"Winter checklist for timber windows\" and its images are gone."
+176
View File
@@ -0,0 +1,176 @@
# QA: Articles list screen
## Implementation note for coder (informational, not a QA assertion)
`internal/infrastructure/persistence/repositories/articleRepository.go`'s `FindByFilters` gains
`.Preload("Images")`, mirroring `Get`, so `GET /v1/article/` carries real `Images` arrays for the
delete dialog's count. This is a persistence-layer change asserted by the existing
`var _ domain.Repository = &ArticleRepository{}`; it needs no domain or use-case change, since
`article.Article.Images` already exists.
Toast ids follow components.md's action-plus-id convention: "article-published-{id}",
"article-unpublished-{id}", "article-publish-failed-{id}", "article-unpublish-failed-{id}",
"article-not-found-{id}", "article-deleted-{id}", "article-link-copied-{id}",
"article-link-copy-failed-{id}".
## HTTP-level (Go, `test/`, `task test`)
Build `article.NewHandler(...)` over the real use cases wired to a fake `article.Repository` (in
the style of `test/article_test.go`'s `mockArticleRepo`), mounted on a fresh `chi.NewRouter()`
mirroring `NewArticleRouter` in `api/router.go` (the outer group, no auth, for `GET /`). Call
`validator.InitValidator()` first.
1. **GET /v1/article/ returns each article's images array populated** — seed the fake repository
with an article whose `Images` slice holds two `image.Image` entries (ids 10 and 11), send
`GET /v1/article/` with `httptest`, decode the JSON array, and assert the matching entry's
`images` field is a 2-element array containing those ids (not `null` and not empty), proving
the handler's response reflects `FindByFilters`'s `Images` data. Because the fake repository's
`FindByFilters` is written by this test rather than GORM, it can't exercise the real
`.Preload("Images")` SQL by itself — this test only proves the handler passes the repository's
`Images` data through to JSON unchanged. The `.Preload` call itself against a real SQLite file is
covered under Manual verification below.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi`'s calls for `GET /v1/article/`, `PATCH /v1/article/{id}/publish`,
`PATCH /v1/article/{id}/unpublish`, `DELETE /v1/article/{id}`, and the clipboard write (e.g.
`navigator.clipboard.writeText`), per case; no real backend, no httptest server. Mount
ArticlesPage inside DashboardShell with a resolved verified session and a test router at
/article. Sample data throughout is the Background's 6-article fixture (ids 1-6, titles and dates
as listed), unless a scenario's Examples row substitutes a smaller fixture.
1. **Default** — mock `GET /v1/article/` resolving 200 with the 6-article fixture; assert
breadcrumb "Articles"; assert tabs "All 6" "Online 4" "Drafts 2"; assert the rows render in
fixture order with title link, ArticleStatusBadge and updated date; assert the table's visually
hidden caption reads "Articles, sorted by last updated, newest first".
2. **Loading** — mock `GET /v1/article/` with an unresolved promise; assert 5 skeleton rows and
`aria-busy="true"` on the list container; resolve 200 with the fixture; assert the Default
state's rows replace the skeletons.
3. **Row menu open** — from Default, click id 4's ellipsis button; assert the menu opens named
"Actions for Winter checklist for timber windows" with items Edit, Publish, Copy API URL, and a
separator then "Delete…"; assert no other row is affected.
4. **Copy API URL succeeds** — from Default, mock the clipboard write to resolve, choose "Copy API
URL" from id 4's menu; assert the clipboard write was called with
`"{origin}/v1/article/4"` (using the test's `window.location.origin`); assert a toast was
raised with id "article-link-copied-4", icon circle-check, title "Link copied", text "The API
URL for \"Winter checklist for timber windows\" is on your clipboard."
5. **Copy API URL fails** — from Default, mock the clipboard write to reject, choose "Copy API
URL" from id 4's menu; assert a toast was raised with id "article-link-copy-failed-4", icon
circle-x, title "Couldn't copy the link", and no description text.
6. **Empty** — mock `GET /v1/article/` resolving 200 with `[]`; assert tabs "All 0" "Online 0"
"Drafts 0"; assert the no-articles EmptyState (icon file-text, title "No articles yet", text,
action "New article"); click it; assert NewArticleModal opens.
7. **No results** — from Default, type "tiles" into the search field; advance past the 120 ms
debounce (fake timers); assert the no-results EmptyState (icon search, title "No articles match
\"tiles\"", text, action "Clear search"); assert tabs still read "All 6" "Online 4" "Drafts 2";
click "Clear search"; assert the Default rows reappear and the search field has focus.
8. **Empty tab, Examples-driven** — (a) mock `GET /v1/article/` with 4 online-only articles, select
the Drafts tab, assert tabs "All 4" "Online 4" "Drafts 0" and the no-drafts EmptyState (icon
pencil-line, title "No drafts", text "Everything you've written is online.", action "New
article"); (b) mock 4 draft-only articles, select the Online tab, assert tabs "All 4" "Online 0"
"Drafts 4" and the EmptyState titled "Nothing is online yet", text "Publish a draft and it will
appear on your website.", action "Show drafts" (icon per the board, confirmed in the visual
check below).
9. **Action in progress** — from Default, choose "Publish" from id 4's menu; assert
`PATCH /v1/article/4/publish` is called; assert id 4's row is disabled with busy label
"Publishing…" and the other 5 rows stay enabled.
10. **Published** — continuing from 9, resolve the PATCH 200 with `is_online: true`, updated "Just
now"; assert id 4's row shows the online badge and "Just now", no longer busy; assert a toast
was raised with id "article-published-4", icon circle-check, title "Article published", text
"\"Winter checklist for timber windows\" is now online."
11. **Action failed, publish, Examples-driven** — for each of a 500 response and a network
rejection: choose "Publish" from id 4's menu, resolve/reject the mocked PATCH accordingly;
assert id 4's row stays exactly unchanged (draft, "5 days ago", not busy) throughout — the row
is never touched before the (failed) response, so there is nothing to revert; assert a toast
with id "article-publish-failed-4", icon circle-x, title "Couldn't publish", text "The server
didn't respond. Nothing changed.", action "Retry"; click "Retry"; assert the same PATCH is
called again.
12. **Action failed, unpublish, Examples-driven** — for each of a 500 response and a network
rejection: choose "Unpublish" from id 3's menu (an online row), resolve/reject the mocked
PATCH accordingly; assert id 3's row stays exactly unchanged (online, "3 days ago", not busy);
assert a toast with id "article-unpublish-failed-3", icon circle-x, title "Couldn't
unpublish", text "The server didn't respond. Nothing changed.", action "Retry"; click "Retry";
assert the same PATCH is called again.
13. **Row vanished, draft row, Examples-driven** — for each of (publish 404), (delete-confirm
404) on id 2 (a draft row): trigger the action, resolve the mocked call 404; assert id 2's row
is removed; assert tabs read "All 5" "Online 4" "Drafts 1"; assert a toast with id
"article-not-found-2", icon info, title "That article no longer exists", text "It was deleted
somewhere else, so we removed it from the list."
14. **Row vanished, online row** — choose "Unpublish" from id 3's menu, resolve the mocked PATCH
404; assert id 3's row is removed; assert tabs read "All 5" "Online 3" "Drafts 2"; assert a
toast with id "article-not-found-3", icon info, title "That article no longer exists", text
"It was deleted somewhere else, so we removed it from the list."
15. **Delete confirm** — from Default (with id 4 carrying 3 images in the mocked
`GET /v1/article/` response), choose "Delete…" from id 4's menu; assert ConfirmDialog opens
with title "Delete this article?", text "\"Winter checklist for timber windows\" and its 3
images will be deleted. This can't be undone.", "Cancel" and "Delete article".
16. **Delete in progress** — from the open dialog, click "Delete article"; assert
`DELETE /v1/article/4` is called; assert both buttons are disabled, the confirm button reads
"Deleting…", `aria-busy` is set, and Esc / backdrop click do not close it.
17. **Delete failed** — continuing from 16, resolve the DELETE 500; assert the dialog stays open
with the inline alert "Couldn't delete the article, so it's still there. Try again in a
moment.", both buttons re-enabled; assert id 4's row is unchanged in the list behind the
dialog.
18. **Deleted** — continuing from 16 (a fresh attempt), resolve the DELETE 204; assert the dialog
closes, id 4's row is removed, focus moves to the next row; assert tabs read "All 5" "Online
4" "Drafts 1"; assert a toast with id "article-deleted-4", icon circle-check, title "Article
deleted", text "\"Winter checklist for timber windows\" and its images are gone."
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/screens/articles.md` and `docs/design/source/RenewCMS Screens
Back-office.dc.html`, group "Articles". Run `task visual:check -- articles` for each state below
and compare against the board frame; add each state's mock setup to `web/visual/states.ts` first,
reusing the 6-article fixture (ids/titles/dates above) as sample data. This screen shows relative
dates ("2 hours ago", "5 days ago"), so fix `now` to the value the board's own script assumes for
those relative strings to render identically (derive it from "2 hours ago" against "12 Mar 2026"
and the absolute dates shown, e.g. "18 Aug 2026", per the board's `base` fixture in the source
file).
- [ ] Default: tabs "All 6/Online 4/Drafts 2", 6 rows in fixture order, title/status/updated
columns (confirm against the board frame directly whether a Created column is also shown —
components.md's prose and the rendered board table disagree on this).
- [ ] Row menu open: id 4's menu open with Edit/Publish/Copy API URL/Delete…, matching the board's
dropdown placement and the brick-red Delete… treatment.
- [ ] Loading: 5 skeleton rows shaped like the real layout.
- [ ] Empty: EmptyState icon file-text, title, text, single "New article" action.
- [ ] No results: EmptyState icon search, title with the quoted search term, text, "Clear search".
- [ ] No drafts / Nothing online: both empty-tab EmptyStates, including the "Nothing is online
yet" state's icon (confirm it directly against the board frame, since it isn't given in the
prose extraction read for this slice).
- [ ] Action in progress: id 4's row at 50% opacity with a spinner in the status cell and
"aria-busy".
- [ ] Published / Action failed toasts: colors, icons and the "Retry" action match the board.
- [ ] Copy API URL: no board state depicts its toasts; verify their copy and icons directly in the
app against this spec rather than a board frame.
- [ ] Row vanished: the info toast and the row's removal match the board.
- [ ] Delete confirm / in progress / failed: the dialog's three states match the board exactly,
including the inline alert's placement in the failed state.
- [ ] Deleted: toast copy matches, and focus visibly lands on the next row.
- [ ] Keyboard/focus: roving tabindex across the tab list (Tab enters on the selected tab, ←/→
move and activate); each row's title link is reachable and Enter opens the editor; each
row's menu opens with Enter/Space/↓, arrow keys and type-ahead navigate it, Home/End jump to
the ends, Esc closes it and returns focus to the ellipsis button; ConfirmDialog traps focus,
starts on Cancel, and Enter does not confirm by default; on a successful delete, focus lands
on the next row (or the page heading if none).
- [ ] Motion: skeleton pulse and modal open/close follow the standard timings and stop under
reduced motion.
## Manual verification
- **Real GET/PATCH/DELETE round trips against a running server** — Vitest only mocks `useApi`;
confirm the real 200/404/500 responses, and the real `updated_at`-based ordering, against
`task dev` with a live SQLite file, since these automated tests never exercise the actual Go
handlers or use cases.
- **Real .Preload("Images") behavior against a live SQLite file** — confirm `GET /v1/article/`
returns accurate, non-empty `images` arrays for articles with real uploaded images, and `[]` for
articles with none, against a running server, since the HTTP-level Go test above only proves the
handler forwards whatever the (fake) repository returns, not that the real GORM query populates
it correctly.
- **Real image-count accuracy in the delete dialog** — confirm the delete dialog's count matches
an article's actual attached images end to end (upload images, then delete), since Vitest only
ever supplies a mocked count.
- **Toast placement and stacking on a live app, including the three-toast cap** — confirm against
a running app, since Vitest only asserts `useToast().add` was called with the right arguments.
- **Real clipboard write on a live app** — confirm `navigator.clipboard.writeText` actually places
the URL on the system clipboard in a real browser, since Vitest only mocks the clipboard API.
@@ -0,0 +1,94 @@
Feature: AuthShell and log-in screen conform to the design spec
As the maintainer of RenewCMS
I want AuthShell and LoginView built from the exact primitives docs/design/components.md
specifies — AppLogo, UPageCard's own title/description styling, the muted page background,
UForm with "Forgot password?" in the Password field's #hint slot, and the neutral-by-default,
red-on-error input border
So that the log-in screen (and every other AuthShell-based screen) matches the visual spec of
record instead of ad hoc markup that happens to look similar
Background:
Given AuthShell is the layout for every public route, per docs/design/routes.md
And AuthShell composes a UPageCard variant="outline" as the centered card, with AppLogo
rendered as the card's first element — left-aligned above the title/description block,
sharing the card's own 24px gap — not as a separate element centered outside/above the
card, per docs/design/components.md's AuthShell section and
docs/design/source/AuthCard.dc.html
And AppLogo is a reusable component (props include size) rendering the brand mark from
docs/design/source/brand/favicon.svg as inline SVG, not an <img>
And UPageCard's title renders through its own title slot as an h1 (tabindex="-1", so route
changes can move focus to it, per AuthShell's a11y spec), and its description renders
through UPageCard's description prop — neither is freeform markup in the default slot
And from the sm breakpoint up, the centered <main> sits on the muted page background
(--ui-bg-muted), the same canvas the rest of the app uses; below sm it sits on --ui-bg
instead, per docs/design/components.md's AuthShell "Responsive" line
And LoginView's form is a UForm wrapping the Username and Password UFormFields and the
submit button
And the Password UFormField's "Forgot password?" link renders through the field's #hint
slot, in the position the Auth board draws it — right-aligned on the label row — per
docs/design/source/RenewCMS Screens Auth.dc.html, group "Log in"
And every UInput on the page uses the neutral control-edge border (--ui-border-accented) by
default, and turns to the error edge (--ui-error) only when its field has an error, per
docs/design/components.md's Input section (":highlight bound to the field error") and
foundations.md's Borders and elevation
Scenario: AppLogo renders the brand mark as inline SVG, and theme-switches
Given a public screen renders inside AuthShell
When AuthShell mounts
Then an AppLogo renders as UPageCard's first element, inside the card, left-aligned, sharing
the card's 24px gap with the title/description block below it — not centered above the card
And the logo is inline SVG sourced from docs/design/source/brand/favicon.svg, at 32px, not as
an <img> tag
And the logo carries alt="RenewCMS" (role="img" aria-label="RenewCMS" on the SVG)
And the logo is not wrapped in a link, since there is nothing public to go back to
And the black square has no edge stroke in light mode or in dark mode, per
docs/design/source/AuthCard.dc.html's renderVals(), which computes the logo's edge as the
literal, non-theme-branched value "transparent"
Scenario: UPageCard's title is the page h1, and its description uses UPageCard's own styling
Given AuthShell renders with title "Log in" and description
"Welcome back. Log in to manage your articles."
When AuthShell mounts
Then the card is a UPageCard with variant="outline"
And UPageCard's title slot renders an h1 with tabindex="-1" containing "Log in"
And UPageCard's description prop is "Welcome back. Log in to manage your articles."
And neither the title nor the description is rendered as separate freeform markup inside
UPageCard's default slot
Scenario: The page sits on the muted background from sm up, and on the default background below sm
Given AuthShell renders any public screen at a viewport of sm or wider
When AuthShell mounts
Then the centered <main> uses the muted background token (--ui-bg-muted)
And the card keeps its border, is capped at 400px, and its top sits 12vh from the top
Scenario: Below sm, AuthShell drops the muted background, the card border and the card padding
Given AuthShell renders any public screen at a viewport below sm
When AuthShell mounts
Then the centered <main> uses the default background token (--ui-bg), not the muted one
And the card has no border and fills the screen with 20px padding
And the fields and buttons on the page render at the xl size
Scenario: The login form is a UForm
Given the user is on /login
When the login form renders
Then the form is a UForm, not a plain HTML <form>, wrapping the Username UFormField, the
Password UFormField and the submit button
Scenario: "Forgot password?" renders in the Password field's hint slot
Given the user is on /login
When the login form renders
Then the Password UFormField's "Forgot password?" link is rendered through the field's
#hint slot
And the Password field's label reads only "Password", with no link inside it
Scenario: Inputs are neutral by default and turn to the error edge only when their field has an error
Given the user is on /login with no validation errors yet
When the Username and Password UInput fields render
Then both use the neutral control-edge border (--ui-border-accented), with :highlight false
When the user submits with Username "ma" and Password empty (the Validation errors state,
per docs/design/screens/log-in.md)
Then the Username UInput's edge turns to the error color (--ui-error), with :highlight true,
alongside the message "Use at least 3 characters."
And the Password UInput's edge turns to the error color (--ui-error), with :highlight true,
alongside the message "Enter your password."
@@ -0,0 +1,88 @@
# QA: AuthShell and log-in screen conform to the design spec
## Automated (Vitest + Vue Test Utils, `task test:web`)
No `/v1` calls are relevant to these assertions; mount AuthShell and LoginView directly (LoginView
already needs `useApi`/`GET /v1/auth/registration` mocked per features/login_screen.qa.md's setup,
reused here only as scaffolding, not as the thing under test).
1. **AppLogo renders inline SVG with no edge in either mode** — mount AuthShell with title
"Log in"; assert an inline `<svg>` (not `<img>`) renders as UPageCard's first child element,
left-aligned (not centered) above the title/description block, sharing the card's 24px gap, at
32px, with `role="img" aria-label="RenewCMS"`, and is not inside an `<a>`; assert its outer
square has no stroke in light mode; switch to dark mode (toggle the `.dark` class the way
`nuxt_ui_shell_foundation`'s color-mode tests do) and assert the outer square still has no
stroke, per `docs/design/source/AuthCard.dc.html`'s literal, non-theme-branched
`edge: 'transparent'`.
2. **UPageCard title is an h1 with tabindex="-1"** — mount AuthShell with title "Log in" and
description "Welcome back. Log in to manage your articles."; assert
`wrapper.find('h1').attributes('tabindex')` is `"-1"` and its text is "Log in"; assert the
description text "Welcome back. Log in to manage your articles." renders via UPageCard's
`description` prop (not as a sibling `<p>` hand-authored in AuthShell's template). This
replaces the older assertion in `AuthShell.spec.ts` that only checked for any `<h1>`; keep that
assertion passing (h1 text still equals the title) while adding the tabindex check.
3. **Muted background at sm and up** — mount AuthShell at a `>= 640px` viewport (mock
`useBreakpoints`/matchMedia the way other shell tests do); assert `<main>`'s class list
includes the muted background utility (e.g. `bg-muted`); assert the card renders with a
border and is capped at 400px width, offset 12vh from the top.
4. **Default background, borderless card, 20px padding, xl controls below sm** — mount AuthShell
at a `< 640px` viewport; assert `<main>`'s class list includes the default background utility
(`bg-default`) and not the muted one; assert the card has no border class; assert the card's
padding is 20px; assert the Username/Password inputs and submit button render at `size="xl"`.
5. **Login form is a UForm** — mount LoginView at /login; assert the form wrapping the fields is
a UForm component instance, not a bare `<form>` element with only `@submit.prevent` wired by
hand.
6. **Forgot password in the #hint slot** — mount LoginView at /login; assert the Password
UFormField is passed content (or renders content) in its `hint` slot containing a
"Forgot password?" link to /register/reset/request; assert the field's rendered label text is
exactly "Password" with no link inside it.
7. **Inputs default to neutral, then highlight red on error** — mount LoginView at /login; assert
the Username and Password `UInput` components render with `highlight` falsy (or absent) and
the theme's default ring class (`ring-(--ui-border-accented)`); then trigger the Validation
errors state (type "ma" into Username, submit with Password empty); assert both `UInput`s now
receive `:highlight="true"` (or the field's error is otherwise truthy) so the edge renders in
`--ui-error`, alongside "Use at least 3 characters." and "Enter your password." respectively.
### Implementation finding for coder
`web/src/views/LoginView.vue`'s Username and Password `UInput`s currently receive no `:highlight`
prop at all — `UFormField` gets `:error="usernameError"` / `:error="passwordError"`, but that value
is never forwarded to the inner `UInput`. Today the edge never turns red in the Validation errors
state; this isn't a stale/inverted binding, it's a missing one. Wiring `:highlight="!!usernameError"`
/ `:highlight="!!passwordError"` (or equivalent) onto each `UInput` is the fix scenario 7 above
pins down.
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Log in", plus
`docs/design/source/RenewCMS Components Product.dc.html`'s AuthShell drawing and
`docs/design/source/RenewCMS Components Primitives.dc.html`'s Input drawing.
- [ ] AppLogo renders inside UPageCard as its first element, left-aligned above the title (not
centered above the card), sharing the card's 24px gap; the mark matches the favicon.svg
drawing exactly (white R on a black square), desktop and mobile; no edge stroke is visible
around the square in either light or dark mode.
- [ ] UPageCard title ("Log in") renders at the card-title size/weight from the AuthShell drawing
and receives focus (visible focus ring) on route change; description text matches
"Welcome back. Log in to manage your articles." styling, desktop and mobile, light and dark.
- [ ] At sm and up: 400px card with its border, 12vh top offset, on the muted page canvas, light
and dark.
- [ ] Below sm: card fills the screen borderless with 20px padding on the default (not muted)
background; fields and the submit button render at the touch (xl, 44px) size, light and
dark.
- [ ] The Password field's "Forgot password?" link sits right-aligned on the label row (the hint
position), matching the Auth board, desktop and mobile, light and dark.
- [ ] Username and Password fields show the neutral control-edge border by default; in the
Validation errors state (short username, empty password) both fields' edges turn to the
error color alongside their messages, in both light and dark.
- [ ] Keyboard/focus: Tab order remains Username → Password → show/hide toggle → "Forgot
password?" → submit; UPageCard's h1 is reachable/focusable via tabindex="-1" on route
change, matching AuthShell's existing a11y spec.
## Manual verification
- **AppLogo reused in DashboardShell** — out of scope for this slice (AuthShell/login only).
`DashboardShell.vue`'s sidebar header is currently a plain `<span class="font-semibold">
RenewCMS</span>` (not AppLogo), and its footer is a bare `<span>Log out</span>` instead of the
UserMenu component components.md specifies. Both need their own "Built from" conformance pass in
a later DashboardShell slice; AppLogo (built here with a `size` prop) will be ready to drop in
at that point without changes.
+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.
+242
View File
@@ -0,0 +1,242 @@
Feature: Editor screen — load, edit and save an article's body
As a verified RenewCMS owner
I want /article/:id/edit to load an article, let me edit its body, save it, and warn me
before I lose unsaved work
So that I can safely write and revise an article's content, with clear feedback while
saving, near the length limit, offline, or when my session expires mid-edit
Background:
Given /article/:id/edit is a verified route (meta.auth "verified") rendered as EditorPage
inside DashboardShell, per docs/design/routes.md
And EditorPage calls GET /v1/article/{id} once per navigation to load the article, per
docs/design/screens/editor.md and features/public_article_read.feature
And the header shows breadcrumb "Articles › {article.title}" linking "Articles" to
/article (mobile top bar title "Edit article" instead), an ArticleStatusBadge size "md"
reflecting article.is_online ("Draft" or "Online"), a SaveStatus span, a Save button,
and a PublishControl button, per docs/design/components.md
And the article's title is never editable after creation, per
features/article_title_rules.feature; only the body is edited here
And the body is edited in ArticleEditor (modelValue bound to the fetched HTML body); any
change moves useArticleDraft's state from "clean" to "dirty", and PublishControl's label
switches from "Publish" (or "Unpublish", if online) to "Save and publish" while dirty and
the article is a draft; clicking PublishControl itself is out of scope for this slice and
is specified in a follow-on slice (editor_publish_and_delete)
And Save is enabled only when useArticleDraft's state is "dirty", the request is not already
in flight, the app is not offline, and the body's HTML length is at most 10000 characters;
it is disabled in every other case
And Save (a click, or Ctrl/⌘ S) sends PATCH /v1/article/{id} with JSON {"body": "..."},
the same contract features/edit_article_body.feature already gives the API: 200 with the
updated article.Article, 400 for a body over 10000 characters, 404 for an id absent from
the repository, 401 with no valid session
And a successful save (200) sets useArticleDraft's state to "saved" and records the save
time; SaveStatus shows "Saved at {HH:MM}" for the first hour and "Saved {n} min ago" after
an hour has passed, per docs/design/components.md
And a 500 response or a request that never reaches the server sets useArticleDraft's state
to "error"; SaveStatus shows "Save failed" with an inline Retry button (UButton size="xs"
variant="outline"); the edited body is kept exactly as typed, both in the editor and for
the next Retry
And a 404 response to a save (the article was deleted elsewhere) does not use the "error"
SaveStatus; it shows a toast "That article no longer exists.", per docs/design/states.md's
HTTP-status-to-copy table
And a 401 response to a save is handled by useApi's global handling
(features/api_client_global_error_handling.feature): before the redirect, useArticleDraft
mirrors the current dirty body to sessionStorage keyed by the article's id, so it survives
the redirect to /login?redirect=/article/{id}/edit&reason=expired
And CharacterCounter shows "{len} / 10 000" below the editor, using the body's HTML string
length (formatting and images count); at 9000 characters or more (90%, "Near the limit")
it adds a warning icon and stays enabled; above 10000 ("Too long") it adds an icon and the
text "{over} characters over. Shorten the text or remove an image. Formatting counts too.",
and disables both Save and PublishControl until the length is 10000 or fewer again
And a Preview switch, when on, replaces the editable ArticleEditor and its toolbar with a
read-only rendering of the title and body in the content type scale; switching it off
restores the editable surface with the same content, unchanged
And when navigator.onLine is false, or the browser fires a window "offline" event,
useArticleDraft's state becomes "offline"; SaveStatus shows the wifi-off icon and reads
"Waiting for connection"; Save and PublishControl are disabled with a tooltip explaining
why, and DashboardShell's existing OfflineBanner is shown; when the connection returns (a
window "online" event, or navigator.onLine true again), Save and PublishControl re-enable
according to the underlying dirty/saved/error state, which is unaffected by having been
offline
And navigating away (the breadcrumb "Articles" link, a sidebar nav link, the browser back
button, or closing the tab) while useArticleDraft's state is "dirty" opens ConfirmDialog
(neutral tone) titled "Leave without saving?", text 'Your changes to "{article.title}"
haven't been saved.', cancel "Discard changes", confirm "Keep editing"; "Discard changes"
proceeds with the navigation, discarding the edit; "Keep editing" closes the dialog and
keeps the user on the editor with the edit intact; navigating away while "clean" or
"saved" never opens this dialog
And an id absent from the repository (GET resolves 404) replaces the entire editor with an
error state: title "This article doesn't exist.", and a single action "All articles"
navigating to /article; no breadcrumb-title, SaveStatus, Save, PublishControl or
ArticleEditor renders in this state
And skeleton placeholders (for the title, content and details) replace the editor's content
only after 150 ms of GET /v1/article/{id} being unresolved, so a fast response never
flashes a skeleton, per the Skeleton component spec
Scenario: Skeletons appear only after 150 ms, never for a fast response
Given the user navigates to /article/12/edit for the first time in this session
When GET /v1/article/12 has not resolved 150 ms after the navigation
Then skeleton placeholders replace the title, content and details areas
When GET /v1/article/12 then resolves 200 with a draft article titled "How we price a
bathroom renovation"
Then the skeletons are replaced by the Saved state's header, editor and details
Given a second, separate navigation to /article/12/edit
When GET /v1/article/12 resolves in under 150 ms
Then no skeleton is ever shown, and the Saved state's content renders directly
Scenario Outline: Saved — a freshly loaded article, draft or online
Given GET /v1/article/12 resolves 200 with an article titled "How we price a bathroom
renovation", is_online <is_online>, body length 4318, updated_at "Today, 11:02"
When the user navigates to /article/12/edit
Then the breadcrumb reads "Articles › How we price a bathroom renovation"
And the ArticleStatusBadge reads "<badge>"
And SaveStatus reads "Saved at 11:02"
And the Save button is disabled
And PublishControl reads "<publish_label>"
And the character counter reads "4 318 / 10 000"
Examples:
| is_online | badge | publish_label |
| false | Draft | Publish |
| true | Online | Unpublish |
Scenario: Unsaved changes — any edit marks the article dirty
Given the user is on /article/12/edit in the Saved state, article "How we price a bathroom
renovation", draft
When the user types into the body
Then SaveStatus reads "Unsaved changes"
And the Save button becomes enabled
And PublishControl now reads "Save and publish"
Scenario: Saving — Save sends the PATCH and disables itself while in flight
Given the user is on /article/12/edit with unsaved changes to the body
When the user activates Save
Then PATCH /v1/article/12 is sent with JSON {"body": "<the edited body>"}
And SaveStatus reads "Saving…"
And the Save button is disabled
Scenario: Ctrl/⌘ S saves the same way as clicking Save
Given the user is on /article/12/edit with unsaved changes to the body
When the user presses Ctrl+S (or ⌘+S)
Then PATCH /v1/article/12 is sent with JSON {"body": "<the edited body>"}
And SaveStatus reads "Saving…"
Scenario: A successful save returns to the Saved state with the new time
Given the user is on /article/12/edit in the Saving state
When PATCH /v1/article/12 resolves 200 with the updated article
Then SaveStatus reads "Saved at {current time}"
And the Save button is disabled again
Scenario: SaveStatus switches to a relative time after an hour
Given the user is on /article/12/edit and PATCH /v1/article/12 resolved 200 at 11:02
When more than an hour has passed with no further edit or save
Then SaveStatus reads "Saved {n} min ago" instead of "Saved at 11:02"
Scenario Outline: Save failed — a 500 or a network failure keeps the changes and offers Retry
Given the user is on /article/12/edit with unsaved changes to the body "Revised opening
paragraph."
When the user activates Save
And PATCH /v1/article/12 <failure>
Then SaveStatus reads "Save failed" with a Retry button
And the editor still shows the body "Revised opening paragraph."
And no navigation occurs
Examples:
| failure |
| resolves with a 500 response |
| never reaches the server |
Scenario: Retry resends the same body and can recover
Given the user is on /article/12/edit in the Save failed state, with unsaved body "Revised
opening paragraph." and PATCH /v1/article/12 having failed with a 500
When the user activates Retry
Then PATCH /v1/article/12 is sent again with JSON {"body": "Revised opening paragraph."}
When it resolves 200 with the updated article
Then SaveStatus reads "Saved at {current time}"
Scenario: A save against an article deleted elsewhere shows a toast, not the error SaveStatus
Given the user is on /article/12/edit with unsaved changes to the body
When the user activates Save
And PATCH /v1/article/12 resolves 404
Then a toast reads "That article no longer exists."
And SaveStatus does not read "Save failed"
Scenario: Near the limit — the counter warns at 9000 characters without blocking Save
Given the user is on /article/12/edit with unsaved changes making the body's HTML length
9240 characters
Then the character counter reads "9 240 / 10 000" with a warning icon
And the Save button remains enabled
Scenario: Too long — over 10000 characters disables Save and Publish until shortened
Given the user is on /article/12/edit with unsaved changes making the body's HTML length
10214 characters
Then the character counter reads "10 214 / 10 000" with the text "214 characters over.
Shorten the text or remove an image. Formatting counts too."
And the Save button is disabled
And PublishControl is disabled
When the user shortens the body to 10000 characters or fewer
Then the character counter no longer shows the over-limit text
And the Save button is enabled again
Scenario: Preview shows the rendered title and body without the editing surface
Given the user is on /article/12/edit, article titled "How we price a bathroom renovation"
with some body content
When the user switches Preview on
Then the editing toolbar and the editable surface are replaced by a read-only rendering of
the title and body in the content type scale
When the user switches Preview off
Then the editable ArticleEditor and toolbar return, with the same body content unchanged
Scenario: Offline blocks Save and Publish, and recovers when the connection returns
Given the user is on /article/12/edit with unsaved changes to the body
When navigator.onLine becomes false
Then SaveStatus shows the wifi-off icon and reads "Waiting for connection"
And the Save button and PublishControl are disabled with a tooltip explaining why
When navigator.onLine becomes true again
Then the Save button re-enables, reflecting the still-unsaved change
And SaveStatus reads "Unsaved changes" again
Scenario: Leaving with unsaved changes prompts, and "Discard changes" proceeds
Given the user is on /article/12/edit with unsaved changes to the body
When the user activates the "Articles" breadcrumb link
Then ConfirmDialog opens, titled "Leave without saving?", text 'Your changes to "How we
price a bathroom renovation" haven't been saved.', cancel "Discard changes", confirm
"Keep editing"
When the user activates "Discard changes"
Then the app navigates to /article
And the edited body is not saved
Scenario: Leaving with unsaved changes, "Keep editing" stays on the page
Given the user is on /article/12/edit with unsaved changes to the body, and the "Leave
without saving?" dialog is open
When the user activates "Keep editing"
Then the dialog closes
And the user remains on /article/12/edit with the unsaved body intact
Scenario: Leaving a clean article never prompts
Given the user is on /article/12/edit in the Saved state, with no unsaved changes
When the user activates the "Articles" breadcrumb link
Then the app navigates to /article directly, with no ConfirmDialog
Scenario: Session expired mid-save keeps the draft and restores it after logging back in
Given the user is on /article/12/edit with unsaved changes to the body "Paragraph added
just before the session expired."
When the user activates Save
And PATCH /v1/article/12 resolves 401
Then the body "Paragraph added just before the session expired." is mirrored to
sessionStorage keyed by article id 12
And the app navigates to /login?redirect=/article/12/edit&reason=expired
When the user logs in again and is returned to /article/12/edit
And GET /v1/article/12 resolves 200 with the last-saved body (without that paragraph)
Then the editor shows the body "Paragraph added just before the session expired." restored
from sessionStorage, not the last-saved body from the GET
And SaveStatus reads "Unsaved changes"
Scenario: An unknown or deleted article id replaces the editor with an error state
Given GET /v1/article/9999 resolves 404
When the user navigates to /article/9999/edit
Then the editor shows a single error state: title "This article doesn't exist." and one
action "All articles"
And no breadcrumb-title, SaveStatus, Save, PublishControl or ArticleEditor renders
When the user activates "All articles"
Then the app navigates to /article
+129
View File
@@ -0,0 +1,129 @@
# QA: Editor screen — load, edit and save (core)
## Implementation note for coder (informational, not a QA assertion)
`docs/design/components.md`'s `ErrorView` only documents two fixed copies (`kind: 'not-found'` →
"Page not found" / Home, and `kind: 'error'` → the generic retry copy). The editor's own 404-on-
load state needs different copy ("This article doesn't exist." / "All articles" → /article),
per `docs/design/states.md`'s HTTP-status-to-copy table. Either widen `ErrorView` with an optional
title/description/action override, or add a third `kind`; this is a same-layer prop widening, not
a new component, so it doesn't need its own slice.
Known limitation, not a bug to silently fix: per `features/public_article_read.feature`,
`GET /v1/article/{id}` falls back to public online-only visibility on a malformed/expired jwt
(no 401). So if a session is already stale when the owner opens an *offline* article, the GET
answers 404 exactly like a truly deleted article — the editor cannot tell the two apart from this
response alone, and correctly shows the "This article doesn't exist." state.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi`'s `request` for `GET /v1/article/{id}` and `PATCH /v1/article/{id}` per case; no
real backend, no httptest server. Mount EditorPage inside DashboardShell with a resolved verified
session (username "marta") and a test router at `/article/12/edit`. Use fake timers for the
150 ms skeleton delay and the relative-time SaveStatus format. Use `Object.defineProperty`/mocked
`navigator.onLine` and dispatched `window` `online`/`offline` events for the offline scenarios.
Fixture article (reused across cases unless a scenario overrides a field): id 12, title
"How we price a bathroom renovation", is_online false, body length 4318, created_at
"2026-03-12T09:14:00Z", updated_at "2026-09-26T11:02:00Z" (fixed `now`
"2026-09-26T11:02:00Z" unless a scenario advances it).
1. **Skeleton only after 150 ms** — mock `GET /v1/article/12` unresolved; assert no skeleton
before 150 ms; advance past it; assert skeleton placeholders for title/content/details;
resolve 200; assert the Saved state renders. Separately, resolve under 150 ms; assert no
skeleton ever renders.
2. **Saved state, draft and online** — Examples-driven on `is_online`: mock `GET
/v1/article/12` resolving 200 with the fixture at each `is_online` value; assert the
breadcrumb, ArticleStatusBadge ("Draft"/"Online"), SaveStatus "Saved at 11:02", Save
disabled, PublishControl ("Publish"/"Unpublish"), and character counter "4 318 / 10 000".
3. **Unsaved changes** — from the Saved state, type into the body; assert SaveStatus "Unsaved
changes", Save enabled, PublishControl now "Save and publish".
4. **Saving via click and via Ctrl/⌘ S** — from Unsaved changes, (a) click Save, (b) press
Ctrl+S; in each, assert `PATCH /v1/article/12` is called with the edited body, SaveStatus
"Saving…", Save disabled while unresolved.
5. **Successful save** — from Saving, resolve `PATCH` 200 with the updated article; assert
SaveStatus "Saved at {time}" and Save disabled again.
6. **Relative save time after an hour** — after a 200 save at 11:02, advance the fake clock past
one hour with no further edit; assert SaveStatus switches to "Saved {n} min ago".
7. **Save failed, Examples-driven** — (a) `PATCH` resolves 500, (b) `PATCH` rejects (network);
in each, assert SaveStatus "Save failed" with a visible Retry button, and the editor still
shows the typed body.
8. **Retry recovers** — from Save failed (500), click Retry; assert `PATCH` is called again with
the same body; resolve 200; assert SaveStatus "Saved at {time}".
9. **404 on save shows a toast, not the error SaveStatus** — from Unsaved changes, save; resolve
`PATCH` 404; assert a toast "That article no longer exists." appears and SaveStatus never
reads "Save failed".
10. **Near the limit** — set the body's HTML length to 9240 (via a fixture string of that
length); assert counter "9 240 / 10 000" with a warning icon and Save still enabled.
11. **Too long** — set the body's HTML length to 10214; assert counter "10 214 / 10 000" with
"214 characters over. Shorten the text or remove an image. Formatting counts too.", Save
disabled, PublishControl disabled; shorten to 10000; assert both re-enable and the over-limit
text disappears.
12. **Preview toggle** — from a loaded article with body content, switch Preview on; assert the
toolbar and editable surface are gone and a read-only rendering of title+body appears;
switch off; assert the editable surface returns with the same content.
13. **Offline blocks and recovers** — from Unsaved changes, set `navigator.onLine` false and
dispatch `window` `offline`; assert SaveStatus shows the wifi-off icon and reads "Waiting
for connection", Save and PublishControl disabled with a tooltip; set `navigator.onLine`
true and dispatch `online`; assert Save re-enables and SaveStatus reads "Unsaved changes"
again.
14. **Leave guard: Discard changes proceeds** — from Unsaved changes, click the "Articles"
breadcrumb link; assert ConfirmDialog opens with the exact title/text/button labels; click
"Discard changes"; assert the router navigates to `/article` and no further `PATCH` fires.
15. **Leave guard: Keep editing stays** — same setup; click "Keep editing"; assert the dialog
closes and the router does not navigate, and the unsaved body is still shown.
16. **No leave guard when clean** — from the Saved state, click the "Articles" breadcrumb link;
assert the router navigates to `/article` with no ConfirmDialog ever rendered.
17. **Session expiry mid-save mirrors and restores the draft** — from Unsaved changes with body
"Paragraph added just before the session expired.", save; resolve `PATCH` 401; assert
`sessionStorage` holds that body keyed by article id 12, and the router navigates to
`/login?redirect=/article/12/edit&reason=expired`. Then, simulating the return trip, mount
EditorPage again at `/article/12/edit` with `GET /v1/article/12` resolving 200 with a body
that lacks that paragraph; assert the rendered body is the restored sessionStorage draft
(with the paragraph) and SaveStatus reads "Unsaved changes".
18. **Not found on load** — mock `GET /v1/article/9999` resolving 404; mount at
`/article/9999/edit`; assert only the error state renders (title "This article doesn't
exist.", action "All articles"), with no breadcrumb-title/SaveStatus/Save/PublishControl/
ArticleEditor; click "All articles"; assert the router navigates to `/article`.
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/screens/editor.md` and `docs/design/source/RenewCMS Screens
Back-office.dc.html`, group "Editor". Run `task visual:check -- editor` for each state below and
compare against the board frame; add each state's mock setup to `web/visual/states.ts` first,
reusing the sample data below and a fixed `now` of "2026-09-26T11:02:00Z" (updated_at "Today,
11:02"; created_at "12 Mar 2026, 09:14"; id 12; title "How we price a bathroom renovation";
3 images).
- [ ] Loading: skeletons for title, content and details, shaped like the real layout.
- [ ] Saved: breadcrumb, "Draft" badge, "Saved at 11:02", disabled Save, "Publish", counter
"4 318 / 10 000", details panel (Created/Updated/ID/Images/Public API URL).
- [ ] Unsaved changes: "Unsaved changes" SaveStatus, enabled Save, "Save and publish".
- [ ] Saving: "Saving…" SaveStatus, disabled Save.
- [ ] Save failed: "Save failed" with Retry, per the SaveStatus error spec.
- [ ] Near the limit: counter "9 240 / 10 000" with the warning icon/style.
- [ ] Too long: counter "10 214 / 10 000" with the over-limit text and disabled Save/Publish.
- [ ] Preview: read-only rendered title and body in the content scale, toolbar hidden.
- [ ] Offline: SaveStatus shows the wifi-off icon and reads "Waiting for connection", disabled
Save/Publish, OfflineBanner visible.
- [ ] Not found (404): the standalone error state, no editor chrome.
- [ ] Mobile (all of the above at 390×844): sticky bottom bar for Save/PublishControl, details
panel as a slideover, single "Edit article" mobile top-bar title instead of the breadcrumb.
- [ ] Keyboard/focus: Tab reaches the breadcrumb, Save, Preview switch and PublishControl in a
sensible order; Ctrl/⌘ S saves from anywhere in the editor surface; the leave-guard
ConfirmDialog traps focus starting on "Discard changes" per ConfirmDialog's a11y spec, and
Esc behaves like dismissing (equivalent to "Keep editing"); the character-counter live
region announces only when crossing the warn/over thresholds, not on every keystroke.
- [ ] Motion: modal open/close and skeleton pulse follow the standard 240 ms / 1.6 s timings and
stop under reduced motion.
## Manual verification
- **Real GET/PATCH round trip against a running server** — Vitest only mocks `useApi`; confirm
the real 200/404/401/500 responses and the real `sessionStorage` restoration across an actual
session expiry (`JWT_SECRET`-signed cookie timing out) against `task dev` with a live SQLite
file, since automated tests never exercise the real Go handler, `UpdateArticleUseCase`, or a
real cookie's `exp` claim.
- **Real `beforeunload` prompt** — Vitest can assert `onBeforeRouteLeave`'s ConfirmDialog, but a
real browser tab-close prompt (native `beforeunload`) needs manual confirmation, since jsdom
doesn't model it.
+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.
+63
View File
@@ -0,0 +1,63 @@
Feature: Integration screen
As a verified RenewCMS owner
I want /integration to show my site's base API URL and ready-to-use endpoint examples
So that I, or the developer who builds my public site, can wire it up without reading the
source
Background:
Given /integration is a verified route (meta.auth "verified") rendered as IntegrationPage
inside DashboardShell, per docs/design/routes.md
And the screen makes no /v1 calls; api: none (static)
And the PageHeader shows title "Connect your website", description "A guide for the developer
who builds your public site.", breadcrumb "Integration" (mobile title "Integration"), and no
actions
And an introductory paragraph reads "RenewCMS is headless: it stores and publishes your
articles but doesn't show them to visitors. Your website fetches published articles from the
two addresses below and displays them in its own design."
And a "Base URL" row shows window.location.origin in a monospace box, with a "Copy" button
(icon copy) beside it
And two EndpointCard entries follow, in this order:
| method | path | description |
| GET | /v1/article/ | Lists every online article. |
| GET | /v1/article/{id} | Returns one online article. Drafts and unknown ids both return 404. |
And each EndpointCard's fetch example uses the current origin as the base of its URL:
| path | fetch example |
| /v1/article/ | const res = await fetch('{origin}/v1/article/')\nconst articles = await res.json() |
| /v1/article/{id} | const res = await fetch('{origin}/v1/article/12')\nif (res.status === 404) return notFound()\nconst article = await res.json() |
And each EndpointCard has fetch, curl and Response tabs, with fetch active by default, and its
CodeBlock is labelled "JavaScript" with its own ghost "Copy" button
And the curl tab's content is a plain curl command built from the current origin (e.g.
"curl {origin}/v1/article/" and "curl {origin}/v1/article/12"), and the Response tab's
content is a JSON sample built from the real domain Article fields; neither tab is active by
default, and neither is visually checked, since the fetch tab is the one shown in the board
And three info notes follow the endpoint cards, in this order:
- "Anonymous callers only ever get online articles. Drafts stay private until you publish
them."
- "Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the
base URL before rendering."
- "Browsers can only call the API from origins listed in the server's CORS setting. Ask
whoever runs the server to add your site's address."
Scenario: Default — the page loads with the app's own origin
Given the app is served from origin "{origin}"
When the user navigates to /integration
Then the Base URL row shows exactly "{origin}"
And the Base URL "Copy" button reads "Copy", not "Copied"
And both EndpointCards' fetch examples use "{origin}" as the base of their URL
Scenario: Copied — the Base URL copy button
Given the user is on /integration, served from origin "{origin}"
When the user activates the Base URL row's "Copy" button
Then "{origin}" is written to the clipboard
And the button's icon changes to check and its text changes to "Copied"
And the change is announced to assistive technology
When 2 seconds elapse without a further click
Then the button reverts to icon copy and text "Copy"
Scenario: Each EndpointCard's own Copy button is independent of the Base URL's
Given the user is on /integration
When the user activates the "/v1/article/" EndpointCard's "JavaScript" code block's "Copy"
button
Then that card's own code is written to the clipboard, and only that button reads "Copied"
And the Base URL row's "Copy" button and the other EndpointCard's "Copy" button are unaffected
+66
View File
@@ -0,0 +1,66 @@
# QA: Integration screen
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mount IntegrationPage inside DashboardShell with a resolved verified session and a test router at
/integration. Stub `window.location.origin` (or the equivalent composable/helper the coder uses to
read it) to a fixed value for the test, and stub `navigator.clipboard.writeText` per case. No
`/v1` mocks are needed; the screen makes no calls.
1. **Default** — mount at /integration with origin stubbed to a fixed value (e.g.
`https://cms.renew.example`, matching the design board's sample so the assertions read the same
as the visual reference); assert breadcrumb "Integration"; assert the PageHeader title
"Connect your website" and description "A guide for the developer who builds your public
site."; assert the intro paragraph text; assert the Base URL row shows exactly the stubbed
origin; assert the Base URL "Copy" button reads "Copy"; assert both EndpointCards render in
order (GET /v1/article/, GET /v1/article/{id}) with their descriptions and their fetch examples
containing the stubbed origin; assert the fetch tab is active by default on both cards; assert
the three info notes render in order.
2. **Copied** — from Default, use fake timers; click the Base URL row's "Copy" button; assert
`navigator.clipboard.writeText` was called with the stubbed origin; assert the button's icon is
check and its text is "Copied"; assert the change is exposed to assistive technology (e.g. an
aria-live region or a status role holds "Copied"); advance the fake timer by 2 s; assert the
button reverts to icon copy and text "Copy".
3. **EndpointCard's own Copy button is independent** — from Default, click the "/v1/article/"
card's "JavaScript" CodeBlock's "Copy" button; assert `navigator.clipboard.writeText` was called
with that card's exact code string; assert only that button reads "Copied"; assert the Base URL
button still reads "Copy" and the other card's Copy button still reads "Copy". This behavior is
Vitest-only; it has no visual-check state of its own (see Visual below).
4. **curl and Response tabs render, unchecked visually** — from Default, switch the "/v1/article/"
card to its curl tab; assert its content is a plain curl command built from the stubbed origin
(e.g. "curl https://cms.renew.example/v1/article/"); switch to its Response tab; assert its
content is a JSON sample built from the real domain Article fields. Neither tab's content is
asserted pixel-for-pixel anywhere, since the design board only ever shows the fetch tab active.
## Visual (`task visual:check -- integration`, desktop and mobile, light and dark)
Reference: `docs/design/screens/integration.md` and
`docs/design/source/RenewCMS Screens Back-office.dc.html`, group "Integration"; layout detail from
`docs/design/source/AppFrame.dc.html`'s `isInteg` block.
The visual-check setup for this screen serves the SPA under `https://cms.renew.example` via
Playwright request routing, so the real `window.location.origin` the running app reads matches the
board's sample text exactly; no production test hook or origin stub is needed in app code.
- [ ] Default state: PageHeader ("Connect your website" / description), intro paragraph, Base URL
row with monospace text and a "Copy" button (icon copy), two EndpointCards in order with
their GET badge, path, description, fetch/curl/Response tabs (fetch active), JavaScript
CodeBlock and its own "Copy" button, and the three info notes below — matches the board
exactly at desktop and mobile, light and dark. Sample data for the mocked run: origin
`https://cms.renew.example`, served as above.
- [ ] Copied state: the Base URL button shows the check icon and "Copied" text, in the success
color, matching the board's `copied: true` frame. This state is timer-driven, not
date-driven, so it is not clock-sensitive and gets a real visual check, not a skip.
- [ ] Keyboard/focus (manual, not a screenshot check): Tab order reaches the Base URL "Copy"
button, then each EndpointCard's tab strip (fetch/curl/Response, reachable and operable with
arrow keys per UTabs), then that card's own CodeBlock "Copy" button, then the pre's
`tabindex="0"` for scrolling; Enter/Space activates each Copy button.
- [ ] Hover (manual): ghost Copy buttons show a hover state consistent with other ghost buttons
elsewhere in the app (UserMenu, ArticleTable row menu).
## Manual verification
- Confirm the real running app's `window.location.origin` (e.g. `https://your-domain.example` in
production, or `http://localhost:8080` under `task dev`) renders correctly in the Base URL row
and in both fetch examples, since Vitest only ever exercises a stubbed origin, and
`visual:check` only ever exercises the routed `https://cms.renew.example` origin.
+102
View File
@@ -0,0 +1,102 @@
Feature: Log-in card sizing, alert placement and focus behavior match the Auth board
As the maintainer of RenewCMS
I want AuthShell's card padding, control heights, "Forgot password?" styling, field-error
presentation, theme select and h1 focus timing built exactly to the AuthCard board
(docs/design/source/AuthCard.dc.html) instead of Nuxt UI's untouched defaults
So that /login (and every other AuthShell-based public screen) matches the design board pixel
for pixel, at desktop and mobile, in light and dark
This complements features/auth_shell_login_conformance.feature, which covers AppLogo, the
UPageCard title/description wiring, the muted-background breakpoint and the neutral/error input
edge. This feature covers what that one doesn't: exact card/control measurements, the "Forgot
password?" link's own styling, the field-error icon and halo behavior, the theme select's
chevron, where the alert sits relative to the card, and when the h1 receives focus.
Background:
Given AuthShell is the layout for every public route, per docs/design/routes.md
And the card's padding, control heights and title size come from
docs/design/source/AuthCard.dc.html's renderVals(): pad "40px" and control height "40px"
from the sm breakpoint up; pad "8px 20px 0" (with a separate footer padding of
"0 20px 24px") and control height "44px" below sm; the title is always "font:600 20px/28px"
And the Password UFormField's "Forgot password?" link is styled color var(--ui-text-toned)
(muted), text-decoration underline, underline-offset 3px, per AuthCard.dc.html's f.link span
And a field error renders with the i-lucide-circle-alert icon in var(--ui-error), and the
field's edge/shadow is computed error-first, so an errored field that is also focused shows
only the single error-colored inset ring, never a focus ring stacked under or around it
And AuthShell's notice slot (the alert) renders inside the card's own layout, after the
title/description block and before the form — not as a sibling element above the card
And the footer's UColorModeSelect renders with no chevron/dropdown-arrow icon, as the last
(right-hand) child of the footer's space-between row, per AuthCard.dc.html's theme span
And on first paint, AuthShell's h1 (tabindex="-1") does not receive focus; focus moves to it
only when the title changes while AuthShell stays mounted (an in-app navigation between
public screens, e.g. /login to /register/reset/request), per components.md's AuthShell a11y
line ("On route change, focus moves to the h1")
And the h1 never shows a visible focus outline, since it is only ever focused programmatically
and is not part of the visible keyboard-tab order
Scenario: Card padding and control heights at sm and up
Given the user is on /login at a viewport of sm or wider
When AuthShell and the login form render
Then the card's own padding is 40px on every side
And the Username input, the Password input and the submit button are each 40px tall
And input text renders at 14px
Scenario: Card padding and control heights below sm
Given the user is on /login at a viewport below sm
When AuthShell and the login form render
Then the card's own padding is 8px top, 20px left and right, 0 bottom
And the footer (outside the card) has its own padding of 0 top, 20px left and right, 24px bottom
And the Username input, the Password input and the submit button are each 44px tall
And input text renders at 16px
Scenario: "Forgot password?" is underlined and muted
Given the user is on /login
When the Password field renders
Then the "Forgot password?" link uses the muted text color (--ui-text-toned)
And the link is underlined with a 3px underline offset
And the link carries no other emphasis (not bold, not the primary color)
Scenario: A field error shows the circle-alert icon with no extra focus halo
Given the user is on /login
When the user submits with Username "ma" and Password empty (the Validation errors state)
Then the Username field's error message is preceded by the i-lucide-circle-alert icon in the
error color
And the Password field's error message is preceded by the same icon in the same color
When the user tabs into the still-errored Username field so it is both errored and focused
Then the field shows exactly one inset ring, in the error color
And no second, differently colored focus ring is layered under or around it
Scenario: The theme select is right-aligned in the footer with no chevron
Given the user is on /login
When AuthShell's footer renders
Then the UColorModeSelect is the last (right-most) element in the footer's row
And it renders only its mode icon and label ("System", "Light" or "Dark")
And it renders no trailing chevron or dropdown-arrow icon
Scenario Outline: The alert renders inside the card, under the description
Given the user is redirected to <path>
When /login renders with its <state> alert
Then the alert is a child of the same card element that contains the title and description,
not a sibling positioned above the card
And the alert sits after the title/description block and before the Username field
And the alert's background uses UAlert's soft variant, which is dark-tinted automatically in
dark mode
Examples:
| path | state |
| /login?redirect=/article/12/edit&reason=expired | Session expired |
| /login?reason=password-changed | Password changed |
Scenario: The h1 is not focused on first load
Given the user navigates directly to /login (a fresh page load)
When AuthShell mounts and the h1 renders
Then the h1 does not receive focus
And focus remains on the document body (or wherever the browser places it on load)
Scenario: Focus moves to the h1 only on an in-app navigation between public screens
Given the user is on /login with AuthShell already mounted
When the user follows the "Forgot password?" link to /register/reset/request without a full
page reload
Then AuthShell's h1 receives focus once ResetRequestPage's title renders
And the h1 shows no visible outline when it receives this focus
+59
View File
@@ -0,0 +1,59 @@
# QA: Log-in card sizing, alert placement and focus behavior match the Auth board
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mount AuthShell/LoginView directly; mock `useApi` (`GET /v1/auth/registration`,
`POST /v1/auth/login`) only as scaffolding, per features/login_screen.qa.md's setup.
1. **Card padding and control heights at sm+** — mount at a `>= 640px` viewport; assert the
card's own padding is 40px on every side (not Nuxt UI's untouched default); assert Username,
Password and the submit button each render at a computed height of 40px; assert input font-size
is 14px.
2. **Card padding and control heights below sm** — mount at a `< 640px` viewport; assert the
card's padding is 8px top / 20px sides / 0 bottom; assert the footer's own padding is 0 top /
20px sides / 24px bottom; assert Username, Password and the submit button are each 44px tall;
assert input font-size is 16px.
3. **"Forgot password?" styling** — assert the link's computed color equals `--ui-text-toned`,
`text-decoration-line: underline`, `text-underline-offset: 3px`, and that it carries no bold
weight or primary-color class.
4. **Field error icon and no stacked halo** — trigger the Validation errors state; assert each
field's error text is preceded by an `i-lucide-circle-alert` icon in the error color; then
focus the still-errored Username input and assert only one ring class (the error ring) is
present — the neutral/primary focus ring class is absent, not merely visually covered.
5. **Theme select has no chevron, is footer-last** — assert `UColorModeSelect` is the last child
of the footer's flex row; assert no chevron-down icon element renders inside it.
6. **Alert renders inside the card** — mount at `/login?reason=expired` and separately at
`/login?reason=password-changed`; assert the alert element's closest UPageCard/card ancestor is
the same element that contains the h1 (i.e., the alert is NOT a sibling before the card);
assert its DOM position is after the title/description block and before the Username field.
7. **h1 not focused on first load** — mount LoginView fresh (simulating first paint); assert
`document.activeElement` is not the h1.
8. **h1 focus on in-app navigation only** — mount AuthShell at /login, then update the `title`
prop the way an in-app route change to /register/reset/request would (per the existing `watch`
on `props.title`); assert the h1 now has focus.
## Visual (manual verification — `task visual:check -- log-in`, desktop and mobile, light and dark)
Reference: `docs/design/source/AuthCard.dc.html` via `RenewCMS Screens Auth.dc.html`, group
"Log in", all 9 states.
- [ ] Card padding is 40px on every side at desktop, and 8px/20px/0 (card) + 0/20px/24px
(footer) below sm, across all 9 states.
- [ ] Username, Password and the submit button are 40px tall at desktop and 44px below sm.
- [ ] "Forgot password?" is muted and underlined in every state that shows it.
- [ ] In the Validation errors state, both fields show the circle-alert icon and exactly one
ring around the field (no double ring), light and dark.
- [ ] The theme select shows icon + label only, no chevron, right-aligned in the footer.
- [ ] In Session expired and Password changed, the alert sits inside the card, under the
description, above the Username field, and is visibly dark-tinted (not a light-mode
background) in dark mode.
- [ ] Keyboard/focus: on a fresh /login load, nothing is auto-focused to the h1 (initial focus
stays with the browser/body); following "Forgot password?" to the reset-request screen moves
focus to that screen's h1, with no visible outline ring at any point.
## Manual verification
- **AppLogo edge in dark mode** — resolved by the amended
features/auth_shell_login_conformance.feature: AppLogo shows no edge stroke in either light or
dark mode, per AuthCard.dc.html's literal `edge: 'transparent'`. No check needed here; see that
feature's own QA file for the AppLogo assertions.
+122
View File
@@ -0,0 +1,122 @@
Feature: Log in screen
As a returning RenewCMS user
I want a /login screen that authenticates me against POST /v1/auth/login, shows why a previous
attempt or session failed, and offers to create the owner account only while registration is
open
So that I can get back into the back office from any entry point (a direct visit, an expired
session, a completed password reset) with copy that matches what actually happened
Background:
Given /login is a public route (meta.auth "public") rendered as LoginPage inside AuthShell,
per docs/design/routes.md
And on mount, LoginPage calls GET /v1/auth/registration to learn whether registration is open
And the form has a Username field (placeholder "Your username") and a Password field (a
password input with a show/hide toggle, autocomplete "current-password", and a
"Forgot password?" link), per docs/design/components.md
And client-side validation runs on submit before any request is sent: Username is required
with "Use at least 3 characters." under 3 characters, and Password is required with
"Enter your password." when empty
And a valid submit calls POST /v1/auth/login with { username, password }
And on a 200 response, LoginPage re-resolves the session (GET /v1/auth/me, per
features/nuxt_ui_shell_foundation.feature and features/api_client_global_error_handling.feature)
and then navigates: to the original ?redirect= target if present, else to /home when the
session is verified, or to /register/pending when it is not
And a 403 response means the username/password pair is wrong, since the API never reveals
which field was wrong
And a 429 response carries a Retry-After header in seconds, surfaced by useApi and displayed as
a live countdown on the submit button; the button re-enables when the countdown reaches zero
And a 500 response, or a request that never reaches the server, is surfaced as a network/server
error; the fields keep whatever the user typed
Scenario: Default — registration is closed
Given GET /v1/auth/registration resolves 200 with { "open": false }
When the user navigates to /login
Then the heading is "Log in"
And the description is "Welcome back. Log in to manage your articles."
And the Username field shows placeholder "Your username" with no value
And the Password field shows no value
And the submit button reads "Log in"
And no "Create the owner account" footer link is shown
Scenario: Fresh install — registration is open
Given GET /v1/auth/registration resolves 200 with { "open": true }
When the user navigates to /login
Then the heading, description and fields render exactly as in the Default state
And the footer reads "First time here?" with a "Create the owner account" link
Scenario: Validation errors — nothing is sent for a short username and an empty password
Given the user is on /login
When the user types "ma" into Username, leaves Password empty, and submits
Then POST /v1/auth/login is never called
And the Username field shows the error "Use at least 3 characters."
And the Password field shows the error "Enter your password."
Scenario: Submitting — fields and button are disabled while the request is in flight
Given the user is on /login with Username "marta" and Password "limewash2026"
When the user submits and POST /v1/auth/login has not yet resolved
Then the Username and Password fields are disabled
And the submit button reads "Logging in…" and shows a loading state
Scenario: Wrong credentials — the password is cleared and focused
Given the user is on /login with Username "marta" and Password "wrong-password"
When the user submits and POST /v1/auth/login resolves 403
Then an error alert reads "That username and password don't match" with description
"Check both and try again. Passwords are case-sensitive."
And the Username field still shows "marta"
And the Password field is cleared and receives focus
And the submit button reads "Log in" again, no longer loading
Scenario: Too many attempts — the button is disabled with a live countdown
Given the user is on /login with Username "marta" and Password "limewash"
When the user submits and POST /v1/auth/login resolves 429 with a Retry-After of 42 seconds
Then an info alert reads "Too many attempts" with description
"For your security, wait a moment before trying again."
And the Username field still shows "marta" and the Password field still shows "limewash"
And the submit button reads "Try again in 0:42" and is disabled
And the Username and Password fields are disabled while the countdown runs, as the board draws
them
When 42 seconds elapse
Then the submit button re-enables and reads "Log in"
And the Username and Password fields are editable again
Scenario: Session expired — redirected from a 401 elsewhere
Given the user is redirected to "/login?redirect=/article/12/edit&reason=expired"
When /login renders
Then an info alert reads "Your session expired" with description
"Log in again to pick up where you left off. Unsaved changes are kept on this device."
And the Password field receives focus
And the submit button reads "Log in"
Scenario: Password changed — arrived from a successful reset
Given the user is redirected to "/login?reason=password-changed"
When /login renders
Then a success alert reads "Password changed" with description "Log in with your new password."
And the Username field shows placeholder "Your username" with no value
And the submit button reads "Log in"
Scenario Outline: Server or network error — the fields keep their values
Given the user is on /login with Username "marta" and Password <password>
When the user submits and POST /v1/auth/login <failure>
Then an error alert reads "Couldn't reach RenewCMS" with description
"Check your connection and try again."
And the Username field still shows "marta" and the Password field still shows <password>
And the submit button reads "Log in" again, no longer loading
Examples:
| password | failure |
| "limewash2026" | fails with a 500 response |
| "limewash2026" | never reaches the server |
| "short1" | fails with a 400 response, because "short1" clears client-side validation (non-empty, 3+ characters) but is under the server's 8-character minimum, which the client does not check |
Scenario Outline: A successful log in navigates to the right destination
Given the user is on <path>
And GET /v1/auth/me, called after the 200 response, resolves 200 with is_verified <verified>
When the user submits valid credentials and POST /v1/auth/login resolves 200 with a jwt cookie
Then the app navigates to <destination>
Examples:
| path | verified | destination |
| /login | true | /home |
| /login | false | /register/pending |
| /login?redirect=/article/12/edit | true | /article/12/edit |
+96
View File
@@ -0,0 +1,96 @@
# QA: Log in screen
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi` (`GET /v1/auth/registration`, `POST /v1/auth/login`, `GET /v1/auth/me`) per case; no
real backend, no httptest server. Mount LoginPage inside AuthShell with a test router whose current
route starts at the path each case needs.
1. **Default state** — mock `GET /v1/auth/registration` resolving `{ open: false }`; mount at
/login; assert heading "Log in", description "Welcome back. Log in to manage your articles.",
Username placeholder "Your username" with no value, Password with no value, submit button "Log
in", and no "Create the owner account" link.
2. **Fresh install state** — mock `GET /v1/auth/registration` resolving `{ open: true }`; mount at
/login; assert the same heading/description/fields as case 1, plus a "Create the owner account"
link under "First time here?".
3. **Validation errors state** — mount at /login; set Username to "ma", leave Password empty,
submit; assert the mocked `POST /v1/auth/login` was never called; assert Username shows "Use at
least 3 characters." and Password shows "Enter your password."
4. **Submitting state** — set Username "marta", Password "limewash2026"; mock
`POST /v1/auth/login` with an unresolved promise; submit; assert Username and Password are
disabled and the submit button reads "Logging in…" with a loading indicator.
5. **Wrong credentials state** — set Username "marta", Password "wrong-password"; mock
`POST /v1/auth/login` resolving 403; submit; assert an error alert with title "That username and
password don't match" and description "Check both and try again. Passwords are case-sensitive.";
assert Username still reads "marta"; assert Password is empty and has focus; assert the submit
button reads "Log in" and is no longer loading.
6. **Too many attempts state** — set Username "marta", Password "limewash"; mock
`POST /v1/auth/login` resolving 429 with a `Retry-After: 42` header; submit; assert an info alert
with title "Too many attempts" and description "For your security, wait a moment before trying
again."; assert Username still reads "marta" and Password still reads "limewash"; assert the
submit button reads "Try again in 0:42" and is disabled; assert both fields are disabled;
advance fake timers by 42 seconds; assert the button re-enables and reads "Log in" and both fields
are editable again.
7. **Session expired state** — mount at
"/login?redirect=/article/12/edit&reason=expired"; assert an info alert with title "Your session
expired" and description "Log in again to pick up where you left off. Unsaved changes are kept on
this device."; assert the Password field has focus; assert the submit button reads "Log in".
8. **Password changed state** — mount at "/login?reason=password-changed"; assert a success alert
with title "Password changed" and description "Log in with your new password."; assert Username
shows placeholder "Your username" with no value.
9. **Server or network error state** — three sub-cases, one per Examples row: (a) a 500 response
with Password "limewash2026"; (b) a rejected fetch (network error) with Password "limewash2026";
(c) a 400 response with Password "short1" (6 characters — passes client validation since it's
non-empty and 3+ characters, but is under the server's 8-character minimum, which the client does
not check, so the request reaches the server and comes back 400). In each sub-case, set Username
"marta" and the row's Password, submit, and assert an error alert with title "Couldn't reach
RenewCMS" and description "Check your connection and try again."; assert Username still reads
"marta" and Password still reads the row's value; assert the submit button reads "Log in" and is
no longer loading. Sub-case (c) also asserts the mocked `POST /v1/auth/login` was actually called
(proving client validation let it through), unlike scenario 3's validation-errors case.
10. **Successful log in redirects correctly** — one test per Examples row: mock
`POST /v1/auth/login` resolving 200, then `GET /v1/auth/me` resolving 200 with the row's
`is_verified`; mount at the row's path; submit valid credentials; assert the router's current
path equals the row's destination.
| path | verified | destination |
|----------------------------------------|----------|-------------------------|
| /login | true | /home |
| /login | false | /register/pending |
| /login?redirect=/article/12/edit | true | /article/12/edit |
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Log in".
- [ ] Default state, desktop and mobile, light and dark: heading, description, Username, Password
with show/hide toggle, "Forgot password?" link, "Log in" button, matches the board exactly.
- [ ] Fresh install state: "First time here? Create the owner account" footer renders below the
form, desktop and mobile, light and dark.
- [ ] Validation errors state: both field errors render inline under their fields with the error
styling from components.md, desktop and mobile, light and dark.
- [ ] Submitting state: disabled field styling and the loading spinner in the submit button, desktop
and mobile, light and dark.
- [ ] Wrong credentials state: error alert (circle-x icon, `role="alert"`) above the heading,
cleared/focused Password field, desktop and mobile, light and dark.
- [ ] Too many attempts state: info alert (timer icon), disabled fields and a disabled button with
the live "Try again in m:ss" countdown text, desktop and mobile, light and dark.
- [ ] Session expired state: info alert (clock icon, no close button), focused Password field,
desktop and mobile, light and dark.
- [ ] Password changed state: success alert (circle-check icon, no close button, as the board draws
it), desktop and mobile, light and dark.
- [ ] Server or network error state: error alert (circle-x icon, no close button), fields retain
their values, desktop and mobile, light and dark.
- [ ] Keyboard/focus: Tab order is Username → Password → show/hide toggle → "Forgot password?" →
submit; the alert (when present) is announced via `role="status"` (info/success) or
`role="alert"` (error) without re-announcing on re-render; Password receives programmatic focus
exactly in the Wrong credentials and Session expired states, and nowhere else.
## Manual verification
- **Real GET /v1/auth/registration and POST /v1/auth/login against a running server** — Vitest only
mocks `useApi`; confirm the Default/Fresh install split and a real 403/429/200 round-trip against
`task dev` with a live SQLite file, since this slice's automated tests never exercise the actual Go
handlers, the rate limiter, or the real `jwt` cookie.
- **429 countdown drift** — confirm `useRetryAfter`'s live countdown does not drift or double-fire
the re-enable against a real elapsed 42-second wait, since Vitest fake timers only prove the logic,
not real wall-clock behavior.
+146
View File
@@ -0,0 +1,146 @@
Feature: Creating a new article from NewArticleModal
As a verified RenewCMS owner
I want NewArticleModal to take a title and a first paragraph, create the article as a draft
over POST /v1/article/, and take me straight to the editor to keep writing
So that starting a new article is a two-field, low-friction step instead of a full form, and I
land where I can actually write it
Background:
Given NewArticleModal is a UModal opened from Home's "New article" action (mobile header
button, desktop header button, and the empty state's "New article" action), per
docs/design/routes.md and docs/design/screens/new-article.md; the Articles-page trigger and
the /article/new deep link are out of scope for this slice, deferred until ArticlesPage exists
And every time it opens, it starts in the Default state: title "New article", text "It's
saved as a draft. Titles can't be changed later, so check it now.", an empty, focused Title
field with placeholder "e.g. Choosing tiles for a small bathroom", a CharacterCounter
reading "0 / 200" and help "3 to 200 characters.", an empty First paragraph textarea with
placeholder "Start writing. You'll continue in the editor." and help "You can format it and
add images in the editor.", a "Cancel" button and a "Create draft" button
And the Title field is sent as the request's title and is validated required, 3 to 200
characters, counted the same way as the CharacterCounter ([...str].length, which is rune
count, matching article_title_rules.feature's server-side rune counting)
And the First paragraph field is sent as the request's body and is validated required (at
least one character)
And each field validates on blur and again on submit; submitting while either field is
invalid sends no request, and shows both fields' errors at once if both are invalid: Title
under 3 characters shows "Use at least 3 characters." in place of its help text, with the
counter in its error style; an empty First paragraph shows "Add a first paragraph. It can
be a single sentence." in place of its help text
And activating "Create draft" while both fields are valid sends POST /v1/article/ with JSON
{"title": "<title>", "body": "<first paragraph>"}; while the request is in flight both
fields and both buttons are disabled, the confirm button reads "Creating…" with a loading
indicator, and the modal cannot be dismissed by Cancel, Esc or a backdrop click
And a 201 response closes the modal, emits created(article) with the response body, and (for
this slice) HomeView.vue's created handler navigates to /article/{id}/edit and raises a
toast: color success, icon i-lucide-circle-check, title "Draft created", description
"\"{title}\" is saved as a draft.", with id "article-created-{id}" per
docs/design/components.md's toast-id convention
And a 400 response — whether apperr.ArticleTitleErrorMessage for a title that passed this
form's own check but still failed the server's, or any other 400 — is never shown verbatim;
it re-enables the form with both fields' current values kept, shows "Check this title and
try again." in place of the Title field's help text, and shows an alert "Something in the
form isn't valid. Check the highlighted fields."; the First paragraph field shows no error
And a 500 response or a request that never reaches the server re-enables the form with both
fields' current values kept and shows an alert "Couldn't create the article. Your text is
still here. Try again in a moment.", with no field-level error
And "Cancel" always closes the modal immediately and discards both fields, with no
confirmation prompt, whenever the form is not disabled by an in-flight request
And a 401 response is handled entirely by useApi's existing global handling
(features/api_client_global_error_handling.feature): the session is cleared and the app
navigates to /login?redirect=<path>&reason=expired; this slice adds no draft-preservation
behavior for the modal's fields, unlike the editor's session-expiry handling
Scenario: Opening the modal shows the Default state
Given the user is on /home
When the user activates "New article"
Then NewArticleModal opens with title "New article" and the text "It's saved as a draft.
Titles can't be changed later, so check it now."
And the Title field is empty, focused, and its counter reads "0 / 200"
And the First paragraph field is empty
And "Create draft" is present but the form has not been submitted
Scenario: Validation — a title under 3 characters on blur
Given the modal is open
When the user types "Ti" into the Title field and moves focus away
Then the Title field shows the error "Use at least 3 characters." in place of its help text
And the counter reads "2 / 200" in its error style
And no request is sent
Scenario: Validation — an empty first paragraph on blur
Given the modal is open with a valid Title
When the user leaves the First paragraph field empty and moves focus away
Then the First paragraph field shows the error "Add a first paragraph. It can be a single
sentence."
And no request is sent
Scenario: Validation — submitting with both fields invalid shows both errors and sends nothing
Given the modal is open with Title "Ti" and an empty First paragraph
When the user activates "Create draft"
Then the Title field shows "Use at least 3 characters." and the First paragraph field shows
"Add a first paragraph. It can be a single sentence."
And POST /v1/article/ is never sent
Scenario: Submitting disables the form and cannot be dismissed
Given the modal is open with Title "Choosing tiles for a small bathroom" and First paragraph
"Small rooms show every grout line, so we start with the floor."
When the user activates "Create draft"
Then POST /v1/article/ is sent with JSON {"title": "Choosing tiles for a small bathroom",
"body": "Small rooms show every grout line, so we start with the floor."}
And both fields become disabled, "Cancel" becomes disabled, and the confirm button reads
"Creating…" with a loading indicator
And pressing Esc or clicking the backdrop does not close the modal
Scenario: A successful create closes the modal and hands off to the editor with a toast
Given the user is on /home with the modal open, Title "Choosing tiles for a small bathroom"
and First paragraph "Small rooms show every grout line, so we start with the floor.", and
"Create draft" has been activated
When POST /v1/article/ resolves 201 with the created article, id 42, is_online false
Then the modal closes
And the app navigates to /article/42/edit
And a toast appears: color success, icon i-lucide-circle-check, title "Draft created",
description "\"Choosing tiles for a small bathroom\" is saved as a draft.", id
"article-created-42"
Scenario: Title rejected — a 400 that got past this form's own check
Given the modal is open with Title "Choosing tiles for a small bathroom" (already valid by
this form's own rule) and First paragraph "Small rooms show every grout line, so we start
with the floor.", and "Create draft" has been activated
When POST /v1/article/ resolves 400
Then the modal re-enables, keeping both field values
And the Title field shows "Check this title and try again." in place of its help text
And an alert reads "Something in the form isn't valid. Check the highlighted fields."
And the First paragraph field shows no error
Scenario Outline: Error — a 500 or a network failure keeps the text and offers to retry
Given the modal is open with Title "Choosing tiles for a small bathroom" and First paragraph
"Small rooms show every grout line, so we start with the floor.", and "Create draft" has
been activated
When POST /v1/article/ <failure>
Then the modal re-enables, keeping both field values
And an alert reads "Couldn't create the article. Your text is still here. Try again in a
moment."
And neither field shows a field-level error
Examples:
| failure |
| resolves with a 500 response |
| never reaches the server |
Scenario: Retrying after an Error succeeds
Given the modal is in the Error state after a 500, with Title "Choosing tiles for a small
bathroom" and First paragraph "Small rooms show every grout line, so we start with the
floor." still in the fields
When the user activates "Create draft" again
Then POST /v1/article/ is sent again with the same JSON body
When it resolves 201 with the created article, id 42
Then the modal closes, the app navigates to /article/42/edit, and a toast appears: color
success, icon i-lucide-circle-check, title "Draft created", description "\"Choosing tiles
for a small bathroom\" is saved as a draft.", id "article-created-42"
Scenario: Cancel discards the fields with no confirmation
Given the modal is open with Title "Choosing tiles for a small bathroom" and First paragraph
"Small rooms show every grout line, so we start with the floor."
When the user activates "Cancel"
Then the modal closes immediately, with no confirmation prompt
And no request is sent
+97
View File
@@ -0,0 +1,97 @@
# QA: Creating a new article from NewArticleModal
## Implementation note for coder (informational, not a QA assertion)
Only HomeView.vue is wired as a real `created` consumer in this slice: it listens for
`@created="article => { router.push(...); useToast().add(...) }"`. The Articles-page trigger and
the `/article/new` route stay pointed at the current ArticleView.vue placeholder until that page
exists; don't wire a `created` handler there yet.
The confirm button's label changes from the placeholder's "Create article" to the design's
"Create draft" — this is a deliberate copy change, not a leftover.
The Created toast uses `useToast().add({ id: 'article-created-{id}', color: 'success', icon:
'i-lucide-circle-check', title: 'Draft created', description: '"{title}" is saved as a draft.' })`,
per docs/design/components.md's toast-id convention (action + article id) and the Toast rules
section (success/info close after 5 s).
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi`'s `request` for `POST /v1/article/` per case; no real backend, no httptest server.
Mount HomePage (which owns NewArticleModal) inside DashboardShell with a resolved verified session,
and a test router at `/home`. Sample data throughout: title "Choosing tiles for a small bathroom",
first paragraph "Small rooms show every grout line, so we start with the floor.", created article
id 42, is_online false.
1. **Default state on open** — click "New article"; assert the modal shows title "New article",
the intro text, an empty focused Title field with counter "0 / 200" and help "3 to 200
characters.", an empty First paragraph field with its placeholder and help, "Cancel" and
"Create draft".
2. **Blur validation, Title** — type "Ti" into Title and blur; assert the error "Use at least 3
characters." replaces the help text and the counter "2 / 200" is in its error style; assert no
`request` call.
3. **Blur validation, First paragraph** — with a valid Title, leave First paragraph empty and
blur; assert "Add a first paragraph. It can be a single sentence." appears; assert no
`request` call.
4. **Submit with both fields invalid** — Title "Ti", empty First paragraph, click "Create draft";
assert both errors show and `request` is never called.
5. **Submitting state** — fill both fields validly, click "Create draft"; assert `request` is
called once with `POST /v1/article/` and the exact JSON body; assert both fields and Cancel
become disabled, the confirm button reads "Creating…"; assert pressing Escape and clicking the
backdrop do not close the modal while unresolved.
6. **Successful create hands off** — from Submitting, resolve `request` 201 with the fixture
article (id 42); assert the modal closes; assert the router navigated to `/article/42/edit`;
assert a toast was raised with id "article-created-42", color success, icon
"i-lucide-circle-check", title "Draft created", and description "\"Choosing tiles for a small
bathroom\" is saved as a draft."
7. **Title rejected (400)** — from Submitting, resolve `request` rejecting with `ApiError(400)`;
assert both field values are kept, the Title field shows "Check this title and try again.",
an alert reads "Something in the form isn't valid. Check the highlighted fields.", and the
First paragraph field shows no error.
8. **Error, Examples-driven** — from Submitting, (a) resolve `request` rejecting with
`ApiError(500)`, (b) resolve it rejecting with `NetworkError`; in each, assert both field values
are kept, the alert reads "Couldn't create the article. Your text is still here. Try again in a
moment.", and neither field shows an error.
9. **Retry after Error succeeds** — from the Error state (500), click "Create draft" again; assert
`request` is called a second time with the same JSON body; resolve 201 with the fixture; assert
the modal closes, the router navigated to `/article/42/edit`, and the same "Draft created" toast
(id "article-created-42") is raised.
10. **Cancel discards** — fill both fields, click "Cancel"; assert the modal closes immediately
with no confirmation UI, and `request` was never called.
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/screens/new-article.md` and `docs/design/source/RenewCMS Screens
Back-office.dc.html`, group "New article". Run `task visual:check -- new-article` for each state
below and compare against the board frame; add each state's mock setup to `web/visual/states.ts`
first, reusing this slice's sample data (title "Choosing tiles for a small bathroom", first
paragraph "Small rooms show every grout line, so we start with the floor."). This screen has no
time-dependent content, so no fixed `now` is needed.
- [ ] Default: empty, focused Title with counter "0 / 200", empty First paragraph, help text on
both fields, "Cancel" / "Create draft".
- [ ] Validation: Title "Ti" with counter "2 / 200" in error and "Use at least 3 characters.";
First paragraph error "Add a first paragraph. It can be a single sentence."
- [ ] Submitting: both fields filled and disabled, confirm reads "Creating…" with its loading
state.
- [ ] Title rejected (400): both fields filled, Title error "Check this title and try again.",
alert "Something in the form isn't valid. Check the highlighted fields."
- [ ] Error: both fields filled, alert "Couldn't create the article. Your text is still here. Try
again in a moment.", no field-level error.
- [ ] Created: no board frame; verified by Vitest (toast copy + navigation).
- [ ] Keyboard/focus: Tab reaches Title (already focused on open) → First paragraph → Cancel →
Create draft; Esc closes the modal in every dismissible state and is blocked while
Submitting; the counter's live region announces only when its error/warn threshold is
crossed, not on every keystroke, per CharacterCounter's a11y spec.
- [ ] Motion: modal open/close follows the standard 240 ms timing and stops under reduced motion.
## Manual verification
- **Real POST /v1/article/ round trip against a running server** — Vitest only mocks `useApi`;
confirm the real 201/400/500 responses and that a genuinely-created article is retrievable at
`/article/{id}/edit` against `task dev` with a live SQLite file, since automated tests never
exercise the real Go handler or `CreateArticleUseCase`.
- **Toast placement and stacking on a live app** — confirm the "Draft created" toast's real
position, duration and stacking behavior (per docs/design/components.md's Toast rules) against
a running app, since Vitest only asserts that `useToast().add` was called with the right
arguments, not the rendered/animated toast region.
+146
View File
@@ -0,0 +1,146 @@
Feature: Nuxt UI shell foundation and router guards
As the maintainer of RenewCMS
I want the SPA to run on Nuxt UI instead of PrimeVue, with AuthShell and DashboardShell layouts,
an AppBoot skeleton, and a router guard that resolves and caches the session once per app load
So that every later screen slice can build on a themed, guarded shell instead of an unguarded router
with no layout
Background:
Given web/src/main.ts wires the app through the @nuxt/ui/vite plugin, and no longer imports
primevue, @primeuix/themes, primeicons or tailwindcss-primeui
And web/src/theme/ui.config.ts and web/src/assets/main.css hold the token files copied from
docs/design/source/tokens/ (ui.config.ts, renewcms.css), and public/favicon.svg is the file
copied from docs/design/source/brand/favicon.svg
And the router declares route meta.auth as one of "public", "session", "verified" or "any", and
meta.title for every verified route
And a global router.beforeEach guard runs on every navigation, and on the first navigation of the
app it resolves the session by calling GET /v1/auth/me once and caches the result (session or
"no session") in a session Pinia store; later navigations in the same app load reuse that
cached result instead of calling GET /v1/auth/me again
And /home, /article and /integration currently render only a trivial placeholder view inside
DashboardShell; later screen slices (Home, Articles, Integration) replace that placeholder
content with the real screens
Scenario: A public route never triggers a session check
Given the target route is /login, whose meta.auth is "public"
When the router navigates to it as the first navigation of the app
Then GET /v1/auth/me is not called
And AuthShell renders immediately, with no AppBoot skeleton shown
Scenario: The first navigation to a non-public route resolves the session and shows AppBoot meanwhile
Given the target route is /home, whose meta.auth is "verified"
And GET /v1/auth/me has not resolved yet
When the router navigates to /home as the first navigation of the app
Then DashboardShell renders with the AppBoot skeleton in place of the page content
When GET /v1/auth/me then resolves 200 with is_verified true
Then the AppBoot skeleton is replaced by the routed page's content
And the navigation is not redirected
Scenario: A second navigation reuses the cached session instead of calling GET /v1/auth/me again
Given GET /v1/auth/me has already resolved 200 with is_verified true during the first navigation
When the router navigates from /home to /article, whose meta.auth is also "verified"
Then GET /v1/auth/me is not called a second time
And no AppBoot skeleton is shown for this navigation
Scenario: A verified-only route admits a verified session
Given the target route is /article, whose meta.auth is "verified"
And GET /v1/auth/me resolves 200 with is_verified true
When the router navigates to /article
Then the navigation completes to /article
And there is no redirect
Scenario: A verified-only route redirects an unverified session to the pending page
Given the target route is /article, whose meta.auth is "verified"
And GET /v1/auth/me resolves 200 with is_verified false
When the router navigates to /article
Then the router redirects to /register/pending
And the navigation to /article does not complete
Scenario: A session-only route admits an unverified session
Given the target route is /register/pending, whose meta.auth is "session"
And GET /v1/auth/me resolves 200 with is_verified false
When the router navigates to /register/pending
Then the navigation completes to /register/pending
And there is no redirect
Scenario: No session redirects to login with the original path and an expired reason
Given the target route is /article/12/edit, whose meta.auth is "verified"
And GET /v1/auth/me resolves 401
When the router navigates to /article/12/edit
Then the router redirects to "/login?redirect=/article/12/edit&reason=expired"
And the navigation to /article/12/edit does not complete
Scenario Outline: The root path resolves to /home, /login or /register/pending depending on the session
Given GET /v1/auth/me resolves <response>
When the router navigates to /
Then the router redirects to <destination>
Examples:
| response | destination |
| 200 with is_verified true | /home |
| 401 | /login |
| 200 with is_verified false | /register/pending |
Scenario: A session check that fails with a server error renders the Generic error view instead of redirecting
Given the target route is /, whose meta.auth is "any"
And GET /v1/auth/me fails with a 500 response
When the router navigates to /
Then the router does not redirect anywhere
And the Generic error view renders in place, with breadcrumb "Home", title "Something went wrong",
text "This page couldn't load. Your saved work is safe. Try again, and if it keeps happening,
restart the RenewCMS server.", a primary action "Try again" and a secondary action "Go to Home"
When "Try again" is activated
Then GET /v1/auth/me is called again to re-run the session check
Scenario: A session check that fails with a network error on a non-root verified route also renders the Generic error view
Given the target route is /article, whose meta.auth is "verified"
And GET /v1/auth/me fails with a network error
When the router navigates to /article
Then the router does not redirect to /login or /register/pending
And DashboardShell renders the Generic error view in place of the AppBoot skeleton and the page
content, with title "Something went wrong" and a primary action "Try again"
When "Try again" is activated
Then GET /v1/auth/me is called again to re-run the session check
Scenario Outline: Each verified placeholder route renders inside DashboardShell with its own heading, breadcrumb and active nav item
Given GET /v1/auth/me resolves 200 with is_verified true
When the router navigates to <path>
Then the route renders inside DashboardShell
And the page heading is <title>
And the breadcrumb is <title>
And the nav item for <title> has aria-current="page"
Examples:
| path | title |
| /home | "Home" |
| /article | "Articles" |
| /integration | "Integration" |
Scenario: AuthShell renders its title as the page heading and sets the document title
Given a screen renders AuthShell with title "Log in" and no description
When AuthShell mounts
Then AuthShell renders an h1 containing "Log in"
And document.title is "Log in · RenewCMS"
Scenario: AuthShell renders its notice and footer slots
Given a screen renders AuthShell with a "notice" slot containing "Your session expired." and a
"footer" slot containing a "Forgot password?" link
When AuthShell mounts
Then the alert above the title contains "Your session expired."
And the footer area outside the card contains a "Forgot password?" link
Scenario: DashboardShell renders the three navigation destinations
Given the current route is /article
When DashboardShell mounts
Then the sidebar navigation lists exactly three destinations: "Home" linking to /home, "Articles"
linking to /article, and "Integration" linking to /integration
And the "Articles" destination has aria-current="page"
And neither "Home" nor "Integration" has aria-current set
Scenario: DashboardShell shows the offline banner when the browser reports offline
Given navigator.onLine is false
When DashboardShell mounts
Then an offline banner is shown
When navigator.onLine becomes true
Then the offline banner is hidden
+73
View File
@@ -0,0 +1,73 @@
# QA: Nuxt UI shell foundation and router guards
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi` / the `/v1/auth/me` call per case; no real backend, no httptest server.
1. **Public route skips session check** — mount the router at /login; assert the mocked
GET /v1/auth/me function is never invoked, and AuthShell is rendered with no AppBoot skeleton.
2. **AppBoot shown while GET /v1/auth/me is pending** — mount the router at /home with an
unresolved GET /v1/auth/me promise; assert the AppBoot skeleton (USkeleton) is present and the
route's own content is absent; resolve the promise with 200 is_verified true; assert the skeleton
is gone and the route's content is present.
3. **Session cached across navigations** — resolve GET /v1/auth/me once (200 is_verified true),
navigate /home → /article; assert the mock was called exactly once.
4. **Verified route + verified session** — mock 200 is_verified true, navigate to /article; assert
the router's current route is /article (no redirect).
5. **Verified route + unverified session** — mock 200 is_verified false, navigate to /article; assert
the router redirects to /register/pending.
6. **Session route + unverified session** — mock 200 is_verified false, navigate to
/register/pending; assert no redirect.
7. **No session** — mock a 401, navigate to /article/12/edit; assert redirect to
/login?redirect=/article/12/edit&reason=expired.
8. **Root path outcomes** — one test per Examples row: 200 is_verified true → /home, 401 → /login,
200 is_verified false → /register/pending.
9. **Boot failure at root renders the Generic error view** — mock GET /v1/auth/me rejecting with a
500, navigate to /; assert no redirect happens, and the Generic error view renders with title
"Something went wrong" and text "This page couldn't load. Your saved work is safe. Try again, and
if it keeps happening, restart the RenewCMS server."; click "Try again"; assert
GET /v1/auth/me is called a second time.
10. **Boot failure at a non-root verified route renders the Generic error view** — mock
GET /v1/auth/me rejecting with a network error, navigate to /article; assert no redirect to
/login or /register/pending happens, DashboardShell shows the Generic error view instead of
AppBoot or page content; click "Try again"; assert GET /v1/auth/me is called a second time.
11. **Placeholder verified routes wire up shell, heading, breadcrumb and active nav** — one test per
Examples row (/home → "Home", /article → "Articles", /integration → "Integration"): mock 200
is_verified true, navigate to the path; assert the route renders inside DashboardShell, the page
heading and breadcrumb both equal the expected title, and the corresponding nav item has
aria-current="page" while the other two do not.
12. **AuthShell renders title as h1 and sets document.title** — mount AuthShell with
title="Log in"; assert an h1 with text "Log in" and document.title === "Log in · RenewCMS".
13. **AuthShell notice/footer slots** — mount AuthShell with notice and footer slot content; assert
both render outside/above the card content as specified.
14. **DashboardShell nav + aria-current** — mount DashboardShell with current route /article; assert
3 links (Home→/home, Articles→/article, Integration→/integration), and only the Articles link
has aria-current="page".
15. **DashboardShell offline banner** — mock navigator.onLine false, mount DashboardShell; assert
the offline banner is visible; set navigator.onLine true and dispatch the 'online' event; assert
it hides.
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/source/RenewCMS Screens Back-office.dc.html`, groups "Boot" and "Global",
plus the components.md AuthShell/DashboardShell sections.
- [ ] AuthShell at desktop (sm+): 400px UPageCard variant="outline", centered, top offset 12vh,
light and dark.
- [ ] AuthShell below sm: card loses border, 20px padding fills screen, fields/buttons use xl size,
light and dark.
- [ ] AuthShell footer: UColorModeSelect size="sm" plus footer links render outside the card.
- [ ] DashboardShell desktop: sidebar with RenewCMS logo header, 3 nav items, UserMenu footer;
collapsing the sidebar leaves a 56px icon rail with tooltips on hover/focus.
- [ ] DashboardShell mobile (below lg): UDashboardSidebarToggle appears, sidebar becomes a
slideover, closes after navigating.
- [ ] DashboardShell active nav item uses the sand-fill/ink-text bend, not Nuxt UI's default
primary-tinted background.
- [ ] AppBoot skeleton visual match to the boot board, light and dark.
- [ ] Generic error view visual match to the Global board's "Generic error" state, desktop and
mobile, light and dark (breadcrumb "Home", title "Something went wrong", primary "Try again",
secondary "Go to Home").
- [ ] Keyboard: first Tab stop on a DashboardShell route is the "Skip to content" link; sidebar is
a `nav` labeled "Main"; collapse button exposes `aria-expanded`; AuthShell moves focus to its
h1 on route change; the Generic error view's title gets focus on render.
- [ ] Favicon renders as the copied favicon.svg mark in the browser tab, dark-aware.
+153
View File
@@ -0,0 +1,153 @@
Feature: Pending registration screen
As a logged-in user who has not yet verified their account
I want /register/pending to tell me where the verification link went, let me log out or cancel
my registration, and route me to the right destination no matter which of those calls
succeeds or fails
So that I always know what to do next while waiting for the emailed link, and a device that
verified elsewhere or a server error never strands me on the wrong screen
Background:
Given /register/pending is a "session" route (meta.auth "session"), rendered as PendingPage
inside AuthShell, per docs/design/routes.md; "session" only requires a logged-in account, so
both a verified and an unverified account can land on it without being redirected away by
the router guard
And PendingPage only calls DELETE /v1/auth/register and POST /v1/auth/logout
And RegisterView.vue, on a 200 from POST /v1/auth/register, writes the submitted email to
sessionStorage under the key "renewcms:pendingEmail" before navigating to /register/pending;
this is the only change this slice makes to RegisterView.vue
And on mount, PendingPage reads "renewcms:pendingEmail" from sessionStorage once: if present,
it renders the Default state with that email interpolated into the ResultPanel text; if
absent (for example, the user arrived by logging in as an unverified account rather than by
just registering), it renders the Email unknown state with the generic text
And in both cases the ResultPanel has tone "success", icon "i-lucide-mail-check", title "Check
your email", and two secondary actions, "Log out" and "Cancel registration" (color
"neutral", variant "outline"); the note line ("Opened it on another device? That works too;
you'll log in there first. Can't find it? Check your spam folder. If the link has expired,
cancel your registration and start again.") is rendered through ResultPanel's default slot
as a second paragraph below the description, per components.md's "Slot default for extras"
And "Log out" calls POST /v1/auth/logout, which per loginHandler.Logout always responds 200 and
clears the jwt cookie; on its response, PendingPage calls session.clear() and navigates to
/login, disabling both actions while the call is in flight
And "Cancel registration" opens ConfirmDialog (built by this slice; components.md documents it
but no file exists yet) via useOverlay().create(ConfirmDialog).open(), with title "Cancel
your registration?", description "Your account will be deleted. You can register again with
the same or a different email.", confirmLabel "Cancel registration", cancelLabel "Keep my
account" (components.md's "Other uses" line pairs title/description/confirmLabel/cancelLabel
in that order, so ConfirmDialog exposes a cancelLabel prop alongside confirmLabel even though
the compact props line only names confirmLabel), pendingLabel "Cancelling…", and tone
"danger"; its action prop performs the DELETE /v1/auth/register call
And while the dialog's action is in flight (Cancelling), both buttons are disabled, the confirm
button reads "Cancelling…", aria-busy is set, and the dialog cannot be dismissed, per
ConfirmDialog's general contract in components.md
And PendingPage's DELETE /v1/auth/register call passes { onForbidden: 'passthrough' } (a new
RequestOptions field on useApi, added by this slice, mirroring the existing onUnauthorized
field: type 'redirect' | 'passthrough', default 'redirect'; when 'passthrough' is given, the
403 branch in useApi's request() skips the "if session.session?.verified === false, push to
/register/pending" step entirely and always throws ApiError(403)) so PendingPage's own 403
handling below runs instead of the global handler bouncing it back to /register/pending
And on a 200 from DELETE /v1/auth/register, the action resolves, the dialog closes, the app
navigates to /register, and a toast (color "success", icon "i-lucide-circle-check", title
"Registration cancelled", description "You can create the owner account again.") is shown
And on a 403 from DELETE /v1/auth/register (user.ErrAlreadyVerified, since the account was
verified on another device while this tab still holds a stale unverified session), the
action first awaits session.retry() so the session store's cached verified flag is refreshed
to true — without this, the router guard's stale local state would immediately bounce a
navigation to /home straight back to /register/pending — then the dialog closes,
PendingPage replaces its panel with a ResultPanel (tone "success", icon
"i-lucide-shield-check", title "You're verified", text "Taking you to your dashboard…", no
actions) and shows a toast (color "info", icon "i-lucide-info", title "Your account is already
verified", description "There's nothing to cancel.") over it, exactly as the design board
draws this state, then navigates to /home after the same 800 ms Verify's Success uses
And on a 500 response or a request that never reaches the server, the action rejects, the
dialog stays open, and it shows the failure inline (per ConfirmDialog's general contract:
"the action runs inside the dialog, so errors show there instead of a toast"), re-enabling
both buttons so the user can retry or choose "Keep my account"
And a 401 from either call is handled entirely by useApi's existing global 401 handling
(redirect to /login?redirect=...&reason=expired); PendingPage adds no special case for it
Scenario: Default — the email is known from a just-completed registration
Given sessionStorage holds "renewcms:pendingEmail" set to "marta@renew.example"
When the user navigates to /register/pending
Then a ResultPanel is shown with tone "success" and icon "i-lucide-mail-check"
And its title is "Check your email"
And its text is "We sent a verification link to marta@renew.example. It expires in 2 hours."
And its note reads "Opened it on another device? That works too; you'll log in there first.
Can't find it? Check your spam folder. If the link has expired, cancel your registration and
start again."
And its actions are "Log out" and "Cancel registration", both secondary
Scenario: Email unknown — arrived by logging in as an unverified account
Given sessionStorage holds no "renewcms:pendingEmail" entry
When the user navigates to /register/pending
Then a ResultPanel is shown with tone "success" and icon "i-lucide-mail-check"
And its title is "Check your email"
And its text is "We sent a verification link to the email you registered with. It expires in 2
hours."
And its note is the same as the Default state's note
And its actions are "Log out" and "Cancel registration", both secondary
Scenario: Logging out
Given the user is on /register/pending
When the user activates "Log out"
Then both actions are disabled while POST /v1/auth/logout is in flight
When the response resolves 200
Then the session store is cleared
And the app navigates to /login
Scenario: Confirm cancel — the destructive dialog opens
Given the user is on /register/pending
When the user activates "Cancel registration"
Then a ConfirmDialog opens with title "Cancel your registration?", description "Your account
will be deleted. You can register again with the same or a different email.", a "Keep my
account" cancel button, a "Cancel registration" confirm button in tone "danger", role
"alertdialog", and focus starting on "Keep my account"
And the underlying ResultPanel is still shown behind it, unchanged
Scenario: Cancelling — the DELETE is in flight and the dialog can't be dismissed
Given the ConfirmDialog from "Confirm cancel" is open
When the user activates "Cancel registration" inside the dialog
Then both dialog buttons are disabled, the confirm button reads "Cancelling…", aria-busy is set
on the dialog, and it cannot be dismissed by Escape or an overlay click
And DELETE /v1/auth/register is sent with { onForbidden: 'passthrough' }
Scenario: Keep my account — the dialog is dismissed without deleting anything
Given the ConfirmDialog from "Confirm cancel" is open
When the user activates "Keep my account"
Then the dialog closes, emitting close(false)
And DELETE /v1/auth/register is never called
And the ResultPanel from before the dialog opened is still shown, unchanged
Scenario: Cancelled — 200 navigates to /register with a toast
Given the ConfirmDialog's action is in flight after the user confirmed
When DELETE /v1/auth/register resolves 200
Then the dialog closes
And the app navigates to /register
And a toast is shown with color "success", icon "i-lucide-circle-check", title "Registration
cancelled", description "You can create the owner account again."
Scenario: Already verified — 403 shows the verified panel and toast, then navigates to /home
Given the ConfirmDialog's action is in flight after the user confirmed
And the session store currently holds { verified: false }
When DELETE /v1/auth/register resolves 403 with the apperr.AlreadyVerifiedErrorMessage body
Then session.retry() is awaited before any navigation happens
And once it resolves the session store holds { verified: true }
And the dialog closes
And a ResultPanel is shown with tone "success", icon "i-lucide-shield-check", title "You're
verified", text "Taking you to your dashboard…", and no actions
And a toast is shown over it with color "info", icon "i-lucide-info", title "Your account is
already verified", description "There's nothing to cancel."
And after 800 ms the app navigates to /home
Scenario Outline: Cancel failed — the dialog stays open and shows the failure inline
Given the ConfirmDialog's action is in flight after the user confirmed
When DELETE /v1/auth/register <failure>
Then the dialog does not close
And the failure is shown inline in the dialog, not as a toast
And both dialog buttons are re-enabled so the user can retry "Cancel registration" or choose
"Keep my account"
Examples:
| failure |
| fails with a 500 response |
| never reaches the server |
+81
View File
@@ -0,0 +1,81 @@
# QA: Pending registration screen
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi` (`DELETE /v1/auth/register`, `POST /v1/auth/logout`) and `useToast`/`useOverlay` per
case; no real backend, no httptest server. Mount PendingPage inside AuthShell with a test router and
a test Pinia session store. Seed/clear `sessionStorage["renewcms:pendingEmail"]` per case.
1. **Default state** — set `sessionStorage["renewcms:pendingEmail"] = "marta@renew.example"`; mount
at /register/pending; assert a ResultPanel with tone "success", icon "i-lucide-mail-check", title
"Check your email", text "We sent a verification link to marta@renew.example. It expires in 2
hours.", the note paragraph rendered via the default slot, and two secondary actions "Log out"
and "Cancel registration".
2. **Email unknown state** — clear `sessionStorage["renewcms:pendingEmail"]`; mount at
/register/pending; assert the same ResultPanel shape with text "We sent a verification link to
the email you registered with. It expires in 2 hours." and the same note and actions.
3. **Logging out** — mount with an unresolved `POST /v1/auth/logout` mock; click "Log out"; assert
both actions are disabled; resolve the mock 200; assert the session store's `clear()` was called
and the router's current path is /login.
4. **Confirm cancel opens the dialog** — click "Cancel registration"; assert a ConfirmDialog renders
with title "Cancel your registration?", description "Your account will be deleted. You can
register again with the same or a different email.", buttons "Keep my account" and "Cancel
registration" (tone danger), `role="alertdialog"`, and initial focus on "Keep my account".
5. **Cancelling state** — with the dialog open, mock `DELETE /v1/auth/register` with an unresolved
promise, click "Cancel registration" inside the dialog; assert both dialog buttons are disabled,
the confirm button reads "Cancelling…", `aria-busy="true"` on the dialog, and the mocked call was
made with `{ onForbidden: 'passthrough' }`.
6. **Keep my account** — with the dialog open, click "Keep my account"; assert the dialog closes,
`DELETE /v1/auth/register` was never called, and the underlying ResultPanel is unchanged.
7. **Cancelled state** — confirm the dialog; mock `DELETE /v1/auth/register` resolving 200; assert
the dialog closes, the router's current path is /register, and a toast was raised with color
"success", icon "i-lucide-circle-check", title "Registration cancelled", description "You can
create the owner account again."
8. **Already verified state** — seed the session store with `{ verified: false }`, `status:'ready'`;
confirm the dialog; mock `DELETE /v1/auth/register` resolving 403; mock the session store's
`retry()` to resolve and set `session.session = { verified: true }`; assert `retry()` was awaited
before the navigation; assert the "You're verified" ResultPanel ("Taking you to your
dashboard…", no actions) is shown and a toast was raised with color "info", icon "i-lucide-info",
title "Your account is already verified", description "There's nothing to cancel." while the
path is not yet /home; then assert the router reaches /home after 800 ms with no second toast.
9. **Cancel failed state** — two sub-cases: (a) a 500 response, (b) a rejected fetch. In each,
confirm the dialog, resolve/reject the mock accordingly, and assert the dialog stays open, an
inline failure indicator is shown inside it (not a toast — assert no toast was raised), and both
dialog buttons are re-enabled.
10. **`useApi` onForbidden option (unit-level, alongside the existing suite in
`api_client_global_error_handling`'s spec file)** — seed `session = { verified: false }`; mock
fetch resolving 403; call `useApi().request('/v1/auth/register', { method: 'DELETE', onForbidden:
'passthrough' })`; assert the promise rejects with `ApiError` status 403; assert the router's
current route did not change (no redirect to /register/pending); assert the session store is
unchanged. This regresses nothing in the existing onForbidden-absent (default 'redirect')
scenarios already covered by `features/api_client_global_error_handling.feature`.
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Pending". Run
`task visual:check -- pending` for each state and compare against the board frame.
- [ ] Default state: icon tile, title, text with the interpolated email, note paragraph, "Log out"
and "Cancel registration" secondary buttons — matches the board exactly.
- [ ] Email unknown state: same layout with the generic text.
- [ ] Confirm cancel state: ConfirmDialog layout, button order (Keep my account left, Cancel
registration right, danger color), matches components.md's button-order rule.
- [ ] Cancelling state: disabled buttons, "Cancelling…" label, spinner/aria-busy styling.
- [ ] Cancelled state (transient, capture via visual test hook or a frozen mock): toast appearance
over the /register board.
- [ ] Already verified state: the "You're verified" ResultPanel with the info toast over it,
captured before the 800 ms redirect (the visual setup freezes timers with `paused: true`).
- [ ] Keyboard/focus: focus starts on "Keep my account" in the dialog and Enter does not confirm;
while cancelling, focus cannot leave the dialog; on the ResultPanel states, focus lands on the
title on mount; toasts are announced without stealing focus.
## Manual verification
- **Real DELETE /v1/auth/register and POST /v1/auth/logout against a running server** — Vitest only
mocks `useApi`; confirm the real 200/403 round-trips against `task dev` with a live SQLite file.
The endpoint's own status/body/cookie contract is already covered by
`features/cancel_pending_registration.qa.md`; this slice only re-verifies the screen consumes it
correctly.
- **Router-guard staleness fix in a real browser** — confirm that after the "Already verified" 403,
a hard reload of /home does not bounce back to /register/pending, since Vitest's fake session store
can't fully reproduce the router's `afterEach` re-check timing.
+112
View File
@@ -0,0 +1,112 @@
Feature: Register screen
As the first person to set up a fresh RenewCMS install
I want a /register screen that creates the single owner account via POST /v1/auth/register,
validates the same rules the API enforces before submitting, and tells me plainly when
someone already owns this site or the server failed
So that I can create my account once, land on the verification-pending page, and never see a
confusing or silent failure
Background:
Given /register is a public route (meta.auth "public") rendered as RegisterPage inside
AuthShell, per docs/design/routes.md
And RegisterPage only calls POST /v1/auth/register; it never calls GET /v1/auth/registration
And the form has a Username field (placeholder "e.g. marta", a live "N / 20" character
counter, help text "Letters and numbers, 3 to 20 characters. You'll use it to log in."), an
Email field (placeholder "you@example.com", help text "We'll send a verification link
here."), and a Password field (a password input with a show/hide toggle and a live
checklist item "At least 8 characters" that is unmet/error until the rule is satisfied, then
turns met/green)
And client-side validation runs on blur of each field and again on submit, before any request
is sent, using the same rules as the API: Username must be 3 to 20 characters (the API has
no character-class constraint beyond length, so the client checks length only, matching the
help text's length claim but not attempting to enforce "letters and numbers" client-side),
Email must be a well-formed email address, and Password must be at least 8 characters
And a failing Username shows "Use 3 to 20 characters."; a failing Email shows "Enter a full
email address, like name@example.com."; a failing Password leaves the checklist item in its
unmet/error state
And a valid submit calls POST /v1/auth/register with { username, email, password }
And on a 200 response, the jwt cookie is set by the server and the app navigates to
/register/pending without re-resolving the session first
And a 403 response means someone already verified an owner account (user.ErrRegistrationClosed
on the backend) and the whole form is replaced by a ResultPanel
And a 500 response, or a request that never reaches the server, is surfaced as a server/network
error; the fields keep whatever the user typed and the form re-enables
And a 400 response is not expected once client-side validation passes, since the client checks
the same rules as the API; it is out of scope for this slice
Scenario: Default — a fresh, empty form
When the user navigates to /register
Then the heading is "Create the owner account"
And the description is "This creates the single admin account for this site. Once it's
verified, registration closes."
And the Username field shows placeholder "e.g. marta" with no value and counter "0 / 20"
And the Email field shows placeholder "you@example.com" with no value
And the Password field shows no value, with a show/hide toggle and the checklist item
"At least 8 characters" in its unmet state
And the submit button reads "Create account"
And the footer reads "Already set up?" with a "Log in" link to /login
Scenario: Validation errors — checked on blur and on submit
Given the user is on /register
When the user types "ma" into Username and blurs the field
Then the Username counter reads "2 / 20" in its error state and the error "Use 3 to 20
characters." is shown
When the user types "marta@renew" into Email and blurs the field
Then the Email field shows the error "Enter a full email address, like name@example.com."
When the user types "lime" into Password and blurs the field
Then the checklist item "At least 8 characters" stays in its error state
When the user submits with these same values
Then POST /v1/auth/register is never called
And all three errors from above are still shown
Scenario: Filled, valid — every rule is met
Given the user is on /register
When the user types "marta" into Username, "marta@renew.example" into Email, and
"limewash2026" into Password, and reveals the password with the show/hide toggle
Then the Username counter reads "5 / 20" with no error, and the help text "Letters and
numbers, 3 to 20 characters. You'll use it to log in." is shown
And the Email field shows no error, with the help text "We'll send a verification link here."
And the Password field shows "limewash2026" as plain text and the checklist item "At least 8
characters" turns to its met/green state
And the submit button reads "Create account" and is enabled
Scenario: Submitting — fields and button are disabled while the request is in flight
Given the user is on /register with Username "marta", Email "marta@renew.example", and
Password "limewash2026"
When the user submits and POST /v1/auth/register has not yet resolved
Then the Username, Email and Password fields are disabled
And the submit button reads "Creating account…" and shows a loading state
Scenario: Registration closed — the form is replaced by a ResultPanel
Given the user is on /register with Username "marta", Email "marta@renew.example", and
Password "limewash2026"
When the user submits and POST /v1/auth/register resolves 403
Then the form, its fields and the footer are no longer shown
And a ResultPanel with tone "info" and icon "user-x" is shown instead, with title "This site
already has an owner" and text "Log in instead."
And its single action is a primary button "Go to log in"
When the user activates "Go to log in"
Then the app navigates to /login
Scenario Outline: Server or network error — the form is kept and re-enabled
Given the user is on /register with Username "marta", Email "marta@renew.example", and
Password "limewash2026"
When the user submits and POST /v1/auth/register <failure>
Then an error alert reads "Couldn't create the account" with description "Something went
wrong on the server. Try again in a moment."
And the Username field still shows "marta", the Email field still shows "marta@renew.example",
and the Password field still shows "limewash2026"
And the Username, Email and Password fields are re-enabled
And the submit button reads "Create account" again, no longer loading
Examples:
| failure |
| fails with a 500 response |
| never reaches the server |
Scenario: A successful registration goes straight to the pending page
Given the user is on /register with Username "marta", Email "marta@renew.example", and
Password "limewash2026"
When the user submits and POST /v1/auth/register resolves 200 with a jwt cookie
Then the app navigates to /register/pending
+73
View File
@@ -0,0 +1,73 @@
# QA: Register screen
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi` (`POST /v1/auth/register` only — RegisterPage never calls
`GET /v1/auth/registration`); no real backend, no httptest server. Mount RegisterPage inside
AuthShell with a test router.
1. **Default state** — mount at /register; assert heading "Create the owner account", description
"This creates the single admin account for this site. Once it's verified, registration
closes.", Username placeholder "e.g. marta" with no value and counter "0 / 20", Email
placeholder "you@example.com" with no value, Password with no value and the checklist item
"At least 8 characters" in its unmet state, submit button "Create account", and the footer
"Already set up? Log in" linking to /login.
2. **Validation errors state** — mount at /register; set Username to "ma" and blur; assert counter
"2 / 20" in error and the error "Use 3 to 20 characters."; set Email to "marta@renew" and blur;
assert the error "Enter a full email address, like name@example.com."; set Password to "lime"
and blur; assert the checklist item stays in its error state; submit; assert the mocked
`POST /v1/auth/register` was never called and all three errors are still shown.
3. **Filled, valid state** — set Username "marta", Email "marta@renew.example", Password
"limewash2026"; toggle password visibility on; assert counter "5 / 20" with no error and the
help text restored, no Email error, Password rendered as plain text, the checklist item in its
met/green state, and the submit button enabled and reading "Create account".
4. **Submitting state** — set Username "marta", Email "marta@renew.example", Password
"limewash2026"; mock `POST /v1/auth/register` with an unresolved promise; submit; assert
Username, Email and Password are disabled and the submit button reads "Creating account…" with
a loading indicator.
5. **Registration closed state** — set the same valid values; mock `POST /v1/auth/register`
resolving 403; submit; assert the form and footer are no longer rendered; assert a ResultPanel
with title "This site already has an owner", text "Log in instead.", and a single primary
action "Go to log in"; click it and assert the router's current path is /login.
6. **Server or network error state** — two sub-cases: (a) a 500 response, (b) a rejected fetch
(network error). In each, set Username "marta", Email "marta@renew.example", Password
"limewash2026", submit, and assert an error alert with title "Couldn't create the account" and
description "Something went wrong on the server. Try again in a moment."; assert Username,
Email and Password still show their values; assert the three fields are re-enabled; assert the
submit button reads "Create account" and is no longer loading.
7. **Successful registration navigates to pending** — mock `POST /v1/auth/register` resolving 200;
set the same valid values; submit; assert the router's current path is /register/pending, and
that no `GET /v1/auth/me` call was made by RegisterPage itself.
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Register". Run
`task visual:check -- register` for each state and compare against the board frame.
- [ ] Default state: heading, description, Username with counter and help text, Email with help
text, Password with show/hide toggle and the unmet checklist item, "Create account" button,
"Already set up? Log in" footer — matches the board exactly.
- [ ] Validation errors state: Username counter and error, Email error, and the Password checklist
item's error styling all render inline per components.md.
- [ ] Filled, valid state: the checklist item's met/green transition and the revealed password
text render correctly.
- [ ] Submitting state: disabled field styling and the loading spinner in the submit button.
- [ ] Registration closed state: the ResultPanel (info tone, user-x icon tile, title, text, single
primary "Go to log in" action) fully replaces the form and footer.
- [ ] Server error state: error alert (circle-x icon, no close button per the Log in screen's
precedent) above the heading, fields retaining their values, form re-enabled.
- [ ] Keyboard/focus: Tab order is Username → Email → Password → show/hide toggle → submit; when
the Registration closed ResultPanel appears, focus moves to its title and it is
`role="status"`; the server error alert is `role="alert"` and does not steal focus from
wherever the user was; the checklist item's live state change is not visually jarring
(no layout shift) as it flips from unmet to met.
## Manual verification
- **Real POST /v1/auth/register against a running server** — Vitest only mocks `useApi`; confirm
the real 403 (`user.ErrRegistrationClosed`) and a real 200 round-trip with the actual jwt cookie
against `task dev` with a live SQLite file, since this slice's automated tests never exercise the
actual Go handler, the `CreateUser` use case, or cookie issuance.
- **Real navigation to /register/pending after a live registration** — confirm the route guard
reads the freshly set cookie correctly, since Vitest never resolves a real session against
GET /v1/auth/me on that navigation.
@@ -28,8 +28,8 @@ Feature: Registration is only open while no verified account exists, and it send
both injected from config.Settings.PublicURL in api/dependecyInjection.go; the handler no longer both injected from config.Settings.PublicURL in api/dependecyInjection.go; the handler no longer
sends mail and no longer holds a SendMailUseCase or PublicURL itself sends mail and no longer holds a SendMailUseCase or PublicURL itself
And after user.Repository.Create succeeds, CreateUserUseCase.CreateUser calls And after user.Repository.Create succeeds, CreateUserUseCase.CreateUser calls
mail.Repository.Send with the user's email, template "mailValidation" mail.Repository.Send with the user's email, template "verification"
(internal/infrastructure/mailer/templates/mailValidation.html, which renders a link from (internal/infrastructure/mailer/templates/verification.html, which renders a link from
"{{.Host}}/register/validate?c={{.VerificationCode}}"), and data whose Host is the injected public "{{.Host}}/register/validate?c={{.VerificationCode}}"), and data whose Host is the injected public
URL and whose VerificationCode is the raw (unhashed) code URL and whose VerificationCode is the raw (unhashed) code
And if mail.Repository.Send returns an error, CreateUserUseCase.CreateUser deletes the And if mail.Repository.Send returns an error, CreateUserUseCase.CreateUser deletes the
@@ -45,7 +45,7 @@ Feature: Registration is only open while no verified account exists, and it send
email "alice@example.com" email "alice@example.com"
Then the response status is 200 Then the response status is 200
And a jwt cookie is set And a jwt cookie is set
And exactly one email was sent to "alice@example.com" using template "mailValidation" And exactly one email was sent to "alice@example.com" using template "verification"
And that email's data has VerificationCode equal to the raw code returned by CreateUser, not its And that email's data has VerificationCode equal to the raw code returned by CreateUser, not its
stored hash stored hash
@@ -100,7 +100,7 @@ Feature: Registration is only open while no verified account exists, and it send
When CreateUser is called with username "carol", password "hunter2example12", When CreateUser is called with username "carol", password "hunter2example12",
email "carol@example.com" email "carol@example.com"
Then the call succeeds and returns a user with IsVerified false Then the call succeeds and returns a user with IsVerified false
And exactly one email was sent to "carol@example.com" using template "mailValidation" And exactly one email was sent to "carol@example.com" using template "verification"
Scenario: The create-user use case rolls back the user when the mail repository fails Scenario: The create-user use case rolls back the user when the mail repository fails
Given an empty user.Repository fake Given an empty user.Repository fake
+5 -5
View File
@@ -39,8 +39,8 @@ Feature: Anyone can request a password reset, and the response never reveals whe
most recently requested code and expiration are ever valid; a user who requests a reset twice most recently requested code and expiration are ever valid; a user who requests a reset twice
can only use the second code, and the first one no longer matches once overwritten can only use the second code, and the first one no longer matches once overwritten
And after storing succeeds, RequestPasswordReset calls mail.Repository.Send with the user's And after storing succeeds, RequestPasswordReset calls mail.Repository.Send with the user's
email, template "passwordReset" (the already-existing email, template "password-reset" (the already-existing
internal/infrastructure/mailer/templates/passwordReset.html, which renders a link from internal/infrastructure/mailer/templates/password-reset.html, which renders a link from
"{{.Host}}/register/reset/validate?c={{.VerificationCode}}&email={{.Email}}"), and a "{{.Host}}/register/reset/validate?c={{.VerificationCode}}&email={{.Email}}"), and a
MailPasswordResetData value whose Host is the injected public URL, whose VerificationCode is MailPasswordResetData value whose Host is the injected public URL, whose VerificationCode is
the raw (unhashed) code, and whose Email is the user's email the raw (unhashed) code, and whose Email is the user's email
@@ -69,7 +69,7 @@ Feature: Anyone can request a password reset, and the response never reveals whe
When the client sends POST /v1/auth/password-reset with body {"email": "alice@example.com"} When the client sends POST /v1/auth/password-reset with body {"email": "alice@example.com"}
Then the response status is 200 Then the response status is 200
And the response body is {"message": "If an account exists for that email, a password reset link has been sent."} And the response body is {"message": "If an account exists for that email, a password reset link has been sent."}
And exactly one email was sent to "alice@example.com" using template "passwordReset" And exactly one email was sent to "alice@example.com" using template "password-reset"
And that email's data has VerificationCode equal to the raw code stored in the repository's And that email's data has VerificationCode equal to the raw code stored in the repository's
hashed PasswordResetCode for alice, not the hash itself, and Email equal to "alice@example.com" hashed PasswordResetCode for alice, not the hash itself, and Email equal to "alice@example.com"
@@ -116,7 +116,7 @@ Feature: Anyone can request a password reset, and the response never reveals whe
When the client sends POST /v1/auth/password-reset with body {"email": "bob@example.com"} When the client sends POST /v1/auth/password-reset with body {"email": "bob@example.com"}
Then the response status is 200 Then the response status is 200
And the response body is {"message": "If an account exists for that email, a password reset link has been sent."} And the response body is {"message": "If an account exists for that email, a password reset link has been sent."}
And exactly one email was sent to "bob@example.com" using template "passwordReset" And exactly one email was sent to "bob@example.com" using template "password-reset"
Scenario Outline: A malformed request is still rejected, since this is not an enumeration concern Scenario Outline: A malformed request is still rejected, since this is not an enumeration concern
When the client sends POST /v1/auth/password-reset with body <body> When the client sends POST /v1/auth/password-reset with body <body>
@@ -136,7 +136,7 @@ Feature: Anyone can request a password reset, and the response never reveals whe
Then the call returns nil Then the call returns nil
And the fake repository's UpdatePasswordResetCode method was invoked exactly once with userId 3, And the fake repository's UpdatePasswordResetCode method was invoked exactly once with userId 3,
a hashed code, and an expiration within 5 seconds of time.Now().Add(1 * time.Hour) a hashed code, and an expiration within 5 seconds of time.Now().Add(1 * time.Hour)
And exactly one email was sent to "alice@example.com" using template "passwordReset" whose data And exactly one email was sent to "alice@example.com" using template "password-reset" whose data
carries the same raw code that was hashed, and Host "https://cms.example.com" carries the same raw code that was hashed, and Host "https://cms.example.com"
Scenario: The use case swallows an unknown email and returns nil without touching the repository or mailer Scenario: The use case swallows an unknown email and returns nil without touching the repository or mailer
+10 -9
View File
@@ -15,13 +15,13 @@ Feature: Runtime settings are read once at startup
CORS_ALLOWED_ORIGINS on every router build, CORS_ALLOWED_ORIGINS on every router build,
api/handlers/auth.shouldCookieBeSecure reads ENVIRONMENT on every api/handlers/auth.shouldCookieBeSecure reads ENVIRONMENT on every
Login/Register/Logout call, internal/infrastructure/mailer.MailRepository.Send Login/Register/Logout call, internal/infrastructure/mailer.MailRepository.Send
reads SMTP_EMAIL, SMTP_PASSWORD, SMTP_HOST and SMTP_PORT on every call, reads SMTP_LOGIN, SMTP_SENDER, SMTP_PASSWORD, SMTP_HOST and SMTP_PORT
and internal/infrastructure/persistence.ImageRepository.Create and on every call, and internal/infrastructure/persistence.ImageRepository.Create
.Delete read UPLOAD_DIR on every call and .Delete read UPLOAD_DIR on every call
And this slice introduces internal/infrastructure/config.Settings, a And this slice introduces internal/infrastructure/config.Settings, a
plain struct with fields Environment, CorsAllowedOrigins ([]string), plain struct with fields Environment, CorsAllowedOrigins ([]string),
PublicURL, UploadDir, JWTSecret, DBFile, Port and an Smtp sub-struct PublicURL, UploadDir, JWTSecret, DBFile, Port and an Smtp sub-struct
(Email, Password, Host, Port) (Login, Sender, Password, Host, Port)
And cmd/server/main.go's initEnvVariables still loads the .env file and And cmd/server/main.go's initEnvVariables still loads the .env file and
applies the same defaults and required-var checks as before, then applies the same defaults and required-var checks as before, then
builds exactly one config.Settings value from the process environment, builds exactly one config.Settings value from the process environment,
@@ -34,9 +34,9 @@ Feature: Runtime settings are read once at startup
the upload dir, and the router's logging and CORS setup receive the the upload dir, and the router's logging and CORS setup receive the
environment string and the CORS origins list as parameters environment string and the CORS origins list as parameters
And after this slice, os.Getenv (and os.LookupEnv) for ENVIRONMENT, And after this slice, os.Getenv (and os.LookupEnv) for ENVIRONMENT,
CORS_ALLOWED_ORIGINS, SMTP_EMAIL, SMTP_PASSWORD, SMTP_HOST, SMTP_PORT CORS_ALLOWED_ORIGINS, SMTP_LOGIN, SMTP_SENDER, SMTP_PASSWORD,
and UPLOAD_DIR appears only inside cmd/server/main.go and SMTP_HOST, SMTP_PORT and UPLOAD_DIR appears only inside
internal/infrastructure/config cmd/server/main.go and internal/infrastructure/config
And a component built from a config.Settings value keeps behaving And a component built from a config.Settings value keeps behaving
according to the values it was built with even if the process according to the values it was built with even if the process
environment changes afterwards, because it holds the captured values, environment changes afterwards, because it holds the captured values,
@@ -109,8 +109,9 @@ Feature: Runtime settings are read once at startup
Secure attribute set to true Secure attribute set to true
Scenario: The mailer is constructed from injected SMTP settings, not the environment Scenario: The mailer is constructed from injected SMTP settings, not the environment
Given a config.Settings value with Smtp.Email "notify@example.com", Given a config.Settings value with Smtp.Login "smtp-login@example.com",
Smtp.Host "smtp.example.com" and Smtp.Port 587 Smtp.Sender "notify@example.com", Smtp.Host "smtp.example.com" and
Smtp.Port 587
And internal/infrastructure/mailer.NewMailRepository is called with that And internal/infrastructure/mailer.NewMailRepository is called with that
Smtp settings value and no SMTP_* environment variable set Smtp settings value and no SMTP_* environment variable set
Then constructing the MailRepository does not read any SMTP_* Then constructing the MailRepository does not read any SMTP_*
+6 -4
View File
@@ -83,9 +83,10 @@ Package: `test` (black-box).
constructor signature changes. constructor signature changes.
## Mailer ## Mailer
16. Construct `mailer.NewMailRepository(config.SmtpSettings{Email: 16. Construct `mailer.NewMailRepository(config.SmtpSettings{Login:
"notify@example.com", Host: "smtp.example.com", Port: 587, Password: "smtp-login@example.com", Sender: "notify@example.com", Host:
"unused-in-test"})` with no `SMTP_*` environment variable set (do not "smtp.example.com", Port: 587, Password: "unused-in-test"})` with no
`SMTP_*` environment variable set (do not
call `t.Setenv` for any `SMTP_*` name in this test). Assert construction call `t.Setenv` for any `SMTP_*` name in this test). Assert construction
does not panic and does not error — this is the only automated does not panic and does not error — this is the only automated
assertion available, since exercising `Send` requires a real SMTP dialog assertion available, since exercising `Send` requires a real SMTP dialog
@@ -95,7 +96,8 @@ Package: `test` (black-box).
## Manual verification (not exercised by the Go test) ## Manual verification (not exercised by the Go test)
- Real SMTP send: run `task dev` (or a built binary) with a real `.env` - Real SMTP send: run `task dev` (or a built binary) with a real `.env`
populated with SMTP_EMAIL/SMTP_PASSWORD/SMTP_HOST/SMTP_PORT, trigger a populated with SMTP_LOGIN/SMTP_SENDER/SMTP_PASSWORD/SMTP_HOST/SMTP_PORT,
trigger a
flow that calls `SendMailUseCase.SendMail`, and confirm the message flow that calls `SendMailUseCase.SendMail`, and confirm the message
actually leaves via the injected host/port/credentials, and that changing actually leaves via the injected host/port/credentials, and that changing
those env vars requires a process restart to take effect (there is no those env vars requires a process restart to take effect (there is no
@@ -0,0 +1,114 @@
Feature: The two transactional email templates are replaced by the design's ready-to-use files
As the maintainer of RenewCMS
I want the verification and password-reset emails to use the exact Go html/template files from
docs/design/source/emails/, renamed to match the design's naming, instead of the old ad hoc
mailValidation.html and passwordReset.html files
So that account-verification and password-reset emails match the visual spec (bulletproof button,
preheader, dark-mode overrides), and the template name used at the call site reflects the file it
now loads
Background:
Given internal/infrastructure/mailer/templates/mailValidation.html is removed and replaced by
internal/infrastructure/mailer/templates/verification.html, copied verbatim from
docs/design/source/emails/verification.html, still driven by a Go struct with a Host field and
a VerificationCode field (the existing MailValidationData shape in CreateUser.go is unchanged)
And internal/infrastructure/mailer/templates/passwordReset.html is removed and replaced by
internal/infrastructure/mailer/templates/password-reset.html, copied verbatim from
docs/design/source/emails/password-reset.html, still driven by a Go struct with a Host field, a
VerificationCode field and an Email field (the existing MailPasswordResetData shape in
RequestPasswordReset.go is unchanged)
And MailRepository.Send (internal/infrastructure/mailer/mailRepository.go) is unchanged code: it
already loads "templates/" + templateName + ".html" via template.ParseFS, so it now resolves
the templateName "verification" to the new verification.html file and "password-reset" to the
new password-reset.html file, purely because of the rename, with no change to Send's own logic
And CreateUserUseCase.CreateUser (internal/infrastructure/useCases/CreateUser.go) now calls
mail.Repository.Send with template "verification" instead of the old "mailValidation"; nothing
else about that call changes: the receiver address, and the Host/VerificationCode data, are as
before
And RequestPasswordResetUseCase.RequestPasswordReset
(internal/infrastructure/useCases/RequestPasswordReset.go) now calls mail.Repository.Send with
template "password-reset" instead of the old "passwordReset"; nothing else about that call
changes: the receiver address, and the Host/VerificationCode/Email data, are as before
And the verification email's link is still built as
"{{.Host}}/register/validate?c={{.VerificationCode}}" and the password-reset email's link is
still built as
"{{.Host}}/register/reset/validate?c={{.VerificationCode}}&email={{.Email}}", matching both the
old templates and the use cases that populate them, so no handler, use case or route changes
as a result of this slice
And MailRepository (internal/infrastructure/mailer/mailRepository.go) gains an unexported render
step, e.g. render(templateName string, data any) (subject string, body string, err error),
that Send calls before dialing; render selects the subject by templateName ("Verify your
RenewCMS account" for "verification", "Reset your RenewCMS password" for "password-reset",
matching docs/design/emails.md's suggested subjects exactly) and parses/executes the
corresponding templates/<templateName>.html file, returning its rendered body; Send then uses
that subject and body to build and dial the message exactly as before, and any ParseFS or
Execute error from render is returned by Send unchanged, with the same log.Println call sites
as today
And an unrecognized templateName still reaches template.ParseFS, which fails to find
"templates/<templateName>.html" and returns its usual error; render does not special-case
unknown names beyond the two known subjects, so an unknown templateName's subject falls back to
the same literal "RenewCMS | Action required" used before this slice, and Send still returns
the ParseFS error without attempting to dial
And the two feature files that already assert a template-name string literal,
registration_gate_and_verification_email.feature and request_password_reset.feature, are
updated in this same slice so every occurrence of "mailValidation" reads "verification" and
every occurrence of "passwordReset" reads "password-reset"; their behaviour is otherwise
unchanged
Scenario: Registering a user sends the verification email under its new template name
Given the user repository is empty
When the client sends POST /v1/auth/register with username "alice", password "hunter2example12",
email "alice@example.com"
Then the response status is 200
And exactly one email was sent to "alice@example.com" using template "verification"
And that email's data has Host and VerificationCode populated exactly as before this slice
Scenario: Requesting a password reset sends the reset email under its new template name
Given the user repository contains a user "alice" with email "alice@example.com"
When the client sends POST /v1/auth/password-reset with body {"email": "alice@example.com"}
Then the response status is 200
And exactly one email was sent to "alice@example.com" using template "password-reset"
And that email's data has Host, VerificationCode and Email populated exactly as before this slice
Scenario: render() produces the verification email's subject, link and copy
Given a real *mailer.MailRepository
When its render step is called with templateName "verification" and data whose Host is
"https://cms.example.com" and whose VerificationCode is "abc123"
Then the returned subject is "Verify your RenewCMS account"
And the returned body contains the link "https://cms.example.com/register/validate?c=abc123"
And the returned body contains the button text "Verify my account"
And the returned body contains the text "This link expires in 2 hours."
Scenario: render() produces the password-reset email's subject, link and copy
Given a real *mailer.MailRepository
When its render step is called with templateName "password-reset" and data whose Host is
"https://cms.example.com", whose VerificationCode is "abc123" and whose Email is
"alice@example.com"
Then the returned subject is "Reset your RenewCMS password"
And the returned body contains the link
"https://cms.example.com/register/reset/validate?c=abc123&email=alice%40example.com"
And the returned body contains the button text "Choose a new password"
And the returned body contains the text "This link expires in 1 hour and works once."
Scenario: render() falls back to the old default subject for an unrecognized template name
Given a real *mailer.MailRepository
When its render step is called with templateName "does-not-exist" and any data
Then the returned subject is "RenewCMS | Action required"
And an error is returned because templates/does-not-exist.html cannot be found
And the returned body is empty
Scenario: A template execution failure is returned without sending
Given a real *mailer.MailRepository
And data that causes Execute to fail for a known templateName (e.g. a type without the fields
the template references)
When its render step is called with that templateName and that data
Then render returns an error and MailRepository.Send never reaches DialAndSend
Scenario Outline: The old template files no longer exist under their previous names
Then the file internal/infrastructure/mailer/templates/<old file> does not exist on disk
Examples:
| old file |
| mailValidation.html |
| passwordReset.html |
@@ -0,0 +1,71 @@
# QA procedure: transactional email templates
## Automated (Go, `test/`)
This slice touches a use-case call site (a string literal) and two static template files; it has
no new HTTP surface of its own, so no new handler/router wiring is exercised here. Reuse the
existing fakes already in `test/` (`mockMailRepo` from `test/mail_test.go`, the fake `user.Repository`
used by the registration and password-reset tests) rather than building new ones.
1. **Registration sends "verification".**
In the existing registration test file (or its replacement after this slice), build
`CreateUserUseCase` over an empty fake `user.Repository`, a real hasher, and `mockMailRepo`, call
`CreateUser` with username `alice`, password `hunter2example12`, email `alice@example.com`, assert
the call succeeds, then assert `mockMailRepo.SentEmails` has exactly one entry whose `Template`
field equals `"verification"` (not `"mailValidation"`).
2. **Password reset sends "password-reset".**
Build `RequestPasswordResetUseCase` over a fake `user.Repository` seeded with a user whose email is
`alice@example.com`, a real hasher, `mockMailRepo` and a public URL, call `RequestPasswordReset("alice@example.com")`,
assert it returns `nil`, then assert `mockMailRepo.SentEmails` has exactly one entry whose
`Template` field equals `"password-reset"` (not `"passwordReset"`).
3. **render() produces the right subject, link and copy for each known template.**
Construct a real `mailer.NewMailRepository(config.SmtpSettings{})` (SMTP fields are irrelevant
since `DialAndSend` is never called here) and call its render step directly (test lives in
package `test`, so this exercises the exported behavior surface of a real `*mailer.MailRepository`
without opening a network connection):
- `render("verification", <data with Host "https://cms.example.com", VerificationCode "abc123">)`
→ subject `"Verify your RenewCMS account"`, body contains
`https://cms.example.com/register/validate?c=abc123`, `Verify my account`,
`This link expires in 2 hours.`
- `render("password-reset", <data with Host "https://cms.example.com", VerificationCode "abc123",
Email "alice@example.com">)` → subject `"Reset your RenewCMS password"`, body contains
`https://cms.example.com/register/reset/validate?c=abc123&email=alice%40example.com`,
`Choose a new password`, `This link expires in 1 hour and works once.`
(`html/template` auto-escapes `Email` in the query string, turning `@` into `%40`; assert the
escaped form, matching what `MailRepository.Send` will actually produce.)
3b. **render() fallback and failure paths.**
- `render("does-not-exist", nil)` returns an error (template file not found) and the returned
subject is still the old default `"RenewCMS | Action required"`, with an empty body.
- A `render(<known templateName>, <data that fails Execute>)` call returns an error; assert
`MailRepository.Send` with the same inputs returns an error too and never gets to
`DialAndSend` (no network dependency needed to prove this: a failing `render` return is
sufficient to prove `Send` short-circuits before dialing, since `Send`'s existing structure
calls render, then returns immediately on its error).
4. **Old template files are gone.**
`os.Stat("internal/infrastructure/mailer/templates/mailValidation.html")` and the `passwordReset.html`
equivalent each return an error satisfying `os.IsNotExist`.
5. **Existing slice tests still pass with updated literals.**
Run the full suite (`task test`) and confirm the tests backing
`registration_gate_and_verification_email.feature` and `request_password_reset.feature` still pass
now that their scenario text and any Go assertions inside their test files use `"verification"` and
`"password-reset"` instead of the old strings. If those test files still contain the old literals
as Go string constants (not just prose in the `.feature` files), they need the same rename; point
this out to `coder` rather than silently doing a code edit here.
## Manual verification (not covered by the automated Go tests above)
- **Rendered appearance in an actual mail client.** The bulletproof button (table-cell background +
padded link), the hidden preheader text, and the `prefers-color-scheme: dark` overrides can only be
judged by opening the rendered HTML in a real or preview mail client (or the board file
`docs/design/source/RenewCMS Emails.dc.html` next to a manually triggered send through a real SMTP
server) — this is not something `os.ReadFile` + `html/template.Execute` can verify. Confirm both
light and dark rendering for both templates.
- **Real SMTP delivery end to end.** Sending an actual email via `SMTP_HOST`/`SMTP_PORT` and eyeballing
the arrived message (subject line, sender name "RenewCMS <...>", clickable button, monospace
fallback URL) is a manual item; nothing here exercises `gomail.Dialer.DialAndSend`.
+203
View File
@@ -0,0 +1,203 @@
Feature: Verify account screen
As a user who just clicked the verification link in their email
I want /register/validate?c=CODE to submit that code automatically, land me on my dashboard once
it's accepted, tell me plainly when it's wrong or expired, and let me log in inline if I opened
the link somewhere I'm not logged in
So that verifying my account is a single click from my inbox, whatever device or session state I
open the link in
Background:
Given /register/validate is a "public" route (meta.auth "public"), rendered as VerifyPage
inside AuthShell, per docs/design/routes.md; the router's beforeEach never runs session.check
for a public route, so VerifyPage starts with no assumption about the session
And VerifyPage only calls POST /v1/auth/verify, DELETE /v1/auth/register, and POST
/v1/auth/logout (and, inline, POST /v1/auth/login on a 401)
And on mount, VerifyPage reads the "c" query parameter once: if it is absent or an empty
string, no request is ever made and the Missing code state renders immediately; otherwise the
Verifying state renders and POST /v1/auth/verify is called immediately with { code: c }
And every call to POST /v1/auth/verify (the initial one and the post-login retry) passes
{ onUnauthorized: 'passthrough', onForbidden: 'passthrough' } (the same onForbidden
RequestOptions field added to useApi by features/pending_screen.feature, plus the existing
onUnauthorized field used the same way getMe() already uses it), so a 401 renders the inline
log-in state instead of the global redirect to /login, and a 403 renders the Invalid-or-
expired state instead of the global "unverified session" redirect
And Verifying and No-session-retrying both render the same full-card spinner: a centered
"i-lucide-loader-2" icon with a spin animation, the title "Verifying your account…" and the
line "This only takes a moment.", as the design board's AuthCard spinner draws it,
role="status", replacing AuthShell's whole card body with no title header shown
And on a 200, VerifyPage shows a ResultPanel with tone "success", icon "i-lucide-shield-check",
title "Your account is verified", text "Taking you to your dashboard…", and no actions, while
a toast (color "success", icon "i-lucide-circle-check", title "Your account is verified",
description "Welcome to RenewCMS.") is shown immediately over that panel, as the design board
draws it, while concurrently awaiting session.retry() (needed so /home's route guard sees the
now-verified session instead of whatever stale state this tab last cached) and an 800 ms
timer; once both settle, the app navigates to /home
And on a 403 (user.ErrInvalidVerificationCode or user.ErrVerificationCodeExpired), VerifyPage
shows a ResultPanel with tone "error", icon "i-lucide-link-2-off", title "This verification
link doesn't work", text "It may have expired (links last 2 hours) or already been used.",
and a note (rendered via the default slot) "Cancel your registration and start over to get a
new link.", with a primary action "Cancel registration and start over" and a secondary action
"Log out"; unlike Pending's cancel flow, this button opens no ConfirmDialog (verify.md's state
list has no confirm step for it) and calls DELETE /v1/auth/register directly, passing
{ onForbidden: 'passthrough' } for the same reason Pending's own DELETE call does
And "Cancel registration and start over" resolving 200 navigates to /register and shows the
same toast Pending's own Cancelled state shows (color "success", icon "i-lucide-circle-check",
title "Registration cancelled", description "You can create the owner account again."),
reusing that copy since it is the same underlying event
And "Cancel registration and start over" failing — with a 403 (already verified elsewhere, so
there is nothing left to cancel), a 500, or a request that never reaches the server — shows an
alert on the same ResultPanel screen via AuthShell's #notice slot, reusing Register's existing
server-error pattern: color "error", icon "i-lucide-circle-x", title "Couldn't cancel
registration", description "Something went wrong on the server. Try again in a moment.",
variant "soft", not closable, role "alert"; the ResultPanel and its two actions stay exactly
as they were so the user can try again or choose "Log out" instead; no redirect happens for
the 403 case here, unlike Pending's own already-verified handling
And "Log out" (on the Invalid-or-expired state) behaves exactly as it does on Pending: it calls
POST /v1/auth/logout, which per loginHandler.Logout always responds 200 and clears the jwt
cookie, then VerifyPage calls session.clear() and navigates to /login, disabling both actions
while the call is in flight
And on a 401, VerifyPage replaces the spinner with an inline log-in form: AuthShell title "Log
in to finish verifying", description "You opened this link somewhere you're not logged in.
Log in here and we'll verify your account right after.", a Username field (placeholder "Your
username") and a Password field (password input with a show/hide toggle), and a submit button
"Log in and verify"; there is no "Forgot password?" hint and no registration-open footer,
unlike LoginView
And the inline form's submit button is enabled, as the design board draws it; on submit,
LoginView's own validation runs first (Username under 3 characters shows "Use at least 3
characters.", an empty Password shows "Enter your password.", and nothing is sent), and a
valid form calls POST /v1/auth/login with { username, password }
And a 200 from that login call transitions to the No-session-retrying spinner and immediately
retries POST /v1/auth/verify with the same code and the same passthrough options, which then
follows the 200/403 handling above
And a 403 (wrong credentials), 429, or 500/network failure from that inline login call reuses
LoginView's existing alert copy for the same statuses (wrongCredentialsAlert, with LoginView's
exact title/description; tooManyAttemptsAlert with the same retryAfter countdown behavior as
LoginView; serverErrorAlert), shown via AuthShell's #notice slot, since verify.md specifies no
separate copy for this embedded form and states.md's HTTP-status table assigns this copy
generically to "Log in" contexts
And while that inline login is submitting, both fields are disabled and the submit button reads
"Logging in…" with a loading indicator, mirroring LoginView's submitting state
Scenario: Verifying — the code submits automatically on load
When the user navigates to "/register/validate?c=abc123"
Then the full-card spinner is shown with the text "Verifying your account…"
And POST /v1/auth/verify is called once, immediately, with body {"code": "abc123"}
Scenario: Success — 200 refreshes the session and navigates to /home with a toast
Given the user is on "/register/validate?c=abc123" in the Verifying state
When POST /v1/auth/verify resolves 200
Then a ResultPanel is shown with tone "success", icon "i-lucide-shield-check", title "Your
account is verified", text "Taking you to your dashboard…", and no actions
And a toast is shown over it with color "success", icon "i-lucide-circle-check", title "Your
account is verified", description "Welcome to RenewCMS."
And session.retry() is awaited
And after 800 ms and once session.retry() has resolved, the app navigates to /home
Scenario: Invalid or expired — 403 offers a way to start over
Given the user is on "/register/validate?c=abc123" in the Verifying state
When POST /v1/auth/verify resolves 403
Then a ResultPanel is shown with tone "error", icon "i-lucide-link-2-off", title "This
verification link doesn't work", text "It may have expired (links last 2 hours) or already
been used.", and note "Cancel your registration and start over to get a new link."
And its actions are a primary "Cancel registration and start over" and a secondary "Log out"
Scenario: Cancel registration and start over — success
Given the ResultPanel from "Invalid or expired" is shown
When the user activates "Cancel registration and start over"
Then DELETE /v1/auth/register is sent directly, without any confirmation dialog, with
{ onForbidden: 'passthrough' }
When it resolves 200
Then the app navigates to /register
And a toast is shown with color "success", icon "i-lucide-circle-check", title "Registration
cancelled", description "You can create the owner account again."
Scenario Outline: Cancel registration and start over — failure shows an alert, not a redirect
Given the ResultPanel from "Invalid or expired" is shown
When the user activates "Cancel registration and start over"
And DELETE /v1/auth/register <failure>
Then an alert is shown with color "error", icon "i-lucide-circle-x", title "Couldn't cancel
registration", description "Something went wrong on the server. Try again in a moment."
And the ResultPanel and its two actions ("Cancel registration and start over", "Log out") are
still shown, unchanged, so the user can retry or log out instead
And no navigation happens
Examples:
| failure |
| resolves 403 with the apperr.AlreadyVerifiedErrorMessage body |
| fails with a 500 response |
| never reaches the server |
Scenario: Log out from the Invalid-or-expired state
Given the ResultPanel from "Invalid or expired" is shown
When the user activates "Log out"
Then POST /v1/auth/logout is sent
When it resolves 200
Then the session store is cleared
And the app navigates to /login
Scenario: No session — the inline log-in form replaces the spinner
Given the user is on "/register/validate?c=abc123" in the Verifying state
When POST /v1/auth/verify resolves 401
Then the spinner is replaced by a form titled "Log in to finish verifying" with description
"You opened this link somewhere you're not logged in. Log in here and we'll verify your
account right after."
And it has a Username field (placeholder "Your username") and a Password field with a show/hide
toggle, and a submit button "Log in and verify"
And there is no "Forgot password?" link and no registration-open footer
Scenario: No session — empty fields are caught on submit, as on Log in
Given the inline log-in form from "No session" is shown
Then the submit button is enabled
When the user submits with both fields empty
Then Username shows "Use at least 3 characters." and Password shows "Enter your password."
And POST /v1/auth/login is not called
Scenario: No session — successful login retries verification with the same code
Given the inline log-in form from "No session" is shown on "/register/validate?c=abc123"
When the user submits Username "marta" and Password "limewash2026"
And POST /v1/auth/login resolves 200
Then the full-card spinner is shown again with the text "Verifying your account…"
And POST /v1/auth/verify is sent again, with body {"code": "abc123"} and the same passthrough
options as the first attempt
Scenario: No session — wrong credentials during the inline login
Given the inline log-in form from "No session" is shown
When the user submits Username "marta" and Password "wrong-password"
And POST /v1/auth/login resolves 403
Then an error alert is shown with title "That username and password don't match" and
description "Check both and try again. Passwords are case-sensitive."
And the Password field is cleared and refocused
And POST /v1/auth/verify is not retried
Scenario: No session — too many login attempts
Given the inline log-in form from "No session" is shown
When the user submits Username "marta" and Password "limewash2026"
And POST /v1/auth/login resolves 429 with Retry-After 30
Then an info alert is shown with title "Too many attempts" and description "For your security,
wait a moment before trying again."
And the submit button reads "Try again in 0:30" and it and both fields are disabled until the
countdown ends, mirroring LoginView
Scenario Outline: No session — server or network error during the inline login
Given the inline log-in form from "No session" is shown
When the user submits Username "marta" and Password "limewash2026"
And POST /v1/auth/login <failure>
Then an error alert is shown with title "Couldn't reach RenewCMS" and description "Check your
connection and try again."
And the fields keep their values and are re-enabled
Examples:
| failure |
| fails with a 500 response |
| never reaches the server |
Scenario: Missing code — no request is made
When the user navigates to "/register/validate" with no "c" query parameter
Then no request is made
And a ResultPanel is shown with tone "error", icon "i-lucide-link-2-off", title "This link is
incomplete", text "The verification code is missing. Open the link from your email again, or
copy the full address into your browser."
And its single action is a secondary "Go to log in"
When the user activates "Go to log in"
Then the app navigates to /login
+97
View File
@@ -0,0 +1,97 @@
# QA: Verify account screen
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi` (`POST /v1/auth/verify`, `POST /v1/auth/login`, `DELETE /v1/auth/register`,
`POST /v1/auth/logout`) and `useToast` per case; no real backend, no httptest server. Mount
VerifyPage inside AuthShell with a test router whose current route starts at the "c" query the case
needs; use fake timers for the 800 ms success delay and the 429 countdown.
1. **Verifying state** — mount at "/register/validate?c=abc123"; assert the full-card spinner title
"Verifying your account…" and that `POST /v1/auth/verify` was called exactly once with
`{ code: 'abc123' }` and `{ onUnauthorized: 'passthrough', onForbidden: 'passthrough' }`.
2. **Success state** — mock `POST /v1/auth/verify` resolving 200; mock the session store's
`retry()` to resolve; mount and let it settle; assert the ResultPanel (success, shield-check,
"Your account is verified", "Taking you to your dashboard…", no actions); assert a toast was
already raised with title "Your account is verified", description "Welcome to RenewCMS." while
the path is still /register/validate; advance by 800 ms; assert `retry()` was called and the
router's current path is /home, with no second toast.
3. **Invalid or expired state** — mock `POST /v1/auth/verify` resolving 403; assert the ResultPanel
(error, link-2-off, "This verification link doesn't work", the text and note above) with actions
"Cancel registration and start over" (primary) and "Log out" (secondary).
4. **Cancel registration and start over — success** — from state 3, mock
`DELETE /v1/auth/register` resolving 200; click the action; assert it was called with
`{ onForbidden: 'passthrough' }` and that no ConfirmDialog was opened; assert the router's current
path is /register and a toast was raised with title "Registration cancelled", description "You
can create the owner account again."
5. **Cancel registration and start over — failure shows an alert, not a redirect** — three
sub-cases from state 3: (a) `DELETE /v1/auth/register` resolves 403 (already verified elsewhere),
(b) resolves 500, (c) a rejected fetch. In each, click the action and assert an alert is shown
with color "error", icon "i-lucide-circle-x", title "Couldn't cancel registration", description
"Something went wrong on the server. Try again in a moment."; assert the ResultPanel and both of
its actions ("Cancel registration and start over", "Log out") are still rendered, unchanged;
assert the router's current path did not change (in particular, sub-case (a) must NOT navigate to
/home — unlike Pending's own already-verified handling, this screen never redirects on this 403).
6. **Log out from Invalid or expired** — from state 3, mock `POST /v1/auth/logout` resolving 200;
click "Log out"; assert the session store's `clear()` was called and the router's current path is
/login.
7. **No session state** — mock `POST /v1/auth/verify` resolving 401; assert the form (title "Log in
to finish verifying", description, Username placeholder "Your username", Password with show/hide,
submit "Log in and verify") and assert no "Forgot password?" link and no registration footer are
rendered.
8. **No session — empty fields are caught on submit** — from state 7, assert the submit button is
enabled; submit with both fields empty; assert "Use at least 3 characters." and "Enter your
password." are shown and `POST /v1/auth/login` was not called.
9. **No session — successful login retries** — from state 7, fill valid credentials; mock
`POST /v1/auth/login` resolving 200; mock a second `POST /v1/auth/verify` call; submit; assert the
spinner "Verifying your account…" reappears and the second `POST /v1/auth/verify` call was made
with the same code and the same passthrough options.
10. **No session — wrong credentials** — from state 7, mock `POST /v1/auth/login` resolving 403;
submit "marta" / "wrong-password"; assert the error alert ("That username and password don't
match", "Check both and try again. Passwords are case-sensitive."), the Password field cleared
and focused, and that `POST /v1/auth/verify` was not called again.
11. **No session — too many attempts** — from state 7, mock `POST /v1/auth/login` resolving 429 with
`Retry-After: 30`; submit; assert the info alert ("Too many attempts", "For your security, wait a
moment before trying again."), the submit button reading "Try again in 0:30" and disabled;
advance fake timers 30s; assert it re-enables.
12. **No session — server/network error** — two sub-cases (500, rejected fetch) from state 7; submit
valid-looking credentials; assert the error alert ("Couldn't reach RenewCMS", "Check your
connection and try again.") and that both fields keep their values and re-enable.
13. **Missing code state** — mount at "/register/validate" (no "c"); assert no request was made
(neither `POST /v1/auth/verify` nor `POST /v1/auth/login`); assert the ResultPanel (error,
link-2-off, "This link is incomplete", the text above) with a single secondary action "Go to log
in"; click it and assert the router's current path is /login.
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Verify". Run
`task visual:check -- verify` for each state and compare against the board frame.
- [ ] Verifying state: the full-card spinner (the only one in the product) matches the board's
layout exactly, with no title header shown.
- [ ] Success state: ResultPanel with no actions and the success toast over it, captured before
the 800 ms redirect (the visual setup freezes timers with `paused: true`).
- [ ] Invalid or expired state: ResultPanel with note and both actions, button order (primary
"Cancel registration and start over" first per components.md's safe/primary-on-top rule for
stacked mobile buttons).
- [ ] Cancel-failed alert: the error alert renders above the ResultPanel via AuthShell's #notice
slot, matching Register's own server-error alert styling exactly.
- [ ] No session state: the trimmed-down log-in form (no forgot-password link, no footer) matches
the board.
- [ ] No session, retrying state: identical spinner to Verifying.
- [ ] Missing code state: ResultPanel with a single secondary action.
- [ ] Keyboard/focus: focus moves to the ResultPanel title on the Success, Invalid-or-expired, and
Missing-code states; on No session, focus order is Username → Password → show/hide → submit;
the spinner's `role="status"` text is announced once, not on every re-render; the cancel-failed
alert is `role="alert"` and does not steal focus from the ResultPanel title.
## Manual verification
- **Real POST /v1/auth/verify and POST /v1/auth/login against a running server** — Vitest only mocks
`useApi`; confirm the real 200/403/401 round-trips (including the actual bcrypt code check and
2-hour expiry) against `task dev` with a live SQLite file. The endpoint's own contract is already
covered by `features/account_verification.qa.md` and `features/login_screen.qa.md`; this slice
only re-verifies the screen consumes it correctly.
- **Real DELETE /v1/auth/register from this screen** — confirm the 200/403/500 round-trips against a
live server; the endpoint's own contract is already covered by
`features/cancel_pending_registration.qa.md`.
+4 -2
View File
@@ -8,7 +8,8 @@ import (
) )
type SmtpSettings struct { type SmtpSettings struct {
Email string Login string
Sender string
Password string Password string
Host string Host string
Port int Port int
@@ -61,7 +62,8 @@ func NewSettingsFromEnv() (Settings, error) {
DBFile: os.Getenv("DB_FILE"), DBFile: os.Getenv("DB_FILE"),
Port: os.Getenv("PORT"), Port: os.Getenv("PORT"),
Smtp: SmtpSettings{ Smtp: SmtpSettings{
Email: os.Getenv("SMTP_EMAIL"), Login: os.Getenv("SMTP_LOGIN"),
Sender: os.Getenv("SMTP_SENDER"),
Password: os.Getenv("SMTP_PASSWORD"), Password: os.Getenv("SMTP_PASSWORD"),
Host: os.Getenv("SMTP_HOST"), Host: os.Getenv("SMTP_HOST"),
Port: smtpPort, Port: smtpPort,
@@ -22,37 +22,53 @@ func NewMailRepository(smtp config.SmtpSettings) *MailRepository {
return &MailRepository{smtp: smtp} return &MailRepository{smtp: smtp}
} }
func (m MailRepository) Send(receiverAddress string, templateName string, data any) error { const defaultSubject = "RenewCMS | Action required"
from := m.smtp.Email
password := m.smtp.Password
smtpHost := m.smtp.Host
smtpPort := m.smtp.Port
d := gomail.NewDialer(smtpHost, smtpPort, from, password) func (m MailRepository) Render(templateName string, data any) (string, string, error) {
subject := defaultSubject
switch templateName {
case "verification":
subject = "Verify your RenewCMS account"
case "password-reset":
subject = "Reset your RenewCMS password"
}
tmpl, err := template.ParseFS(mailTemplateFiles, "templates/"+templateName+".html") tmpl, err := template.ParseFS(mailTemplateFiles, "templates/"+templateName+".html")
if err != nil { if err != nil {
log.Println("error when template.ParseFS:", err) log.Println("error when template.ParseFS:", err)
return err return subject, "", err
} }
var body bytes.Buffer var body bytes.Buffer
err = tmpl.Execute(&body, data) if err := tmpl.Execute(&body, data); err != nil {
if err != nil {
log.Println("error when tmpl.Execute:", err) log.Println("error when tmpl.Execute:", err)
return subject, "", err
}
return subject, body.String(), nil
}
func (m MailRepository) Send(receiverAddress string, templateName string, data any) error {
login := m.smtp.Login
sender := m.smtp.Sender
password := m.smtp.Password
smtpHost := m.smtp.Host
smtpPort := m.smtp.Port
d := gomail.NewDialer(smtpHost, smtpPort, login, password)
subject, body, err := m.Render(templateName, data)
if err != nil {
return err return err
} }
msg := gomail.NewMessage() msg := gomail.NewMessage()
msg.SetHeaders(map[string][]string{ msg.SetHeaders(map[string][]string{
"From": {"RenewCMS <" + from + ">"}, "From": {"RenewCMS <" + sender + ">"},
"To": {receiverAddress}, "To": {receiverAddress},
"MIME-version": {"1.0"}, "Subject": {subject},
"Content-Type": {"text/html"},
"charset": {"UTF-8"},
"Subject": {"RenewCMS | Action required"},
}) })
msg.SetBody("text/html", body.String()) msg.SetBody("text/html", body)
if err := d.DialAndSend(msg); err != nil { if err := d.DialAndSend(msg); err != nil {
log.Println("error when sending email:", err) log.Println("error when sending email:", err)
@@ -1,18 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<body>
<h1>
<img src="{{.Host}}/static/renewcms-favicon-64.png" alt="G" style="width: 2.5rem"/>
RenewCMS
</h1>
<main>
<p>You or someone tried to create a RenewCMS admin account with this e-mail address. If you are not responsible for
this, please do not validate the account creation.</p>
<p><b>If you want to validate the account creation, please <a
href="{{.Host}}/register/validate?c={{.VerificationCode}}" target="_blank">click HERE</a>.</b>
</p>
<p>If you can't click the link:</p>
<p>{{.Host}}/register/validate?c={{.VerificationCode}}</p>
</main>
</body>
</html>
@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Reset your password</title>
<style>
body { margin: 0; padding: 0; width: 100%; -webkit-text-size-adjust: 100%; }
table { border-collapse: collapse; }
a { color: #111111; }
@media (max-width: 620px) {
.rc-card { padding: 28px 20px !important; }
.rc-btn a { display: block !important; }
}
@media (prefers-color-scheme: dark) {
.rc-bg { background-color: #0a0a0a !important; }
.rc-card { background-color: #111111 !important; border-color: #333333 !important; }
.rc-h1, .rc-word { color: #fafafa !important; }
.rc-text { color: #e6e4e0 !important; }
.rc-muted { color: #999999 !important; }
.rc-mark { border: 1px solid #444444 !important; }
.rc-btn td { background-color: #fafafa !important; }
.rc-btn a { background-color: #fafafa !important; color: #111111 !important; border-color: #fafafa !important; }
.rc-url { color: #e6e4e0 !important; background-color: #1a1a1a !important; }
.rc-rule { border-color: #333333 !important; }
}
</style>
</head>
<body class="rc-bg" style="margin:0;padding:0;background-color:#fafafa;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">Choose a new password for RenewCMS. The link expires in 1 hour.</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" class="rc-bg" style="background-color:#fafafa;">
<tr>
<td align="center" style="padding:40px 12px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;">
<tr>
<td style="padding:0 0 20px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td class="rc-mark" width="32" height="32" align="center" valign="middle" bgcolor="#111111" style="width:32px;height:32px;background-color:#111111;color:#ffffff;font-family:Arial,Helvetica,sans-serif;font-size:19px;font-weight:bold;line-height:32px;">R</td>
<td class="rc-word" style="padding-left:10px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:17px;font-weight:600;color:#111111;">RenewCMS</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="rc-card" style="background-color:#ffffff;border:1px solid #e6e4e0;padding:40px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;">
<h1 class="rc-h1" style="margin:0 0 16px 0;font-size:24px;line-height:32px;font-weight:600;color:#111111;">Reset your password</h1>
<p class="rc-text" style="margin:0 0 28px 0;font-size:16px;line-height:26px;color:#222222;">Someone asked to reset the password for the RenewCMS account that uses {{.Email}} at {{.Host}}.</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" class="rc-btn">
<tr>
<td bgcolor="#111111" style="background-color:#111111;">
<a href="{{.Host}}/register/reset/validate?c={{.VerificationCode}}&amp;email={{.Email}}" target="_blank" style="display:inline-block;padding:14px 24px;border:1px solid #111111;background-color:#111111;color:#ffffff;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:16px;font-weight:600;line-height:20px;text-decoration:none;text-align:center;">Choose a new password</a>
</td>
</tr>
</table>
<p class="rc-muted" style="margin:16px 0 0 0;font-size:14px;line-height:22px;color:#666666;">This link expires in 1 hour and works once.</p>
<p class="rc-muted" style="margin:28px 0 8px 0;font-size:14px;line-height:22px;color:#666666;">If the button doesn't work, copy this address into your browser:</p>
<p class="rc-url" style="margin:0;padding:10px 12px;background-color:#f3f2f0;font-family:'IBM Plex Mono',Menlo,Consolas,monospace;font-size:13px;line-height:20px;color:#222222;word-break:break-all;">{{.Host}}/register/reset/validate?c={{.VerificationCode}}&amp;email={{.Email}}</p>
<p class="rc-text rc-rule" style="margin:28px 0 0 0;padding-top:20px;border-top:1px solid #e6e4e0;font-size:14px;line-height:22px;color:#222222;">If this wasn't you, ignore this email; nothing will change.</p>
</td>
</tr>
<tr>
<td class="rc-muted" style="padding:20px 0 0 0;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:12px;line-height:18px;color:#666666;">RenewCMS · {{.Host}}</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -1,18 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<body>
<h1>
<img src="{{.Host}}/static/renewcms-favicon-64.png" alt="G" style="width: 2.5rem"/>
RenewCMS
</h1>
<main>
<p>You or someone tried to create reset your RenewCMS account with this e-mail address. If you are not responsible for
this, please do not validate the password reset.</p>
<p><b>If you want to reset your password, please <a
href="{{.Host}}/register/reset/validate?c={{.VerificationCode}}&email={{.Email}}" target="_blank">click HERE</a>.</b>
</p>
<p>If you can't click the link:</p>
<p>{{.Host}}/register/reset/validate?c={{.VerificationCode}}&email={{.Email}}</p>
</main>
</body>
</html>
@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Verify your account</title>
<style>
body { margin: 0; padding: 0; width: 100%; -webkit-text-size-adjust: 100%; }
table { border-collapse: collapse; }
a { color: #111111; }
@media (max-width: 620px) {
.rc-card { padding: 28px 20px !important; }
.rc-btn a { display: block !important; }
}
@media (prefers-color-scheme: dark) {
.rc-bg { background-color: #0a0a0a !important; }
.rc-card { background-color: #111111 !important; border-color: #333333 !important; }
.rc-h1, .rc-word { color: #fafafa !important; }
.rc-text { color: #e6e4e0 !important; }
.rc-muted { color: #999999 !important; }
.rc-mark { border: 1px solid #444444 !important; }
.rc-btn td { background-color: #fafafa !important; }
.rc-btn a { background-color: #fafafa !important; color: #111111 !important; border-color: #fafafa !important; }
.rc-url { color: #e6e4e0 !important; background-color: #1a1a1a !important; }
.rc-rule { border-color: #333333 !important; }
}
</style>
</head>
<body class="rc-bg" style="margin:0;padding:0;background-color:#fafafa;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">Confirm your email to finish setting up RenewCMS. The link expires in 2 hours.</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" class="rc-bg" style="background-color:#fafafa;">
<tr>
<td align="center" style="padding:40px 12px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;">
<tr>
<td style="padding:0 0 20px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td class="rc-mark" width="32" height="32" align="center" valign="middle" bgcolor="#111111" style="width:32px;height:32px;background-color:#111111;color:#ffffff;font-family:Arial,Helvetica,sans-serif;font-size:19px;font-weight:bold;line-height:32px;">R</td>
<td class="rc-word" style="padding-left:10px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:17px;font-weight:600;color:#111111;">RenewCMS</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="rc-card" style="background-color:#ffffff;border:1px solid #e6e4e0;padding:40px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;">
<h1 class="rc-h1" style="margin:0 0 16px 0;font-size:24px;line-height:32px;font-weight:600;color:#111111;">Verify your account</h1>
<p class="rc-text" style="margin:0 0 28px 0;font-size:16px;line-height:26px;color:#222222;">Someone, hopefully you, created the owner account for RenewCMS at {{.Host}}. Verify this email address to finish setting it up.</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" class="rc-btn">
<tr>
<td bgcolor="#111111" style="background-color:#111111;">
<a href="{{.Host}}/register/validate?c={{.VerificationCode}}" target="_blank" style="display:inline-block;padding:14px 24px;border:1px solid #111111;background-color:#111111;color:#ffffff;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:16px;font-weight:600;line-height:20px;text-decoration:none;text-align:center;">Verify my account</a>
</td>
</tr>
</table>
<p class="rc-muted" style="margin:16px 0 0 0;font-size:14px;line-height:22px;color:#666666;">This link expires in 2 hours.</p>
<p class="rc-muted" style="margin:28px 0 8px 0;font-size:14px;line-height:22px;color:#666666;">If the button doesn't work, copy this address into your browser:</p>
<p class="rc-url" style="margin:0;padding:10px 12px;background-color:#f3f2f0;font-family:'IBM Plex Mono',Menlo,Consolas,monospace;font-size:13px;line-height:20px;color:#222222;word-break:break-all;">{{.Host}}/register/validate?c={{.VerificationCode}}</p>
<p class="rc-text rc-rule" style="margin:28px 0 0 0;padding-top:20px;border-top:1px solid #e6e4e0;font-size:14px;line-height:22px;color:#222222;">If this wasn't you, ignore this email; nothing will change.</p>
</td>
</tr>
<tr>
<td class="rc-muted" style="padding:20px 0 0 0;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:12px;line-height:18px;color:#666666;">RenewCMS · {{.Host}}</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -18,7 +18,7 @@ func NewArticleRepository(db *gorm.DB) *ArticleRepository {
} }
func (a *ArticleRepository) FindByFilters(filters domain.Filters) ([]domain.Article, error) { func (a *ArticleRepository) FindByFilters(filters domain.Filters) ([]domain.Article, error) {
query := a.db.Model(&entity.Article{}) query := a.db.Model(&entity.Article{}).Preload("Images")
if filters.IsOnline != nil { if filters.IsOnline != nil {
query = query.Where("is_online = ?", *filters.IsOnline) query = query.Where("is_online = ?", *filters.IsOnline)
@@ -65,7 +65,7 @@ func (g *CreateUserUseCase) CreateUser(createUser CreateUserCommand) (user.User,
createdUser.VerificationCode = rawUuid createdUser.VerificationCode = rawUuid
if err := g.mailRepository.Send(createdUser.Email, "mailValidation", MailValidationData{ if err := g.mailRepository.Send(createdUser.Email, "verification", MailValidationData{
Host: g.publicURL, Host: g.publicURL,
VerificationCode: rawUuid, VerificationCode: rawUuid,
}); err != nil { }); err != nil {
@@ -46,7 +46,7 @@ func (g *RequestPasswordResetUseCase) RequestPasswordReset(email string) error {
return nil return nil
} }
if err := g.mailRepository.Send(foundUser.Email, "passwordReset", MailPasswordResetData{ if err := g.mailRepository.Send(foundUser.Email, "password-reset", MailPasswordResetData{
Host: g.publicURL, Host: g.publicURL,
VerificationCode: rawCode, VerificationCode: rawCode,
Email: foundUser.Email, Email: foundUser.Email,
+92
View File
@@ -0,0 +1,92 @@
package test
import (
"RenewCMS/api/handlers/article"
domainArticle "RenewCMS/internal/domain/article"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
validator "RenewCMS/api/pkg/validator"
)
func newArticlesListRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux {
userRepo := newVerifiedUserRepo()
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, newMockImageRepo()),
useCases.NewUpdateArticleUseCase(articleRepo),
getUserUseCase,
)
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Get("/", articleHandler.ListArticles)
})
})
return r
}
func TestArticlesListHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
t.Run("GET /v1/article/ returns each article's images array populated", func(t *testing.T) {
repo := newMockArticleRepo()
repo.articles[4] = domainArticle.Article{
ID: 4,
Title: "Winter checklist for timber windows",
Images: []*domainImage.Image{
{ID: 10, ArticleID: 4},
{ID: 11, ArticleID: 4},
},
}
router := newArticlesListRouter(repo, token)
req := httptest.NewRequest(http.MethodGet, "/v1/article/", nil)
setJwtCookie(t, req, token, 1)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
var got []domainArticle.Article
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
var found *domainArticle.Article
for i := range got {
if got[i].ID == 4 {
found = &got[i]
}
}
if found == nil {
t.Fatalf("expected article id 4 in response, got %v", got)
}
if len(found.Images) != 2 {
t.Fatalf("expected 2 images, got %d", len(found.Images))
}
ids := map[uint32]bool{}
for _, img := range found.Images {
ids[img.ID] = true
}
if !ids[10] || !ids[11] {
t.Errorf("expected images 10 and 11, got %v", found.Images)
}
})
}
@@ -90,8 +90,8 @@ func TestRegistrationGateAndVerificationEmailHttp(t *testing.T) {
if sent.Address != "alice@example.com" { if sent.Address != "alice@example.com" {
t.Errorf("expected email sent to alice@example.com, got %q", sent.Address) t.Errorf("expected email sent to alice@example.com, got %q", sent.Address)
} }
if sent.Template != "mailValidation" { if sent.Template != "verification" {
t.Errorf("expected template mailValidation, got %q", sent.Template) t.Errorf("expected template verification, got %q", sent.Template)
} }
storedUser, err := userRepo.GetByUsername("alice") storedUser, err := userRepo.GetByUsername("alice")
@@ -260,8 +260,8 @@ func TestRegistrationGateAndVerificationEmailUseCase(t *testing.T) {
if mailRepo.SentEmails[0].Address != "carol@example.com" { if mailRepo.SentEmails[0].Address != "carol@example.com" {
t.Errorf("expected email sent to carol@example.com, got %q", mailRepo.SentEmails[0].Address) t.Errorf("expected email sent to carol@example.com, got %q", mailRepo.SentEmails[0].Address)
} }
if mailRepo.SentEmails[0].Template != "mailValidation" { if mailRepo.SentEmails[0].Template != "verification" {
t.Errorf("expected template mailValidation, got %q", mailRepo.SentEmails[0].Template) t.Errorf("expected template verification, got %q", mailRepo.SentEmails[0].Template)
} }
}) })
+4 -4
View File
@@ -65,8 +65,8 @@ func TestRequestPasswordResetHTTP(t *testing.T) {
if mailRepo.SentEmails[0].Address != "alice@example.com" { if mailRepo.SentEmails[0].Address != "alice@example.com" {
t.Errorf("expected email to alice@example.com, got %s", mailRepo.SentEmails[0].Address) t.Errorf("expected email to alice@example.com, got %s", mailRepo.SentEmails[0].Address)
} }
if mailRepo.SentEmails[0].Template != "passwordReset" { if mailRepo.SentEmails[0].Template != "password-reset" {
t.Errorf("expected template passwordReset, got %s", mailRepo.SentEmails[0].Template) t.Errorf("expected template password-reset, got %s", mailRepo.SentEmails[0].Template)
} }
}) })
@@ -204,8 +204,8 @@ func TestRequestPasswordResetHTTP(t *testing.T) {
if mailRepo.SentEmails[0].Address != "bob@example.com" { if mailRepo.SentEmails[0].Address != "bob@example.com" {
t.Errorf("expected email to bob@example.com, got %s", mailRepo.SentEmails[0].Address) t.Errorf("expected email to bob@example.com, got %s", mailRepo.SentEmails[0].Address)
} }
if mailRepo.SentEmails[0].Template != "passwordReset" { if mailRepo.SentEmails[0].Template != "password-reset" {
t.Errorf("expected template passwordReset, got %s", mailRepo.SentEmails[0].Template) t.Errorf("expected template password-reset, got %s", mailRepo.SentEmails[0].Template)
} }
}) })
+2 -2
View File
@@ -54,8 +54,8 @@ func TestRequestPasswordResetUseCase(t *testing.T) {
if sent.Address != "alice@example.com" { if sent.Address != "alice@example.com" {
t.Errorf("expected email to alice@example.com, got %s", sent.Address) t.Errorf("expected email to alice@example.com, got %s", sent.Address)
} }
if sent.Template != "passwordReset" { if sent.Template != "password-reset" {
t.Errorf("expected template passwordReset, got %s", sent.Template) t.Errorf("expected template password-reset, got %s", sent.Template)
} }
data, ok := sent.Data.(useCases.MailPasswordResetData) data, ok := sent.Data.(useCases.MailPasswordResetData)
if !ok { if !ok {
+7 -3
View File
@@ -14,7 +14,8 @@ func setBaseSettingsEnv(t *testing.T) {
t.Setenv("JWT_SECRET", "test-secret") t.Setenv("JWT_SECRET", "test-secret")
t.Setenv("DB_FILE", "./data/renewcms.db") t.Setenv("DB_FILE", "./data/renewcms.db")
t.Setenv("PORT", "8080") t.Setenv("PORT", "8080")
t.Setenv("SMTP_EMAIL", "notify@example.com") t.Setenv("SMTP_LOGIN", "smtp-login@example.com")
t.Setenv("SMTP_SENDER", "notify@example.com")
t.Setenv("SMTP_PASSWORD", "hunter2") t.Setenv("SMTP_PASSWORD", "hunter2")
t.Setenv("SMTP_HOST", "smtp.example.com") t.Setenv("SMTP_HOST", "smtp.example.com")
t.Setenv("SMTP_PORT", "465") t.Setenv("SMTP_PORT", "465")
@@ -50,8 +51,11 @@ func TestNewSettingsFromEnvReadsAllFields(t *testing.T) {
if settings.Port != "8080" { if settings.Port != "8080" {
t.Errorf("expected Port %q, got %q", "8080", settings.Port) t.Errorf("expected Port %q, got %q", "8080", settings.Port)
} }
if settings.Smtp.Email != "notify@example.com" { if settings.Smtp.Login != "smtp-login@example.com" {
t.Errorf("expected Smtp.Email %q, got %q", "notify@example.com", settings.Smtp.Email) t.Errorf("expected Smtp.Login %q, got %q", "smtp-login@example.com", settings.Smtp.Login)
}
if settings.Smtp.Sender != "notify@example.com" {
t.Errorf("expected Smtp.Sender %q, got %q", "notify@example.com", settings.Smtp.Sender)
} }
if settings.Smtp.Password != "hunter2" { if settings.Smtp.Password != "hunter2" {
t.Errorf("expected Smtp.Password %q, got %q", "hunter2", settings.Smtp.Password) t.Errorf("expected Smtp.Password %q, got %q", "hunter2", settings.Smtp.Password)
+2 -1
View File
@@ -230,7 +230,8 @@ func TestRuntimeSettingsInjectionUploadDirectory(t *testing.T) {
func TestRuntimeSettingsInjectionMailer(t *testing.T) { func TestRuntimeSettingsInjectionMailer(t *testing.T) {
smtp := config.SmtpSettings{ smtp := config.SmtpSettings{
Email: "notify@example.com", Login: "smtp-login@example.com",
Sender: "notify@example.com",
Host: "smtp.example.com", Host: "smtp.example.com",
Port: 587, Port: 587,
Password: "unused-in-test", Password: "unused-in-test",
+119
View File
@@ -0,0 +1,119 @@
package test
import (
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/mailer"
"RenewCMS/internal/infrastructure/useCases"
"os"
"strings"
"testing"
)
func TestTransactionalEmailTemplates(t *testing.T) {
t.Run("render() produces the verification email's subject, link and copy", func(t *testing.T) {
repo := mailer.NewMailRepository(config.SmtpSettings{})
subject, body, err := repo.Render("verification", useCases.MailValidationData{
Host: "https://cms.example.com",
VerificationCode: "abc123",
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if subject != "Verify your RenewCMS account" {
t.Errorf("expected subject %q, got %q", "Verify your RenewCMS account", subject)
}
if !strings.Contains(body, "https://cms.example.com/register/validate?c=abc123") {
t.Errorf("expected body to contain the verification link, got %q", body)
}
if !strings.Contains(body, "Verify my account") {
t.Errorf("expected body to contain the button text, got %q", body)
}
if !strings.Contains(body, "This link expires in 2 hours.") {
t.Errorf("expected body to contain the expiry copy, got %q", body)
}
})
t.Run("render() produces the password-reset email's subject, link and copy", func(t *testing.T) {
repo := mailer.NewMailRepository(config.SmtpSettings{})
subject, body, err := repo.Render("password-reset", useCases.MailPasswordResetData{
Host: "https://cms.example.com",
VerificationCode: "abc123",
Email: "alice@example.com",
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if subject != "Reset your RenewCMS password" {
t.Errorf("expected subject %q, got %q", "Reset your RenewCMS password", subject)
}
if !strings.Contains(body, "https://cms.example.com/register/reset/validate?c=abc123&amp;email=alice%40example.com") {
t.Errorf("expected body to contain the password-reset link, got %q", body)
}
if !strings.Contains(body, "Choose a new password") {
t.Errorf("expected body to contain the button text, got %q", body)
}
if !strings.Contains(body, "This link expires in 1 hour and works once.") {
t.Errorf("expected body to contain the expiry copy, got %q", body)
}
})
t.Run("render() falls back to the old default subject for an unrecognized template name", func(t *testing.T) {
repo := mailer.NewMailRepository(config.SmtpSettings{})
subject, body, err := repo.Render("does-not-exist", nil)
if subject != "RenewCMS | Action required" {
t.Errorf("expected fallback subject, got %q", subject)
}
if err == nil {
t.Fatal("expected an error because templates/does-not-exist.html cannot be found")
}
if body != "" {
t.Errorf("expected empty body, got %q", body)
}
})
t.Run("A template execution failure is returned without sending", func(t *testing.T) {
repo := mailer.NewMailRepository(config.SmtpSettings{})
type incompatibleData struct {
Unrelated int
}
_, body, err := repo.Render("verification", incompatibleData{Unrelated: 1})
if err == nil {
t.Fatal("expected an error from a failed template execution")
}
if body != "" {
t.Errorf("expected empty body, got %q", body)
}
})
t.Run("Send() returns a render error without dialing SMTP", func(t *testing.T) {
repo := mailer.NewMailRepository(config.SmtpSettings{})
_, _, renderErr := repo.Render("does-not-exist", nil)
err := repo.Send("someone@example.com", "does-not-exist", nil)
if err == nil {
t.Fatal("expected an error from a failed render, got nil")
}
if err.Error() != renderErr.Error() {
t.Errorf("expected Send to return the render error %q, got %q", renderErr, err)
}
})
t.Run("The old template files no longer exist under their previous names", func(t *testing.T) {
for _, oldFile := range []string{"mailValidation.html", "passwordReset.html"} {
path := "../internal/infrastructure/mailer/templates/" + oldFile
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("expected %s to not exist, got err=%v", path, err)
}
}
})
}
+3
View File
@@ -28,6 +28,9 @@ coverage
.eslintcache .eslintcache
/auto-imports.d.ts
/components.d.ts
# Cypress # Cypress
/cypress/videos/ /cypress/videos/
/cypress/screenshots/ /cypress/screenshots/
+743 -22
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RenewCMS</title> <title>RenewCMS</title>
</head> </head>
+7 -4
View File
@@ -16,17 +16,19 @@
"format": "prettier --write --experimental-cli src/" "format": "prettier --write --experimental-cli src/"
}, },
"dependencies": { "dependencies": {
"@primeuix/themes": "^2.0.2", "@nuxt/ui": "^4.11.2",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@tiptap/core": "3.31.3",
"@vueuse/core": "14.4.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"primeicons": "^7.0.0", "shiki": "^4.4.3",
"primevue": "^4.5.4",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"tailwindcss-primeui": "^0.6.1",
"vue": "^3.5.25", "vue": "^3.5.25",
"vue-router": "^4.6.3" "vue-router": "^4.6.3"
}, },
"devDependencies": { "devDependencies": {
"@iconify-json/lucide": "^1.2.136",
"@iconify/utils": "^3.1.7",
"@tsconfig/node24": "^24.0.3", "@tsconfig/node24": "^24.0.3",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2", "@vitejs/plugin-vue": "^6.0.2",
@@ -39,6 +41,7 @@
"jiti": "^2.6.1", "jiti": "^2.6.1",
"jsdom": "25", "jsdom": "25",
"npm-run-all2": "^8.0.4", "npm-run-all2": "^8.0.4",
"playwright": "^1.63.0",
"prettier": "3.6.2", "prettier": "3.6.2",
"typescript": "~5.9.0", "typescript": "~5.9.0",
"vite": "^7.2.4", "vite": "^7.2.4",
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" shape-rendering="crispEdges" xmlns:c2pa="http://c2pa.org/manifest"><metadata><c2pa:manifest>AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTplOTQ3NWYzYi1iMzk1LTQwYTItYTc0NC02OTBiNWNhN2ExYmEAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaBZb8xjia8MHL36GYqgFh9IAAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDphMzk4M2EzMy03MTA2LTQwODctYjI1Yy05ZDBjMDgyNTBhNWNscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNoZLnQVKFQmfShOH339JG7cQAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggm30c/9ae4hGpd7ZqF9l6g7kujDirGaXyTi536jB+bcekZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaLY9Kv67+CqoKTGwY2CZrToAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCB5yyMJKOtdrWszMUivonxA1KFo3L7r2RCA4Mx1H0/3uWRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBiYZmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOmU5NDc1ZjNiLWIzOTUtNDBhMi1hNzQ0LTY5MGI1Y2E3YTFiYS9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOjViZTQwMjZjLWM3ZjctNDUzNC1hYmFiLTY3OTc0OWZjNGFkY3JjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCCbfRz/1p7iEal3tmoX2XqDuS6MOKsZpfJOLnfqMH5tx6JjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggM1R3FsRboXyA4rm7pAVOcC/HIi2cgqmrpa/mZrSD7n6iY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFggoD1kSgBM7/QspOsFvy5FPeDatbpPPR1Z+79+Ox5QvZZ0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALine truncated

After

Width:  |  Height:  |  Size: 7.9 KiB

+3 -7
View File
@@ -1,13 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
import NavComponent from './components/NavComponent.vue'
</script> </script>
<template> <template>
<nav-component /> <UApp class="isolate">
<main> <RouterView />
<router-view /> </UApp>
</main>
</template> </template>
<style scoped></style>
+370 -7
View File
@@ -1,14 +1,377 @@
@import "tailwindcss"; @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;1,8..60,400&display=swap');
@import "tailwindcss-primeui"; @import 'tailwindcss';
@import '@nuxt/ui';
* { @theme static {
box-sizing: border-box; --font-sans: 'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif;
--font-serif: 'Source Serif 4', ui-serif, Georgia, serif;
--font-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, monospace;
--color-ink-50: #fafafa;
--color-ink-100: #f2f2f2;
--color-ink-200: #e5e5e5;
--color-ink-300: #cccccc;
--color-ink-400: #999999;
--color-ink-500: #666666;
--color-ink-600: #444444;
--color-ink-700: #333333;
--color-ink-800: #222222;
--color-ink-900: #111111;
--color-ink-950: #0a0a0a;
--color-chalk-50: #fafafa;
--color-chalk-100: #f3f2f0;
--color-chalk-200: #e6e4e0;
--color-chalk-300: #cfccc7;
--color-chalk-400: #999999;
--color-chalk-450: #8a8783;
--color-chalk-500: #666666;
--color-chalk-600: #444444;
--color-chalk-700: #333333;
--color-chalk-800: #222222;
--color-chalk-850: #1a1a1a;
--color-chalk-900: #111111;
--color-chalk-950: #0a0a0a;
--color-sand-50: #f7f4ef;
--color-sand-100: #f0eae1;
--color-sand-200: #e8dfd1;
--color-sand-300: #d4c5b0;
--color-sand-400: #bba88e;
--color-sand-500: #9c8a70;
--color-sand-600: #7d6d56;
--color-sand-700: #635643;
--color-sand-800: #4a4033;
--color-sand-900: #322b23;
--color-sand-950: #211c17;
--color-moss-50: #f0faf0;
--color-moss-100: #e1f3e1;
--color-moss-200: #c7e2c7;
--color-moss-300: #a3caa3;
--color-moss-400: #7fac7f;
--color-moss-500: #5f8e60;
--color-moss-600: #457347;
--color-moss-700: #315b33;
--color-moss-800: #1f4422;
--color-moss-900: #143015;
--color-moss-950: #081e0a;
--color-slate-50: #f0f8ff;
--color-slate-100: #e2effa;
--color-slate-200: #c8dcee;
--color-slate-300: #a5c2db;
--color-slate-400: #81a3c0;
--color-slate-500: #6284a2;
--color-slate-600: #486a86;
--color-slate-700: #34536d;
--color-slate-800: #223d53;
--color-slate-900: #162b3c;
--color-slate-950: #0a1a26;
--color-ochre-50: #fff5e8;
--color-ochre-100: #fdead1;
--color-ochre-200: #f1d4ad;
--color-ochre-300: #dfb67d;
--color-ochre-400: #c4954f;
--color-ochre-500: #a67628;
--color-ochre-600: #8a5b00;
--color-ochre-700: #6c4700;
--color-ochre-800: #503400;
--color-ochre-900: #3a2400;
--color-ochre-950: #241500;
--color-brick-50: #fef4f2;
--color-brick-100: #ffe7e2;
--color-brick-200: #ffcbc2;
--color-brick-300: #fba394;
--color-brick-400: #e37e6e;
--color-brick-500: #c45d4e;
--color-brick-600: #a54134;
--color-brick-700: #882c21;
--color-brick-800: #6a1a11;
--color-brick-900: #4d0f08;
--color-brick-950: #330503;
--text-xs: 0.75rem;
--text-xs--line-height: 16px;
--text-sm: 0.8125rem;
--text-sm--line-height: 20px;
--text-base: 0.875rem;
--text-base--line-height: 22px;
--text-lg: 1rem;
--text-lg--line-height: 24px;
--text-xl: 1.25rem;
--text-xl--line-height: 28px;
--text-2xl: 1.75rem;
--text-2xl--line-height: 36px;
--text-code: 0.75rem;
--text-code--line-height: 19px;
--breakpoint-xs: 22.5rem;
--breakpoint-sm: 40rem;
--breakpoint-md: 48rem;
--breakpoint-lg: 64rem;
--breakpoint-xl: 80rem;
--shadow-popover: 0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06);
--shadow-modal: 0 16px 48px rgb(17 17 17 / 0.18);
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-enter: cubic-bezier(0, 0, 0, 1);
--ease-exit: cubic-bezier(0.4, 0, 1, 1);
}
:root {
--ui-radius: 0rem;
--ui-header-height: 3rem;
--ui-container: 72rem;
--duration-fast: 120ms;
--duration-base: 180ms;
--duration-slow: 240ms;
--z-sticky: 10;
--z-sidebar: 20;
--z-banner: 30;
--z-popover: 40;
--z-overlay: 50;
--z-toast: 60;
--z-tooltip: 70;
--ui-bg: var(--color-white);
--ui-bg-muted: var(--color-chalk-50);
--ui-bg-elevated: var(--color-chalk-100);
--ui-bg-accented: var(--color-chalk-200);
--ui-bg-inverted: var(--color-chalk-900);
--ui-text-dimmed: var(--color-chalk-400);
--ui-text-muted: var(--color-chalk-500);
--ui-text-toned: var(--color-chalk-600);
--ui-text: var(--color-chalk-800);
--ui-text-highlighted: var(--color-chalk-900);
--ui-text-inverted: var(--color-white);
--ui-border: var(--color-chalk-200);
--ui-border-muted: var(--color-chalk-100);
--ui-border-accented: var(--color-chalk-450);
--ui-border-inverted: var(--color-chalk-900);
--ui-primary: var(--ui-color-primary-900);
--ui-secondary: var(--ui-color-secondary-700);
--ui-success: var(--ui-color-success-700);
--ui-info: var(--ui-color-info-700);
--ui-warning: var(--ui-color-warning-700);
--ui-error: var(--ui-color-error-700);
--rc-success-soft: #eaefeb;
--rc-info-soft: #ebeef0;
--rc-warning-soft: #f0ede6;
--rc-error-soft: #f3eae9;
--rc-overlay: rgb(10 10 10 / 0.4);
}
.dark {
--ui-bg: var(--color-chalk-900);
--ui-bg-muted: var(--color-chalk-950);
--ui-bg-elevated: var(--color-chalk-850);
--ui-bg-accented: var(--color-chalk-800);
--ui-bg-inverted: var(--color-chalk-50);
--ui-text-dimmed: var(--color-chalk-500);
--ui-text-muted: var(--color-chalk-400);
--ui-text-toned: var(--color-chalk-300);
--ui-text: var(--color-chalk-200);
--ui-text-highlighted: var(--color-chalk-50);
--ui-text-inverted: var(--color-chalk-900);
--ui-border: var(--color-chalk-700);
--ui-border-muted: var(--color-chalk-800);
--ui-border-accented: var(--color-chalk-500);
--ui-border-inverted: var(--color-chalk-50);
--ui-primary: var(--ui-color-primary-50);
--ui-secondary: var(--ui-color-secondary-300);
--ui-success: var(--ui-color-success-300);
--ui-info: var(--ui-color-info-300);
--ui-warning: var(--ui-color-warning-300);
--ui-error: var(--ui-color-error-300);
--rc-success-soft: #202420;
--rc-info-soft: #202325;
--rc-warning-soft: #26221c;
--rc-error-soft: #28201e;
--rc-overlay: rgb(0 0 0 / 0.6);
--shadow-popover: 0 4px 16px rgb(0 0 0 / 0.5);
--shadow-modal: 0 16px 48px rgb(0 0 0 / 0.6);
} }
body { body {
margin: 0; background: var(--ui-bg-muted);
color: var(--ui-text);
font-family: var(--font-sans);
font-size: var(--text-sm);
line-height: var(--text-sm--line-height);
-webkit-font-smoothing: antialiased;
} }
.button-position { .article-content {
@apply fixed bottom-0 left-0 m-3; font-family: var(--font-serif);
font-size: 1.0625rem;
line-height: 1.75rem;
color: var(--ui-text-highlighted);
max-width: 42rem;
overflow-wrap: break-word;
}
.article-content > :first-child {
margin-top: 0;
}
.article-content h1,
.article-content h2,
.article-content h3,
.article-content h4 {
font-weight: 600;
text-wrap: balance;
color: var(--ui-text-highlighted);
}
.article-content h1 {
font-size: 1.75rem;
line-height: 2.25rem;
margin: 0 0 1rem;
}
.article-content h2 {
font-size: 1.4375rem;
line-height: 1.9375rem;
margin: 2.5rem 0 0.75rem;
}
.article-content h3 {
font-size: 1.1875rem;
line-height: 1.75rem;
margin: 2rem 0 0.5rem;
}
.article-content h4 {
font-size: 1.0625rem;
line-height: 1.625rem;
margin: 1.5rem 0 0.5rem;
}
.article-content p {
font-size: 1.0625rem;
line-height: 1.75rem;
margin: 0 0 1.25rem;
}
.article-content ul,
.article-content ol {
margin: 0 0 1.25rem;
padding-left: 1.75rem;
}
.article-content ul {
list-style: square;
}
.article-content ol {
list-style: decimal;
}
.article-content li {
margin: 0 0 0.375rem;
}
.article-content li > p {
margin: 0;
}
.article-content li::marker {
color: var(--ui-text-muted);
}
.article-content [data-indent='1'] {
padding-left: 1.5rem;
}
.article-content [data-indent='2'] {
padding-left: 3rem;
}
.article-content [data-indent='3'] {
padding-left: 4.5rem;
}
.article-content [data-indent='4'] {
padding-left: 6rem;
}
.article-content blockquote {
margin: 1.75rem 0;
padding-left: 1.25rem;
border-left: 2px solid var(--ui-primary);
font-style: italic;
color: var(--ui-text-toned);
}
.article-content a {
color: var(--ui-text-highlighted);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
.article-content a:hover {
text-decoration-thickness: 2px;
}
.article-content img {
display: block;
max-width: 100%;
height: auto;
margin: 1.75rem 0;
}
.article-content figure {
margin: 1.75rem 0;
}
.article-content figure img {
margin: 0;
}
.article-content figcaption {
margin-top: 0.5rem;
font-family: var(--font-sans);
font-size: 0.8125rem;
line-height: 1.25rem;
color: var(--ui-text-muted);
}
@media (min-width: 48rem) {
.article-content h1 {
font-size: 2rem;
line-height: 2.5rem;
}
.article-content h2 {
font-size: 1.625rem;
line-height: 2.125rem;
}
.article-content h3 {
font-size: 1.3125rem;
line-height: 1.875rem;
}
.article-content h4 {
font-size: 1.125rem;
line-height: 1.75rem;
}
.article-content p {
font-size: 1.125rem;
line-height: 1.875rem;
}
.article-content {
font-size: 1.125rem;
line-height: 1.875rem;
}
.article-content figcaption {
font-size: 0.875rem;
}
}
@media (prefers-reduced-motion: reduce) {
*,
::before,
::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
.animate-spin {
animation-duration: 1.6s !important;
animation-iteration-count: infinite !important;
}
}
.shiki-block :where(pre.shiki) {
margin: 0;
padding: 0;
background-color: transparent !important;
}
.shiki-block :where(pre.shiki),
.shiki-block :where(pre.shiki span) {
color: var(--shiki-light);
}
.dark .shiki-block :where(pre.shiki),
.dark .shiki-block :where(pre.shiki span) {
color: var(--shiki-dark);
} }
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts"></script>
<template>
<div class="space-y-4 p-6" role="status" aria-label="Loading">
<USkeleton class="h-6 w-40" />
<USkeleton class="h-32 w-full" />
<USkeleton class="h-32 w-full" />
</div>
</template>
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
withDefaults(
defineProps<{
size?: number
}>(),
{ size: 32 },
)
</script>
<template>
<svg
role="img"
aria-label="RenewCMS"
:width="size"
:height="size"
viewBox="0 0 16 16"
shape-rendering="crispEdges"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="16" height="16" fill="#111111" />
<path
fill="#ffffff"
fill-rule="evenodd"
d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"
/>
</svg>
</template>
+146
View File
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import { formatRelativeDate } from '@/lib/relativeDate'
import type { Article } from '@/composables/useArticles'
const props = defineProps<{
articles: Article[]
loading: boolean
busyIds: Set<number>
now?: Date
}>()
const emit = defineEmits<{
publish: [id: number]
unpublish: [id: number]
delete: [article: Article]
'copy-url': [id: number]
}>()
const router = useRouter()
function editPath(article: Article) {
return `/article/${article.id}/edit`
}
function isBusy(article: Article) {
return props.busyIds.has(article.id)
}
function pendingFor(article: Article) {
if (!isBusy(article)) {
return undefined
}
return article.is_online ? ('unpublish' as const) : ('publish' as const)
}
function actionsFor(article: Article) {
return [
[
{
label: 'Edit',
icon: 'i-lucide-pencil',
onSelect: () => router.push(editPath(article)),
},
{
label: article.is_online ? 'Unpublish' : 'Publish',
icon: article.is_online ? 'i-lucide-globe-lock' : 'i-lucide-globe',
disabled: isBusy(article),
onSelect: () => {
if (article.is_online) {
emit('unpublish', article.id)
} else {
emit('publish', article.id)
}
},
},
{
label: 'Copy API URL',
icon: 'i-lucide-link',
onSelect: () => emit('copy-url', article.id),
},
],
[
{
label: 'Delete…',
icon: 'i-lucide-trash-2',
color: 'error' as const,
onSelect: () => emit('delete', article),
},
],
]
}
function onRowClick(article: Article, event: MouseEvent) {
if ((event.target as HTMLElement).closest('[data-row-menu]')) {
return
}
router.push(editPath(article))
}
</script>
<template>
<ul
class="-mx-4 border-y border-default bg-default md:hidden"
:aria-busy="loading ? 'true' : undefined"
>
<template v-if="loading">
<li
v-for="n in 5"
:key="n"
class="flex min-h-17 flex-col justify-center gap-2 border-t border-muted py-3 pl-4 pr-1 first:border-t-0"
>
<USkeleton class="h-3.5 w-3/4 rounded-none bg-accented" />
<USkeleton class="h-3 w-2/5 rounded-none bg-accented" />
</li>
</template>
<template v-else>
<li v-if="articles.length === 0">
<slot name="empty" />
</li>
<li
v-for="article in articles"
:key="article.id"
class="relative flex min-h-17 items-center gap-2 border-t border-muted py-3 pl-4 pr-1 first:border-t-0"
:class="{ 'opacity-55': isBusy(article) }"
@click="onRowClick(article, $event)"
>
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
<RouterLink
:to="editPath(article)"
:data-row-title="article.id"
class="truncate text-[0.9375rem]/[21px] font-medium text-highlighted"
@click.stop
>
{{ article.title }}
</RouterLink>
<div class="flex items-center gap-2.5 text-xs text-muted">
<ArticleStatusBadge
:online="article.is_online"
size="sm"
:pending="pendingFor(article)"
/>
<span :title="article.updated_at"
>Updated {{ formatRelativeDate(article.updated_at, now) }}</span
>
</div>
</div>
<UDropdownMenu
:items="actionsFor(article)"
:content="{ align: 'end' }"
data-row-menu
>
<UButton
icon="i-lucide-ellipsis-vertical"
color="neutral"
variant="ghost"
:aria-label="`Actions for ${article.title}`"
class="size-11 justify-center text-toned"
@click.stop
/>
</UDropdownMenu>
</li>
</template>
</ul>
</template>
+146
View File
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Article } from '@/composables/useArticles'
const props = defineProps<{ article: Article }>()
defineEmits<{ 'copy-url': [] }>()
const open = defineModel<boolean>('open', { default: false })
const TITLE_NOTE =
"Titles can't be changed after an article is created. To use a different title, create a new article and delete this one."
const MONTH_NAMES = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
]
function isSameDay(a: Date, b: Date) {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
)
}
function formatDetailDate(iso: string, relative = false) {
const date = new Date(iso)
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
if (relative && isSameDay(date, new Date())) {
return `Today, ${hours}:${minutes}`
}
return `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}, ${hours}:${minutes}`
}
const createdLabel = computed(() => formatDetailDate(props.article.created_at))
const updatedLabel = computed(() =>
formatDetailDate(props.article.updated_at, true),
)
const publicUrl = computed(() => `/v1/article/${props.article.id}`)
const imagesCount = computed(() => props.article.images.length)
const apiNote = computed(() =>
props.article.is_online
? 'Live. Your website can read this article.'
: 'Returns 404 until the article is online.',
)
</script>
<template>
<div>
<UCard
data-testid="article-details"
class="hidden lg:flex"
:ui="{
root: 'flex-col gap-4 rounded-none bg-transparent shadow-none ring-0 divide-y-0',
header: 'p-0 sm:p-0',
body: 'flex flex-col gap-4 p-0 sm:p-0',
}"
>
<template #header>
<h2 class="text-base font-semibold text-highlighted">
Details
</h2>
</template>
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm">
<dt class="text-muted">Created</dt>
<dd class="text-highlighted">{{ createdLabel }}</dd>
<dt class="text-muted">Updated</dt>
<dd class="text-highlighted">{{ updatedLabel }}</dd>
<dt class="text-muted">ID</dt>
<dd class="font-mono text-highlighted">{{ article.id }}</dd>
<dt class="text-muted">Images</dt>
<dd class="text-highlighted">{{ imagesCount }}</dd>
</dl>
<div class="flex flex-col gap-1.5 text-sm">
<p class="text-muted">Public API URL</p>
<div
class="flex items-center gap-1.5 bg-elevated px-2 py-1.5 font-mono text-xs"
>
<span class="flex-1">{{ publicUrl }}</span>
<UIcon name="i-lucide-copy" class="size-3.5" />
</div>
<p class="text-xs/[18px] text-muted">{{ apiNote }}</p>
</div>
<div
class="flex flex-col gap-1 border-t border-default pt-3 text-sm"
>
<p class="text-muted">Title</p>
<p class="text-xs/[18px] text-toned">{{ TITLE_NOTE }}</p>
</div>
</UCard>
<USlideover
v-model:open="open"
side="right"
title="Details"
class="lg:hidden"
>
<template #body>
<div class="flex flex-col gap-4 text-sm">
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2">
<dt class="text-muted">Created</dt>
<dd class="text-highlighted">{{ createdLabel }}</dd>
<dt class="text-muted">Updated</dt>
<dd class="text-highlighted">{{ updatedLabel }}</dd>
<dt class="text-muted">ID</dt>
<dd class="font-mono text-highlighted">
{{ article.id }}
</dd>
<dt class="text-muted">Images</dt>
<dd class="text-highlighted">{{ imagesCount }}</dd>
</dl>
<div class="flex flex-col gap-1.5">
<p class="text-muted">Public API URL</p>
<div
class="flex items-center gap-1.5 bg-elevated px-2 py-1.5 font-mono text-xs"
>
<span class="flex-1">{{ publicUrl }}</span>
<UIcon name="i-lucide-copy" class="size-3.5" />
</div>
<p class="text-xs/[18px] text-muted">{{ apiNote }}</p>
</div>
<div
class="flex flex-col gap-1 border-t border-default pt-3"
>
<p class="text-muted">Title</p>
<p class="text-xs/[18px] text-toned">
{{ TITLE_NOTE }}
</p>
</div>
</div>
</template>
</USlideover>
</div>
</template>
+255
View File
@@ -0,0 +1,255 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { EditorToolbarItem } from '@nuxt/ui'
import type { Editor } from '@tiptap/core'
import { ArticleIndent } from '@/lib/articleIndent'
const props = withDefaults(
defineProps<{
modelValue: string
articleId: number
disabled?: boolean
}>(),
{
disabled: false,
},
)
const emit = defineEmits<{
'update:modelValue': [value: string]
'request-image': []
}>()
const editorInstance = ref<Editor | null>(null)
const extensions = [ArticleIndent]
function captureEditor(editor: Editor) {
editorInstance.value = editor
return ''
}
function onUpdate(value: string) {
emit('update:modelValue', value)
}
function currentBlockLabel(editor: Editor) {
for (let level = 1; level <= 4; level += 1) {
if (editor.isActive('heading', { level })) {
return `Heading ${level}`
}
}
return 'Paragraph'
}
function isDefaultAlignment(editor: Editor) {
return !['center', 'right', 'justify'].some((align) =>
editor.isActive({ textAlign: align }),
)
}
function buildItems(editor: Editor): EditorToolbarItem[][] {
return [
[
{
kind: 'undo',
icon: 'i-lucide-undo-2',
tooltip: { text: 'Undo' },
},
{
kind: 'redo',
icon: 'i-lucide-redo-2',
tooltip: { text: 'Redo' },
},
],
[
{
icon: 'i-lucide-pilcrow',
label: currentBlockLabel(editor),
trailingIcon: 'i-lucide-chevron-down',
ui: { trailingIcon: 'size-3' },
tooltip: { text: 'Paragraph style' },
items: [
{
icon: 'i-lucide-pilcrow',
label: 'Paragraph',
onSelect: () =>
editor.chain().focus().setParagraph().run(),
},
{
kind: 'heading',
level: 1,
icon: 'i-lucide-heading-1',
label: 'Heading 1',
tooltip: { text: 'Heading 1' },
},
{
kind: 'heading',
level: 2,
icon: 'i-lucide-heading-2',
label: 'Heading 2',
tooltip: { text: 'Heading 2' },
},
{
kind: 'heading',
level: 3,
icon: 'i-lucide-heading-3',
label: 'Heading 3',
tooltip: { text: 'Heading 3' },
},
{
kind: 'heading',
level: 4,
icon: 'i-lucide-heading-4',
label: 'Heading 4',
tooltip: { text: 'Heading 4' },
},
],
},
],
[
{
kind: 'mark',
mark: 'bold',
icon: 'i-lucide-bold',
tooltip: { text: 'Bold' },
},
{
kind: 'mark',
mark: 'italic',
icon: 'i-lucide-italic',
tooltip: { text: 'Italic' },
},
{
kind: 'mark',
mark: 'underline',
icon: 'i-lucide-underline',
tooltip: { text: 'Underline' },
},
{
kind: 'mark',
mark: 'strike',
icon: 'i-lucide-strikethrough',
tooltip: { text: 'Strikethrough' },
},
],
[
{
kind: 'textAlign',
align: 'left',
active: isDefaultAlignment(editor),
icon: 'i-lucide-align-left',
tooltip: { text: 'Align left' },
},
{
kind: 'textAlign',
align: 'center',
icon: 'i-lucide-align-center',
tooltip: { text: 'Align center' },
},
{
kind: 'textAlign',
align: 'right',
icon: 'i-lucide-align-right',
tooltip: { text: 'Align right' },
},
{
kind: 'textAlign',
align: 'justify',
icon: 'i-lucide-align-justify',
tooltip: { text: 'Justify' },
},
],
[
{
kind: 'bulletList',
icon: 'i-lucide-list',
tooltip: { text: 'Bullet list' },
},
{
kind: 'orderedList',
icon: 'i-lucide-list-ordered',
tooltip: { text: 'Numbered list' },
},
{
icon: 'i-lucide-indent-decrease',
tooltip: { text: 'Outdent' },
onClick: () => editor.chain().focus().outdent().run(),
},
{
icon: 'i-lucide-indent-increase',
tooltip: { text: 'Indent' },
onClick: () => editor.chain().focus().indent().run(),
},
],
[
{
kind: 'clearFormatting',
icon: 'i-lucide-remove-formatting',
tooltip: { text: 'Clear formatting' },
},
{
icon: 'i-lucide-image-plus',
label: 'Image',
tooltip: { text: 'Add an image' },
onClick: () => emit('request-image'),
},
],
]
}
defineExpose({
focus() {
editorInstance.value?.commands.focus()
},
})
</script>
<template>
<UEditor
v-slot="{ editor }"
:model-value="props.modelValue"
:extensions="extensions"
content-type="html"
:editable="!props.disabled"
:ui="{ base: 'article-content' }"
class="article-editor grid h-full min-h-0 grid-cols-1 grid-rows-[auto_minmax(0,1fr)_auto] lg:grid-cols-[minmax(0,1fr)_17.5rem]"
@update:model-value="onUpdate"
>
<span class="hidden">{{ captureEditor(editor) }}</span>
<UEditorToolbar
:editor="editor"
:items="buildItems(editor)"
layout="fixed"
class="col-span-full row-start-1 border-b border-default bg-default px-6 py-1.5"
/>
<div class="col-start-1 row-start-3">
<slot name="footer" />
</div>
<div
class="col-start-2 row-span-2 row-start-2 hidden min-h-0 lg:block"
>
<slot name="aside" />
</div>
</UEditor>
</template>
<style scoped>
.article-editor :deep([data-slot='content']) {
grid-column-start: 1;
grid-row-start: 2;
min-height: 0;
overflow-y: auto;
padding: 2.5rem 3rem;
background: var(--ui-bg);
}
.article-editor :deep(.ProseMirror) {
max-width: 42rem;
padding: 0;
outline: none;
}
@media (max-width: 47.999rem) {
.article-editor :deep([data-slot='content']) {
padding: 1.5rem 1rem;
}
}
</style>
+39
View File
@@ -0,0 +1,39 @@
<script setup lang="ts">
withDefaults(
defineProps<{
online: boolean
size?: 'sm' | 'md' | 'lg'
pending?: 'publish' | 'unpublish'
}>(),
{
size: 'md',
},
)
</script>
<template>
<UBadge
v-if="pending"
color="neutral"
variant="outline"
icon="i-lucide-loader-circle"
:size="size"
:class="size === 'lg' ? undefined : 'px-2'"
:ui="{ leadingIcon: 'animate-spin' }"
>
{{ pending === 'publish' ? 'Publishing…' : 'Unpublishing…' }}
</UBadge>
<UBadge v-else-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="size === 'lg' ? undefined : 'px-2'"
>
Draft
</UBadge>
</template>
+180
View File
@@ -0,0 +1,180 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import { formatAbsoluteDateFromIso, formatRelativeDate } from '@/lib/relativeDate'
import type { Article } from '@/composables/useArticles'
const props = defineProps<{
articles: Article[]
loading: boolean
busyIds: Set<number>
now?: Date
}>()
const emit = defineEmits<{
publish: [id: number]
unpublish: [id: number]
delete: [article: Article]
'copy-url': [id: number]
}>()
const router = useRouter()
const columnWidths =
'[&:nth-child(2)]:w-27.5 [&:nth-child(3)]:w-32.5 [&:nth-child(4)]:w-35 [&:nth-child(5)]:w-11'
const columns = [
{ accessorKey: 'title', header: 'Title' },
{ accessorKey: 'is_online', header: 'Status', size: 110 },
{ accessorKey: 'created_at', header: 'Created', size: 130 },
{ accessorKey: 'updated_at', header: 'Updated', size: 140 },
{ id: 'actions', header: '', size: 44 },
]
function editPath(article: Article) {
return `/article/${article.id}/edit`
}
function isBusy(article: Article) {
return props.busyIds.has(article.id)
}
function pendingFor(article: Article) {
if (!isBusy(article)) {
return undefined
}
return article.is_online ? ('unpublish' as const) : ('publish' as const)
}
function actionsFor(article: Article) {
return [
[
{
label: 'Edit',
icon: 'i-lucide-pencil',
onSelect: () => router.push(editPath(article)),
},
{
label: article.is_online ? 'Unpublish' : 'Publish',
icon: article.is_online ? 'i-lucide-globe-lock' : 'i-lucide-globe',
disabled: isBusy(article),
onSelect: () => {
if (article.is_online) {
emit('unpublish', article.id)
} else {
emit('publish', article.id)
}
},
},
{
label: 'Copy API URL',
icon: 'i-lucide-link',
onSelect: () => emit('copy-url', article.id),
},
],
[
{
label: 'Delete…',
icon: 'i-lucide-trash-2',
color: 'error' as const,
onSelect: () => emit('delete', article),
},
],
]
}
function onSelect(row: { original: Article }) {
router.push(editPath(row.original))
}
</script>
<template>
<div v-if="loading" aria-busy="true" class="hidden border border-default bg-default md:block">
<span role="status" class="sr-only">Loading articles</span>
<div
v-for="n in 5"
:key="n"
class="flex h-11 items-center gap-3 border-t border-muted px-3 first:border-t-0"
>
<USkeleton class="h-3 max-w-[55%] flex-1 rounded-none bg-accented" />
<USkeleton class="h-5 w-15 rounded-none bg-accented" />
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
</div>
</div>
<div v-else class="hidden border border-default bg-default md:block">
<UTable
:data="articles"
:columns="columns"
:ui="{
base: 'table-fixed',
tr: 'cursor-pointer',
th: `px-3 ${columnWidths}`,
td: `h-11 px-3 py-0 ${columnWidths}`,
}"
@select="onSelect"
>
<template #caption>
<caption class="sr-only">
Articles, sorted by last updated, newest first
</caption>
</template>
<template #title-cell="{ row }">
<RouterLink
:to="editPath(row.original)"
:data-row-title="row.original.id"
class="font-medium text-highlighted"
@click.stop
>
{{ row.original.title }}
</RouterLink>
</template>
<template #is_online-cell="{ row }">
<ArticleStatusBadge
:online="row.original.is_online"
size="sm"
:pending="pendingFor(row.original)"
:aria-busy="isBusy(row.original) ? 'true' : undefined"
/>
</template>
<template #updated_at-header>
<span class="inline-flex items-center gap-1 text-highlighted">
Updated
<UIcon name="i-lucide-arrow-down" class="size-3" />
</span>
</template>
<template #created_at-cell="{ row }">
<span class="text-muted">{{
formatAbsoluteDateFromIso(row.original.created_at)
}}</span>
</template>
<template #updated_at-cell="{ row }">
<span class="text-muted" :title="row.original.updated_at">{{
formatRelativeDate(row.original.updated_at, now)
}}</span>
</template>
<template #actions-cell="{ row }">
<UDropdownMenu :items="actionsFor(row.original)" :content="{ align: 'end' }">
<UButton
icon="i-lucide-ellipsis"
color="neutral"
variant="ghost"
:aria-label="`Actions for ${row.original.title}`"
class="size-8 justify-center text-toned"
@click.stop
/>
</UDropdownMenu>
</template>
<template #empty>
<slot name="empty" />
</template>
</UTable>
</div>
</template>
+134
View File
@@ -0,0 +1,134 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, useSlots, watch } from 'vue'
import AppLogo from '@/components/AppLogo.vue'
import { isInAppNavigation } from '@/composables/useAuthShellNavigation'
const props = defineProps<{
title: string
description?: string
}>()
const slots = useSlots()
const titleEl = ref<HTMLHeadingElement | null>(null)
const belowSm = ref(false)
let mediaQuery: MediaQueryList | null = null
function updateBelowSm() {
belowSm.value = mediaQuery?.matches ?? false
}
onMounted(() => {
if (typeof window.matchMedia === 'function') {
mediaQuery = window.matchMedia('(max-width: 39.9975rem)')
updateBelowSm()
mediaQuery.addEventListener('change', updateBelowSm)
}
if (isInAppNavigation()) {
titleEl.value?.focus()
}
})
onUnmounted(() => {
mediaQuery?.removeEventListener('change', updateBelowSm)
})
const controlSize = computed(() => (belowSm.value ? 'xl' : undefined))
const controlClass = computed(() =>
belowSm.value ? '!h-11 !text-base' : '!h-10 !text-base',
)
const inputClass = computed(() =>
belowSm.value ? '!h-11 !text-lg' : '!h-10 !text-base',
)
const mainClass = computed(() => [
'flex min-h-screen flex-col items-center gap-5',
belowSm.value ? 'bg-default pt-10' : 'bg-muted pt-[12vh]',
])
const cardClass = computed(() =>
belowSm.value ? 'w-full' : 'w-full max-w-[400px]',
)
const cardUi = computed(() => ({
root: belowSm.value
? 'rounded-none ring-0'
: 'rounded-none ring-0 border border-default',
container: belowSm.value
? 'flex flex-col gap-6 px-5 pt-2 pb-0 lg:flex'
: 'flex flex-col gap-6 p-10 sm:p-10 lg:flex',
header: props.title ? 'mb-6' : 'mb-0',
title: 'text-xl/7 font-semibold',
description: 'mt-1.5 text-base text-muted',
}))
const footerClass = computed(() => [
'flex w-full flex-wrap items-center justify-between gap-3 text-sm text-muted',
belowSm.value ? 'px-5 pb-6' : 'p-0',
cardClass.value,
])
const colorModeSelectUi = {
base: 'h-7 ps-7 pe-2 text-sm text-toned',
leading: 'ps-2',
leadingIcon: 'size-3.5 text-toned',
trailing: 'hidden',
}
watch(
() => props.title,
(title) => {
if (!title) {
return
}
document.title = `${title} · RenewCMS`
},
{ immediate: true },
)
watch(
() => props.title,
(title) => {
if (!title) {
return
}
titleEl.value?.focus()
},
)
</script>
<template>
<main :class="mainClass">
<UPageCard
variant="outline"
:description="description"
:ui="cardUi"
data-testid="auth-card"
:class="cardClass"
>
<template #header>
<AppLogo :size="32" />
</template>
<template v-if="title" #title>
<h1 ref="titleEl" tabindex="-1" class="outline-none">
{{ title }}
</h1>
</template>
<slot v-if="slots.notice" name="notice" />
<slot
:control-size="controlSize"
:control-class="controlClass"
:input-class="inputClass"
/>
</UPageCard>
<footer v-if="!belowSm || $slots.footer" :class="footerClass">
<span class="flex flex-wrap items-center gap-1.5">
<slot name="footer" />
</span>
<UColorModeSelect size="sm" :ui="colorModeSelectUi" />
</footer>
</main>
</template>
+139
View File
@@ -0,0 +1,139 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
const props = withDefaults(
defineProps<{
value: number
max: number
min?: number
warnAt?: number
variant?: 'inline' | 'footer'
}>(),
{
min: 0,
warnAt: 0.9,
variant: 'inline',
},
)
function formatThousands(count: number) {
return count.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
}
const overBy = computed(() => props.value - props.max)
const status = computed<'normal' | 'warn' | 'over' | 'under'>(() => {
if (props.min > 0 && props.value < props.min) {
return 'under'
}
if (overBy.value > 0) {
return 'over'
}
if (props.value >= props.max * props.warnAt) {
return 'warn'
}
return 'normal'
})
const overText = computed(() =>
overBy.value === 1
? '1 character over. Shorten the text or remove an image. Formatting counts too.'
: `${formatThousands(overBy.value)} characters over. Shorten the text or remove an image. Formatting counts too.`,
)
const warnText = 'Close to the limit. Images and formatting count too.'
const message = computed(() => {
if (status.value === 'over') {
return overText.value
}
if (status.value === 'warn') {
return warnText
}
return ''
})
const announcement = ref('')
watch(status, (next, previous) => {
if (next === previous) {
return
}
if (next === 'over') {
announcement.value = overText.value
} else if (next === 'warn') {
announcement.value = 'Almost at the length limit.'
} else {
announcement.value = ''
}
})
const isErrorStatus = computed(() => status.value === 'over' || status.value === 'under')
</script>
<template>
<div
v-if="variant === 'footer'"
class="flex items-center gap-3 border-t border-default bg-default px-6 py-2"
>
<span
class="flex-1 text-xs"
:class="{
'text-warning': status === 'warn',
'text-error': isErrorStatus,
'text-muted': status === 'normal',
}"
>{{ message }}</span
>
<span
class="flex items-center gap-1.5 font-mono text-xs"
:class="{
'text-warning': status === 'warn',
'text-error': isErrorStatus,
'text-muted': status === 'normal',
}"
>
<UIcon
v-if="status === 'warn'"
name="i-lucide-triangle-alert"
class="size-3.5"
data-testid="character-counter-warning-icon"
/>
<UIcon
v-else-if="status === 'over'"
name="i-lucide-circle-alert"
class="size-3.5"
data-testid="character-counter-over-icon"
/>
{{ formatThousands(value) }} / {{ formatThousands(max) }}
</span>
<span aria-live="polite" class="sr-only">{{ announcement }}</span>
</div>
<div v-else class="flex flex-col gap-1">
<div
class="flex items-center gap-1.5 font-mono text-xs"
:class="{
'text-warning': status === 'warn',
'text-error': isErrorStatus,
'text-muted': status === 'normal',
}"
>
<UIcon
v-if="status === 'warn'"
name="i-lucide-triangle-alert"
class="size-4"
data-testid="character-counter-warning-icon"
/>
<UIcon
v-else-if="status === 'over'"
name="i-lucide-circle-alert"
class="size-4"
data-testid="character-counter-over-icon"
/>
<span>{{ formatThousands(value) }} / {{ formatThousands(max) }}</span>
</div>
<p v-if="status === 'over'" class="text-sm text-error">
{{ overText }}
</p>
<span aria-live="polite" class="sr-only">{{ announcement }}</span>
</div>
</template>
+62
View File
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed, ref, watchEffect } from 'vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { highlightToHtml, useShikiHighlighter } from '@/composables/useShikiHighlighter'
const props = defineProps<{
code: string
lang: string
label?: string
}>()
const { copied, copy } = useCopyToClipboard()
const buttonLabel = computed(() => (copied.value ? 'Copied' : 'Copy'))
const buttonIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
const highlightedHtml = ref<string | null>(null)
watchEffect(async () => {
const highlighter = await useShikiHighlighter()
highlightedHtml.value = highlightToHtml(highlighter, props.code, props.lang)
})
function onCopy() {
copy(props.code)
}
</script>
<template>
<div class="bg-elevated">
<div class="flex h-8 items-center justify-between border-b border-default pr-2 pl-4">
<span class="font-mono text-xs leading-tight font-medium text-muted">{{
label ?? lang
}}</span>
<UButton
:icon="buttonIcon"
color="neutral"
variant="ghost"
size="xs"
:aria-label="`Copy ${label ?? lang} example`"
aria-live="polite"
class="gap-1.5 px-1.5 font-medium text-toned"
:ui="{ leadingIcon: 'size-3.5' }"
@click="onCopy"
>
{{ buttonLabel }}
</UButton>
</div>
<div
v-if="highlightedHtml"
tabindex="0"
class="shiki-block overflow-x-auto px-4 py-3.5 font-mono text-code whitespace-pre"
v-html="highlightedHtml"
/>
<pre
v-else
tabindex="0"
class="overflow-x-auto px-4 py-3.5 font-mono text-code whitespace-pre text-default"
>{{ code }}</pre
>
</div>
</template>
+142
View File
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
const props = withDefaults(
defineProps<{
title: string
description: string
confirmLabel: string
cancelLabel: string
pendingLabel: string
tone: 'danger' | 'neutral'
action: () => Promise<void>
dismissResolvesTo?: 'cancel' | 'confirm'
}>(),
{
dismissResolvesTo: 'cancel',
},
)
const emit = defineEmits<{ close: [confirmed: boolean] }>()
const open = defineModel<boolean>('open', { default: true })
const pending = ref(false)
const error = ref('')
const cancelButton = ref<{ $el: HTMLElement } | null>(null)
let settled = false
const confirmColor = computed(() =>
props.tone === 'danger' ? 'error' : 'primary',
)
const confirmText = computed(() =>
pending.value ? props.pendingLabel : props.confirmLabel,
)
onMounted(async () => {
await nextTick()
cancelButton.value?.$el?.focus()
})
function settle(confirmed: boolean) {
if (settled) {
return
}
settled = true
open.value = false
emit('close', confirmed)
}
watch(open, (value) => {
if (!value) {
settle(props.dismissResolvesTo === 'confirm')
}
})
async function onConfirm() {
if (pending.value) {
return
}
pending.value = true
error.value = ''
try {
await props.action()
settle(true)
} catch (err) {
error.value =
err instanceof Error
? err.message
: 'Something went wrong. Try again.'
} finally {
pending.value = false
}
}
function onCancel() {
if (pending.value) {
return
}
settle(false)
}
</script>
<template>
<UModal
v-model:open="open"
:title="title"
:description="description"
:dismissible="!pending"
:close="false"
:content="{
role: 'alertdialog',
'aria-busy': pending ? 'true' : undefined,
}"
:ui="{
content:
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-[220px] sm:max-w-110 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
header: 'min-h-0 px-6 pt-6 pb-0 sm:px-6 max-sm:px-5 max-sm:pt-5',
title: 'text-lg max-sm:text-[1.0625rem]/6',
description:
'mt-2 text-base text-toned max-sm:text-[0.9375rem]/[23px]',
body: 'px-6 pt-4 pb-0 sm:px-6 sm:pt-4 sm:pb-0 max-sm:px-5',
footer: 'p-6 sm:px-6 max-sm:p-5',
}"
>
<template v-if="error" #body>
<UAlert
color="error"
icon="i-lucide-circle-x"
:description="error"
variant="soft"
:close="false"
role="alert"
/>
</template>
<template #footer>
<div
class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end"
>
<UButton
ref="cancelButton"
color="neutral"
variant="outline"
block
class="h-11 justify-center text-[0.9375rem] text-highlighted disabled:text-dimmed disabled:opacity-100 sm:h-8 sm:w-auto sm:px-3 sm:text-sm"
:disabled="pending"
@click="onCancel"
>
{{ cancelLabel }}
</UButton>
<UButton
:color="confirmColor"
:loading="pending"
:disabled="pending"
block
class="h-11 justify-center text-[0.9375rem] sm:h-8 sm:w-auto sm:px-3 sm:text-sm"
@click="onConfirm"
>
{{ confirmText }}
</UButton>
</div>
</template>
</UModal>
</template>
+181
View File
@@ -0,0 +1,181 @@
<script setup lang="ts">
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 props = withDefaults(
defineProps<{
mobileTitle?: string
fullBleed?: boolean
}>(),
{
mobileTitle: undefined,
fullBleed: false,
},
)
const bodyUi = computed(() =>
props.fullBleed
? 'gap-0 p-0 sm:gap-0 sm:p-0 lg:p-0'
: 'gap-0 px-4 pt-5 pb-22 sm:gap-0 sm:px-4 sm:pt-5 sm:pb-22 lg:p-8',
)
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const api = useApi()
const navItems = computed(() =>
[
{ label: 'Home', to: '/home', icon: 'i-lucide-home' },
{ label: 'Articles', to: '/article', icon: 'i-lucide-file-text' },
{ label: 'Integration', to: '/integration', icon: 'i-lucide-plug' },
].map((item) => ({
...item,
active: route.path === item.to || route.path.startsWith(`${item.to}/`),
})),
)
const routeTitle = computed(() => (route.meta.title as string) ?? '')
const pageTitle = computed(() => props.mobileTitle ?? routeTitle.value)
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
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>
<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"
: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>
<UserMenu
:username="session.session?.username ?? ''"
:collapsed="sidebarCollapsed"
@logout="onLogout"
/>
</template>
</UDashboardSidebar>
<UDashboardPanel :ui="{ body: bodyUi }">
<template #header>
<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
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"
/>
<slot name="breadcrumb">
<UBreadcrumb
:items="[{ label: routeTitle }]"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
</slot>
</template>
<template #right>
<slot name="navbar-right" />
</template>
</UDashboardNavbar>
</template>
<template #body>
<div
id="dashboard-content"
:class="
fullBleed ? 'flex h-full min-h-0 flex-1 flex-col' : ''
"
>
<OfflineBanner />
<AppBoot v-if="session.status === 'checking'" />
<ErrorView
v-else-if="session.status === 'error'"
kind="error"
@retry="onRetry"
/>
<div
v-else
:class="fullBleed ? 'flex min-h-0 flex-1 flex-col' : ''"
>
<slot />
</div>
</div>
</template>
</UDashboardPanel>
</UDashboardGroup>
</template>
+76
View File
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { computed } from 'vue'
const QUERY_MAX_LENGTH = 40
const props = defineProps<{
variant: 'no-articles' | 'no-results' | 'no-drafts' | 'no-online'
query?: string
}>()
const emit = defineEmits<{ action: [] }>()
const truncatedQuery = computed(() => {
const value = props.query ?? ''
return value.length > QUERY_MAX_LENGTH ? value.slice(0, QUERY_MAX_LENGTH) : value
})
const variants = computed(() => ({
'no-articles': {
icon: 'i-lucide-file-text',
title: 'No articles yet',
description: 'Write your first one. It stays a draft until you publish it.',
action: 'New article',
primary: true,
},
'no-results': {
icon: 'i-lucide-search',
title: `No articles match "${truncatedQuery.value}"`,
description: 'Check the spelling, or search for a shorter part of the title.',
action: 'Clear search',
primary: false,
},
'no-drafts': {
icon: 'i-lucide-pencil-line',
title: 'No drafts',
description: "Everything you've written is online.",
action: 'New article',
primary: false,
},
'no-online': {
icon: 'i-lucide-globe',
title: 'Nothing is online yet',
description: 'Publish a draft and it will appear on your website.',
action: 'Show drafts',
primary: false,
},
}))
const current = computed(() => variants.value[props.variant])
</script>
<template>
<UEmpty
variant="naked"
:icon="current.icon"
:title="current.title"
:description="current.description"
:ui="{
root: 'gap-3 border border-default bg-default px-6 py-14',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-[0.9375rem] font-semibold',
description: 'max-w-70 text-muted text-pretty',
}"
>
<template #actions>
<UButton
:color="current.primary ? 'primary' : 'neutral'"
:variant="current.primary ? 'solid' : 'outline'"
class="h-8 px-3"
@click="emit('action')"
>
{{ current.action }}
</UButton>
</template>
</UEmpty>
</template>
+73
View File
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { ref } from 'vue'
import CodeBlock from '@/components/CodeBlock.vue'
defineProps<{
method: 'GET'
path: string
description: string
examples: { fetch: string; curl: string; response: string }
}>()
const activeTab = ref('fetch')
const tabItems = [
{ label: 'fetch', value: 'fetch' },
{ label: 'curl', value: 'curl' },
{ label: 'Response', value: 'response' },
]
</script>
<template>
<UCard
:ui="{
root: 'border border-default ring-0 divide-default',
header: 'p-4 sm:p-4',
body: 'p-0 sm:p-0',
}"
>
<template #header>
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-2.5">
<UBadge :label="method" color="neutral" variant="solid" class="font-mono" />
<span class="font-mono text-base font-medium text-highlighted">{{ path }}</span>
</div>
<span class="text-toned">{{ description }}</span>
</div>
</template>
<UTabs
v-model="activeTab"
:items="tabItems"
variant="link"
color="primary"
:ui="{
root: 'gap-0',
list: 'gap-5 border-b border-default px-4 py-0 before:hidden',
trigger: 'h-9 grow-0 px-0 text-sm font-normal data-[state=active]:font-medium',
content: 'mt-0',
}"
>
<template #content="{ item }">
<CodeBlock
v-if="item.value === 'fetch'"
label="JavaScript"
lang="javascript"
:code="examples.fetch"
/>
<pre
v-else-if="item.value === 'curl'"
tabindex="0"
class="overflow-x-auto bg-elevated px-4 py-3.5 font-mono text-code whitespace-pre text-default"
>{{ examples.curl }}</pre
>
<pre
v-else
tabindex="0"
class="overflow-x-auto bg-elevated px-4 py-3.5 font-mono text-code whitespace-pre text-default"
>{{ examples.response }}</pre
>
</template>
</UTabs>
</UCard>
</template>
+94
View File
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
const props = withDefaults(
defineProps<{
kind: 'not-found' | 'error' | 'article-not-found'
breadcrumb?: string
}>(),
{
breadcrumb: 'Home',
},
)
const emit = defineEmits<{
retry: []
}>()
const router = useRouter()
const copy = computed(() => {
if (props.kind === 'not-found') {
return {
title: 'Page not found',
text: 'The link may be mistyped, or the page may have moved.',
primary: 'Go to Home',
secondary: 'All articles',
}
}
if (props.kind === 'article-not-found') {
return {
title: "This article doesn't exist.",
text: 'It may have been deleted, or the link is wrong.',
primary: 'All articles',
secondary: '',
}
}
return {
title: 'Something went wrong',
text: "This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.",
primary: 'Try again',
secondary: 'Go to Home',
}
})
function onPrimary() {
if (props.kind === 'error') {
emit('retry')
return
}
if (props.kind === 'article-not-found') {
router.push('/article')
return
}
router.push('/home')
}
function onSecondary() {
if (props.kind === 'error') {
router.push('/home')
return
}
router.push('/article')
}
</script>
<template>
<div role="alert" class="flex max-w-140 flex-col gap-3.5 p-8">
<nav v-if="kind !== 'article-not-found'" aria-label="Breadcrumb">
<span>{{ breadcrumb }}</span>
</nav>
<span
v-if="kind === 'article-not-found'"
aria-hidden="true"
class="font-mono text-sm text-muted"
>404</span
>
<h1 tabindex="-1" class="text-2xl font-semibold text-highlighted">
{{ copy.title }}
</h1>
<p v-if="copy.text" class="text-sm text-toned">{{ copy.text }}</p>
<div class="flex gap-2">
<UButton class="h-8" @click="onPrimary">{{ copy.primary }}</UButton>
<UButton
v-if="copy.secondary"
variant="outline"
color="neutral"
class="h-8"
@click="onSecondary"
>{{ copy.secondary }}</UButton
>
</div>
</div>
</template>
-33
View File
@@ -1,33 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Button, Drawer } from 'primevue'
import { RouterLink } from 'vue-router'
const visible = ref(false)
</script>
<template>
<header>
<Drawer v-model:visible="visible">
<template #container="{ closeCallback }">
<ul @click="closeCallback">
<li><RouterLink to="/">Home</RouterLink></li>
<li><RouterLink to="/article">Article</RouterLink></li>
</ul>
<div class="button-position">
<Button
type="button"
@click="closeCallback"
icon="pi pi-times"
variant="outlined"
></Button>
</div>
</template>
</Drawer>
</header>
<div class="button-position">
<Button icon="pi pi-bars" @click="visible = true" />
</div>
</template>
<style scoped></style>
+261
View File
@@ -0,0 +1,261 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import CharacterCounter from '@/components/CharacterCounter.vue'
import { ApiError, useApi } from '@/composables/useApi'
import type { Article } from '@/composables/useArticles'
const TITLE_MIN_LENGTH = 3
const TITLE_MAX_LENGTH = 200
const TITLE_PLACEHOLDER = 'e.g. Choosing tiles for a small bathroom'
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ created: [article: Article] }>()
const api = useApi()
const title = ref('')
const body = ref('')
const titleTouched = ref(false)
const bodyTouched = ref(false)
const titleFocused = ref(false)
const submitting = ref(false)
const titleServerError = ref('')
const formAlert = ref<'' | 'invalid' | 'failed'>('')
const titleLength = computed(() => [...title.value].length)
const titleValid = computed(
() => titleLength.value >= TITLE_MIN_LENGTH && titleLength.value <= TITLE_MAX_LENGTH,
)
const bodyValid = computed(() => body.value.length > 0)
const titleLengthError = computed(() => {
if (!titleTouched.value) {
return ''
}
if (titleLength.value < TITLE_MIN_LENGTH) {
return 'Use at least 3 characters.'
}
if (titleLength.value > TITLE_MAX_LENGTH) {
return 'Use 3 to 200 characters.'
}
return ''
})
const titleError = computed(() => titleServerError.value || titleLengthError.value)
const bodyError = computed(() =>
bodyTouched.value && !bodyValid.value
? 'Add a first paragraph. It can be a single sentence.'
: '',
)
const titleCounterMin = computed(() => (titleTouched.value ? TITLE_MIN_LENGTH : 0))
const showHelp = computed(() => !submitting.value && formAlert.value === '')
const alertMessage = computed(() => {
if (formAlert.value === 'invalid') {
return "Something in the form isn't valid. Check the highlighted fields."
}
if (formAlert.value === 'failed') {
return "Couldn't create the article. Your text is still here. Try again in a moment."
}
return ''
})
function resetForm() {
title.value = ''
body.value = ''
titleTouched.value = false
bodyTouched.value = false
titleFocused.value = false
submitting.value = false
titleServerError.value = ''
formAlert.value = ''
}
async function focusTitleField() {
await nextTick()
await nextTick()
document.querySelector<HTMLInputElement>(`input[placeholder="${TITLE_PLACEHOLDER}"]`)?.focus()
titleFocused.value = true
}
watch(
open,
(isOpen) => {
if (!isOpen) {
return
}
resetForm()
focusTitleField()
},
{ immediate: true },
)
function onTitleBlur() {
titleTouched.value = true
titleFocused.value = false
}
function onBodyBlur() {
bodyTouched.value = true
}
async function onSubmit() {
titleTouched.value = true
bodyTouched.value = true
titleFocused.value = false
if (!titleValid.value || !bodyValid.value) {
return
}
titleServerError.value = ''
formAlert.value = ''
submitting.value = true
try {
const article = await api.request<Article>('/v1/article/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title.value, body: body.value }),
})
open.value = false
emit('created', article)
} catch (error) {
if (error instanceof ApiError && error.status === 400) {
titleServerError.value = 'Check this title and try again.'
formAlert.value = 'invalid'
} else {
formAlert.value = 'failed'
}
} finally {
submitting.value = false
}
}
function onCancel() {
if (submitting.value) {
return
}
resetForm()
open.value = false
}
</script>
<template>
<UModal
v-model:open="open"
title="New article"
description="It's saved as a draft. Titles can't be changed later, so check it now."
:dismissible="!submitting"
:ui="{
content:
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-140 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
header: 'items-start gap-3 px-6 pt-5 pb-0 sm:px-6',
wrapper: 'pe-10',
title: 'text-lg/6 font-semibold',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: 'px-6 py-5 sm:px-6',
}"
>
<template #body>
<div class="flex flex-col gap-4">
<UAlert
v-if="alertMessage"
role="alert"
color="error"
variant="soft"
:close="false"
icon="i-lucide-circle-x"
:ui="{ root: 'gap-2 px-3 py-2.5' }"
:description="alertMessage"
/>
<UForm :state="{ title, body }" class="flex flex-col gap-4">
<UFormField label="Title" name="title" :error="titleError || undefined">
<template #hint>
<CharacterCounter
:value="titleLength"
:max="TITLE_MAX_LENGTH"
:min="titleCounterMin"
/>
</template>
<template v-if="titleError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
</span>
</template>
<template v-else-if="showHelp" #help>3 to 200 characters.</template>
<UInput
v-model="title"
:placeholder="TITLE_PLACEHOLDER"
:disabled="submitting"
color="primary"
:highlight="titleFocused || !!titleError"
:ui="{ base: 'h-11 text-lg md:h-10 md:text-base' }"
class="w-full"
@focus="titleFocused = true"
@blur="onTitleBlur"
/>
</UFormField>
<UFormField label="First paragraph" name="body" :error="bodyError || undefined">
<template v-if="bodyError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
</span>
</template>
<template v-else-if="showHelp" #help>
You can format it and add images in the editor.
</template>
<UTextarea
v-model="body"
placeholder="Start writing. You'll continue in the editor."
:disabled="submitting"
:highlight="!!bodyError"
:rows="3"
:ui="{
base: 'h-30 text-lg disabled:bg-elevated disabled:text-dimmed disabled:opacity-100 md:h-24 md:text-base',
}"
class="w-full"
@blur="onBodyBlur"
/>
</UFormField>
</UForm>
</div>
</template>
<template #footer>
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton
color="neutral"
variant="outline"
block
:ui="{ base: 'h-11 md:h-8' }"
class="sm:w-auto"
:disabled="submitting"
@click="onCancel"
>
Cancel
</UButton>
<UButton
block
:ui="{ base: 'h-11 md:h-8' }"
class="sm:w-auto"
:loading="submitting"
:disabled="submitting"
@click="onSubmit"
>
{{ submitting ? 'Creating…' : 'Create draft' }}
</UButton>
</div>
</template>
</UModal>
</template>
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
const isOffline = ref(!navigator.onLine)
function updateOnlineStatus() {
isOffline.value = !navigator.onLine
}
onMounted(() => {
window.addEventListener('online', updateOnlineStatus)
window.addEventListener('offline', updateOnlineStatus)
})
onUnmounted(() => {
window.removeEventListener('online', updateOnlineStatus)
window.removeEventListener('offline', updateOnlineStatus)
})
</script>
<template>
<UBanner
v-if="isOffline"
color="neutral"
title="You're offline. You can keep writing, but saving and publishing will wait until you're back online."
/>
</template>
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { computed } from 'vue'
interface PasswordRule {
label: string
met: boolean
}
const props = defineProps<{
rules: PasswordRule[]
showErrors: boolean
}>()
function iconClass(rule: PasswordRule) {
if (rule.met) {
return 'text-success'
}
return props.showErrors ? 'text-error' : 'text-muted'
}
function textClass(rule: PasswordRule) {
if (rule.met) {
return 'text-default'
}
return props.showErrors ? 'text-error' : 'text-muted'
}
function itemIcon(rule: PasswordRule) {
if (rule.met) {
return 'i-lucide-check'
}
return props.showErrors ? 'i-lucide-circle-alert' : 'i-lucide-circle'
}
const items = computed(() =>
props.rules.map((rule) => ({
...rule,
iconClass: iconClass(rule),
textClass: textClass(rule),
icon: itemIcon(rule),
})),
)
</script>
<template>
<ul class="flex flex-col gap-1.5">
<li
v-for="item in items"
:key="item.label"
class="flex items-center gap-2 text-xs/[18px]"
>
<UIcon
:name="item.icon"
:class="['size-3.5 shrink-0', item.iconClass]"
/>
<span :class="item.textClass">{{ item.label }}</span>
<span class="sr-only">{{ item.met ? 'met' : 'not met' }}</span>
</li>
</ul>
</template>
+58
View File
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
online: boolean
dirty: boolean
pending?: string
disabled?: boolean
hideIcon?: boolean
}>(),
{
pending: undefined,
disabled: false,
hideIcon: false,
},
)
defineEmits<{ publish: []; unpublish: [] }>()
const label = computed(() => {
if (props.pending) {
return props.pending
}
if (props.online) {
return 'Unpublish'
}
if (props.dirty) {
return 'Save and publish'
}
return 'Publish'
})
const icon = computed(() => {
if (props.hideIcon) {
return undefined
}
if (props.online) {
return 'i-lucide-globe-lock'
}
return 'i-lucide-globe'
})
const variant = computed(() => (props.online ? 'outline' : 'solid'))
</script>
<template>
<UButton
:color="online ? 'neutral' : 'primary'"
:variant="variant"
:icon="icon"
:disabled="disabled"
:loading="Boolean(pending)"
@click="online ? $emit('unpublish') : $emit('publish')"
>
{{ label }}
</UButton>
</template>
+74
View File
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue'
interface ResultPanelAction {
label: string
color?: 'primary' | 'neutral'
variant?: 'solid' | 'soft' | 'outline' | 'ghost'
to?: string
disabled?: boolean
onClick?: () => void
}
const props = defineProps<{
tone: 'success' | 'error' | 'info'
icon: string
title: string
description: string
actions: ResultPanelAction[]
}>()
const role = computed(() => (props.tone === 'error' ? 'alert' : 'status'))
const toneTileClass: Record<'success' | 'error' | 'info', string> = {
success: 'bg-(--rc-success-soft) text-success',
error: 'bg-(--rc-error-soft) text-error',
info: 'bg-(--rc-info-soft) text-info',
}
const tileClass = computed(() => toneTileClass[props.tone])
const titleEl = ref<HTMLHeadingElement | null>(null)
onMounted(async () => {
await nextTick()
titleEl.value?.focus()
})
</script>
<template>
<div :role="role" class="flex flex-col gap-4">
<div :class="['flex size-11 items-center justify-center', tileClass]">
<UIcon :name="icon" class="size-5.5" />
</div>
<div class="flex flex-col gap-1.5">
<h1
ref="titleEl"
tabindex="-1"
class="text-xl/7 font-semibold text-highlighted outline-none"
>
{{ title }}
</h1>
<p class="text-base text-pretty text-toned">{{ description }}</p>
<slot />
</div>
<div v-if="actions.length" class="flex w-full flex-col gap-2">
<UButton
v-for="action in actions"
:key="action.label"
block
:color="action.color ?? 'primary'"
:variant="action.variant ?? 'solid'"
:class="[
'h-11 justify-center text-base sm:h-10',
action.variant === 'outline' ? 'text-highlighted' : '',
]"
:to="action.to"
:disabled="action.disabled"
@click="action.onClick"
>
{{ action.label }}
</UButton>
</div>
</div>
</template>
+92
View File
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
const props = defineProps<{
state: 'clean' | 'dirty' | 'saving' | 'saved' | 'error' | 'offline'
savedAt?: Date | null
}>()
defineEmits<{ retry: [] }>()
const HOUR_MS = 60 * 60 * 1000
const TICK_MS = 15000
const now = ref(Date.now())
let ticker: ReturnType<typeof setInterval> | null = null
onMounted(() => {
ticker = setInterval(() => {
now.value = Date.now()
}, TICK_MS)
})
onUnmounted(() => {
if (ticker !== null) {
clearInterval(ticker)
}
})
function formatClock(date: Date) {
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
return `${hours}:${minutes}`
}
const savedText = computed(() => {
if (!props.savedAt) {
return 'Saved'
}
const diffMs = now.value - props.savedAt.getTime()
if (diffMs < HOUR_MS) {
return `Saved at ${formatClock(props.savedAt)}`
}
const minutes = Math.floor(diffMs / 60000)
return minutes === 1 ? 'Saved 1 min ago' : `Saved ${minutes} min ago`
})
</script>
<template>
<span
aria-live="polite"
class="inline-flex shrink-0 items-center gap-1.5 text-sm whitespace-nowrap"
:class="{
'text-warning': state === 'dirty',
'text-error': state === 'error',
'text-muted': state !== 'dirty' && state !== 'error',
}"
>
<template v-if="state === 'clean' || state === 'saved'">
<UIcon name="i-lucide-check" class="size-3.5" />
<span>{{ savedText }}</span>
</template>
<template v-else-if="state === 'dirty'">
<UIcon name="i-lucide-circle-dot" class="size-3.5" />
<span>Unsaved changes</span>
</template>
<template v-else-if="state === 'saving'">
<UIcon
name="i-lucide-loader-circle"
class="size-3.5 animate-spin"
/>
<span>Saving…</span>
</template>
<template v-else-if="state === 'error'">
<UIcon name="i-lucide-circle-alert" class="size-3.5" />
<span>Save failed</span>
<UButton
size="xs"
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-6 gap-1 px-2 text-xs text-highlighted"
@click="$emit('retry')"
>
Retry
</UButton>
</template>
<template v-else-if="state === 'offline'">
<UIcon name="i-lucide-wifi-off" class="size-3.5" />
<span>Waiting for connection</span>
</template>
</span>
</template>
+86
View File
@@ -0,0 +1,86 @@
<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>
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import AppLogo from '../AppLogo.vue'
describe('AppLogo renders the brand mark as inline SVG, and theme-switches', () => {
it('renders an inline svg at 32px by default, not an img tag', () => {
const wrapper = mount(AppLogo)
expect(wrapper.find('img').exists()).toBe(false)
const svg = wrapper.find('svg')
expect(svg.exists()).toBe(true)
expect(svg.attributes('width')).toBe('32')
expect(svg.attributes('height')).toBe('32')
})
it('carries alt text through role=img and aria-label, and is not wrapped in a link', () => {
const wrapper = mount(AppLogo)
const svg = wrapper.find('svg')
expect(svg.attributes('role')).toBe('img')
expect(svg.attributes('aria-label')).toBe('RenewCMS')
expect(wrapper.find('a').exists()).toBe(false)
})
it('sizes the svg through the size prop', () => {
const wrapper = mount(AppLogo, { props: { size: 48 } })
const svg = wrapper.find('svg')
expect(svg.attributes('width')).toBe('48')
expect(svg.attributes('height')).toBe('48')
})
it('has no edge stroke, in light mode or in dark mode', () => {
const wrapper = mount(AppLogo)
const square = wrapper.find('rect')
expect(square.attributes('fill')).toBe('#111111')
expect(square.attributes('stroke')).toBeUndefined()
expect(square.classes().some((c) => c.includes('stroke'))).toBe(false)
})
})
@@ -0,0 +1,86 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import AuthShell from '../AuthShell.vue'
import { resetAuthShellNavigation } from '@/composables/useAuthShellNavigation'
beforeEach(() => {
resetAuthShellNavigation()
})
function mountShell(
props: Record<string, unknown>,
slots: Record<string, unknown> = {},
) {
return mount(AuthShell, {
props,
slots,
global: {
plugins: [uiPlugin],
},
})
}
describe('AuthShell renders its title as the page heading and sets the document title', () => {
it('renders an h1 containing the title and sets document.title', () => {
const wrapper = mountShell({ title: 'Log in' })
const heading = wrapper.find('h1')
expect(heading.text()).toBe('Log in')
expect(heading.attributes('tabindex')).toBe('-1')
expect(document.title).toBe('Log in · RenewCMS')
})
})
describe('AuthShell with an empty title renders no heading and leaves document.title untouched', () => {
it('mounts directly with an empty title without rendering an h1 or touching document.title', () => {
document.title = 'Existing title · RenewCMS'
const wrapper = mountShell({ title: '' })
expect(wrapper.find('h1').exists()).toBe(false)
expect(document.title).toBe('Existing title · RenewCMS')
})
it('leaves document.title untouched and does not crash when the title changes to empty', async () => {
const wrapper = mountShell({ title: 'Create the owner account' })
expect(document.title).toBe('Create the owner account · RenewCMS')
await wrapper.setProps({ title: '' })
expect(wrapper.find('h1').exists()).toBe(false)
expect(document.title).toBe('Create the owner account · RenewCMS')
})
})
describe('AuthShell renders its notice and footer slots', () => {
it('renders the notice slot as-is inside the card, after the title, and the footer slot outside the card', () => {
const wrapper = mountShell(
{ title: 'Log in' },
{
notice: '<span>Your session expired.</span>',
footer: '<a href="/register/reset/request">Forgot password?</a>',
},
)
const notice = wrapper.find('span')
expect(notice.text()).toBe('Your session expired.')
expect(
wrapper.findAll('[data-slot="root"][data-orientation]').length,
).toBe(1)
const card = wrapper.find('[data-testid="auth-card"]')
expect(card.element.contains(notice.element)).toBe(true)
const html = wrapper.html()
expect(html.indexOf('<h1')).toBeLessThan(
html.indexOf('Your session expired.'),
)
const footerLink = wrapper.find('a[href="/register/reset/request"]')
expect(footerLink.exists()).toBe(true)
expect(footerLink.text()).toBe('Forgot password?')
expect(card.element.contains(footerLink.element)).toBe(false)
})
})
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import CharacterCounter from '../CharacterCounter.vue'
function mountCounter(value: number) {
return mount(CharacterCounter, {
props: { value, max: 10000 },
global: { plugins: [uiPlugin] },
})
}
describe('Below the warning threshold', () => {
it('shows the plain count with no icon', () => {
const wrapper = mountCounter(4318)
expect(wrapper.text()).toContain('4 318 / 10 000')
expect(
wrapper
.find('[data-testid="character-counter-warning-icon"]')
.exists(),
).toBe(false)
expect(
wrapper
.find('[data-testid="character-counter-over-icon"]')
.exists(),
).toBe(false)
})
})
describe('Near the limit — 90% or more', () => {
it('shows the warning icon without an over-limit message', () => {
const wrapper = mountCounter(9240)
expect(wrapper.text()).toContain('9 240 / 10 000')
expect(
wrapper
.find('[data-testid="character-counter-warning-icon"]')
.exists(),
).toBe(true)
expect(wrapper.text()).not.toContain('characters over')
})
})
describe('Too long — over the max', () => {
it('shows the over-limit icon and the exact overage copy', () => {
const wrapper = mountCounter(10214)
expect(wrapper.text()).toContain('10 214 / 10 000')
expect(
wrapper
.find('[data-testid="character-counter-over-icon"]')
.exists(),
).toBe(true)
expect(wrapper.text()).toContain(
'214 characters over. Shorten the text or remove an image. Formatting counts too.',
)
})
})
@@ -0,0 +1,188 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import ConfirmDialog from '../ConfirmDialog.vue'
afterEach(() => {
document.body.innerHTML = ''
})
function mountDialog(action: () => Promise<void>) {
return mount(ConfirmDialog, {
attachTo: document.body,
props: {
title: 'Cancel your registration?',
description:
'Your account will be deleted. You can register again with the same or a different email.',
confirmLabel: 'Cancel registration',
cancelLabel: 'Keep my account',
pendingLabel: 'Cancelling…',
tone: 'danger',
action,
},
global: {
plugins: [uiPlugin],
},
})
}
function findButtonElement(text: string) {
return Array.from(document.body.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === text,
)
}
async function clickButton(text: string) {
findButtonElement(text)?.click()
await flushPromises()
}
describe('Confirm cancel — the destructive dialog opens', () => {
it('renders title, description, both buttons, role and starts focus on the cancel button', async () => {
const wrapper = mountDialog(() => new Promise(() => {}))
await flushPromises()
expect(document.body.textContent).toContain('Cancel your registration?')
expect(document.body.textContent).toContain(
'Your account will be deleted. You can register again with the same or a different email.',
)
const cancelButton = findButtonElement('Keep my account')
const confirmButton = findButtonElement('Cancel registration')
expect(cancelButton).toBeTruthy()
expect(confirmButton).toBeTruthy()
expect(document.querySelector('[role="alertdialog"]')).toBeTruthy()
expect(cancelButton).toBe(document.activeElement)
wrapper.unmount()
})
})
describe('Cancelling — the action is in flight and the dialog cannot be dismissed', () => {
it('disables both buttons, shows the pending label and sets aria-busy', async () => {
let resolveAction: (() => void) | null = null
const action = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveAction = resolve
}),
)
const wrapper = mountDialog(action)
await flushPromises()
await clickButton('Cancel registration')
expect(action).toHaveBeenCalledTimes(1)
expect(
(findButtonElement('Keep my account') as HTMLButtonElement)
.disabled,
).toBe(true)
expect(findButtonElement('Cancelling…')).toBeTruthy()
expect(
(findButtonElement('Cancelling…') as HTMLButtonElement).disabled,
).toBe(true)
expect(document.querySelector('[aria-busy="true"]')).toBeTruthy()
resolveAction?.()
await flushPromises()
wrapper.unmount()
})
})
describe('Keep my account — the dialog is dismissed without deleting anything', () => {
it('emits close(false) without ever calling the action', async () => {
const action = vi.fn(() => Promise.resolve())
const wrapper = mountDialog(action)
await flushPromises()
await clickButton('Keep my account')
expect(action).not.toHaveBeenCalled()
expect(wrapper.emitted('close')).toEqual([[false]])
wrapper.unmount()
})
})
describe('Cancelled — resolving the action closes the dialog', () => {
it('emits close(true) once the action resolves', async () => {
const action = vi.fn(() => Promise.resolve())
const wrapper = mountDialog(action)
await flushPromises()
await clickButton('Cancel registration')
expect(wrapper.emitted('close')).toEqual([[true]])
wrapper.unmount()
})
})
describe('Dismissing (Esc or the overlay) defaults to the cancel outcome', () => {
it('emits close(false) without calling the action', async () => {
const action = vi.fn(() => Promise.resolve())
const wrapper = mountDialog(action)
await flushPromises()
await wrapper.setProps({ open: false })
expect(action).not.toHaveBeenCalled()
expect(wrapper.emitted('close')).toEqual([[false]])
wrapper.unmount()
})
})
describe('dismissResolvesTo="confirm" makes Esc/overlay resolve as confirmed', () => {
it('emits close(true) on dismiss without calling the action', async () => {
const action = vi.fn(() => Promise.resolve())
const wrapper = mount(ConfirmDialog, {
attachTo: document.body,
props: {
title: 'Leave without saving?',
description:
'Your changes to "How we price a bathroom renovation" haven’t been saved.',
confirmLabel: 'Keep editing',
cancelLabel: 'Discard changes',
pendingLabel: 'Keep editing',
tone: 'neutral',
action,
dismissResolvesTo: 'confirm',
},
global: {
plugins: [uiPlugin],
},
})
await flushPromises()
await wrapper.setProps({ open: false })
expect(action).not.toHaveBeenCalled()
expect(wrapper.emitted('close')).toEqual([[true]])
wrapper.unmount()
})
})
describe('Cancel failed — the dialog stays open and shows the failure inline', () => {
it('re-enables both buttons and shows the rejection message inline', async () => {
const action = vi.fn(() =>
Promise.reject(new Error("Couldn't cancel registration.")),
)
const wrapper = mountDialog(action)
await flushPromises()
await clickButton('Cancel registration')
expect(wrapper.emitted('close')).toBeFalsy()
expect(document.body.textContent).toContain(
"Couldn't cancel registration.",
)
expect(
(findButtonElement('Keep my account') as HTMLButtonElement)
.disabled,
).toBe(false)
expect(
(findButtonElement('Cancel registration') as HTMLButtonElement)
.disabled,
).toBe(false)
wrapper.unmount()
})
})
@@ -0,0 +1,178 @@
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 { useApi } from '@/composables/useApi'
import DashboardShell from '../DashboardShell.vue'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return {
...actual,
useApi: vi.fn(),
}
})
async function mountShell(path: string, username = 'marta') {
setActivePinia(createPinia())
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],
},
})
await flushPromises()
return wrapper
}
afterEach(async () => {
await router.push('/login')
document.body.innerHTML = ''
})
describe('DashboardShell renders the three navigation destinations', () => {
it('lists Home, Articles and Integration, with only Articles carrying aria-current', async () => {
const wrapper = await mountShell('/article')
const home = wrapper.find('a[href="/home"]')
const articles = wrapper.find('a[href="/article"]')
const integration = wrapper.find('a[href="/integration"]')
expect(home.exists()).toBe(true)
expect(articles.exists()).toBe(true)
expect(integration.exists()).toBe(true)
expect(articles.attributes('aria-current')).toBe('page')
expect(home.attributes('aria-current')).toBeUndefined()
expect(integration.attributes('aria-current')).toBeUndefined()
})
})
describe('DashboardShell shows the offline banner when the browser reports offline', () => {
it('shows the banner while offline and hides it once back online', async () => {
const onLineSpy = vi
.spyOn(navigator, 'onLine', 'get')
.mockReturnValue(false)
const wrapper = await mountShell('/article')
expect(wrapper.text()).toContain("You're offline")
onLineSpy.mockReturnValue(true)
window.dispatchEvent(new Event('online'))
await flushPromises()
expect(wrapper.text()).not.toContain("You're offline")
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,69 @@
import { describe, it, expect } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
import ErrorView from '../ErrorView.vue'
function makeRouter() {
return createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: { template: '<div />' } },
{ path: '/home', component: { template: '<div />' } },
{ path: '/article', component: { template: '<div />' } },
],
})
}
async function mountNotFound(breadcrumb?: string) {
const router = makeRouter()
await router.push('/')
return mount(ErrorView, {
props: breadcrumb
? { kind: 'not-found', breadcrumb }
: { kind: 'not-found' },
global: {
plugins: [router],
},
})
}
describe('ErrorView not-found kind', () => {
it('renders the not-found copy with a breadcrumb, primary and secondary actions', async () => {
const wrapper = await mountNotFound('Not found')
expect(wrapper.find('nav[aria-label="Breadcrumb"]').text()).toBe(
'Not found',
)
expect(wrapper.find('h1').text()).toBe('Page not found')
expect(wrapper.text()).toContain(
'The link may be mistyped, or the page may have moved.',
)
const buttons = wrapper.findAll('button')
expect(buttons.some((button) => button.text() === 'Go to Home')).toBe(
true,
)
expect(buttons.some((button) => button.text() === 'All articles')).toBe(
true,
)
})
it('routes the primary action to /home and the secondary action to /article', async () => {
const wrapper = await mountNotFound()
const router = wrapper.vm.$router
const primary = wrapper
.findAll('button')
.find((button) => button.text() === 'Go to Home')
await primary?.trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/home')
const secondary = wrapper
.findAll('button')
.find((button) => button.text() === 'All articles')
await secondary?.trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/article')
})
})
@@ -0,0 +1,361 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, ref } from 'vue'
import { mount, flushPromises } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import type { Article } from '@/composables/useArticles'
import NewArticleModal from '../NewArticleModal.vue'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return {
...actual,
useApi: vi.fn(),
}
})
const TITLE = 'Choosing tiles for a small bathroom'
const BODY = 'Small rooms show every grout line, so we start with the floor.'
function articleFixture(): Article {
return {
id: 42,
title: TITLE,
body: BODY,
images: [],
is_online: false,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
}
}
const Host = defineComponent({
components: { NewArticleModal },
emits: ['created'],
setup(_props, { emit }) {
const open = ref(true)
return { open, onCreated: (article: Article) => emit('created', article) }
},
template: `<NewArticleModal v-model:open="open" @created="onCreated" />`,
})
function findButton(text: string) {
return Array.from(document.body.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === text,
)
}
async function clickButton(text: string) {
findButton(text)?.click()
await flushPromises()
}
let requestMock: ReturnType<typeof vi.fn>
function mountModal() {
requestMock = vi.fn()
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn(),
request: requestMock,
})
return mount(Host, {
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
}
function titleInput() {
return document.body.querySelector<HTMLInputElement>(
'input[placeholder="e.g. Choosing tiles for a small bathroom"]',
)!
}
function bodyTextarea() {
return document.body.querySelector<HTMLTextAreaElement>(
'textarea[placeholder="Start writing. You\'ll continue in the editor."]',
)!
}
function dispatch(element: HTMLElement, type: string) {
element.dispatchEvent(new Event(type, { bubbles: true }))
}
async function fillTitle(value: string) {
const input = titleInput()
input.value = value
dispatch(input, 'input')
await flushPromises()
}
async function blurTitle() {
dispatch(titleInput(), 'blur')
await flushPromises()
}
async function fillBody(value: string) {
const textarea = bodyTextarea()
textarea.value = value
dispatch(textarea, 'input')
await flushPromises()
}
async function blurBody() {
dispatch(bodyTextarea(), 'blur')
await flushPromises()
}
async function fillValidForm() {
await fillTitle(TITLE)
await fillBody(BODY)
}
afterEach(() => {
document.body.innerHTML = ''
})
describe('Opening the modal shows the Default state', () => {
it('renders empty, focused fields with the Default copy', async () => {
mountModal()
await flushPromises()
expect(document.body.textContent).toContain('New article')
expect(document.body.textContent).toContain(
"It's saved as a draft. Titles can't be changed later, so check it now.",
)
expect(titleInput().value).toBe('')
expect(document.activeElement).toBe(titleInput())
expect(document.body.textContent).toContain('0 / 200')
expect(document.body.textContent).toContain('3 to 200 characters.')
expect(bodyTextarea().value).toBe('')
expect(document.body.textContent).toContain(
'You can format it and add images in the editor.',
)
expect(findButton('Cancel')).toBeTruthy()
expect(findButton('Create draft')).toBeTruthy()
expect(requestMock).not.toHaveBeenCalled()
})
})
describe('Validation — a title under 3 characters on blur', () => {
it('shows the error in place of help and the counter in its error style', async () => {
mountModal()
await flushPromises()
await fillTitle('Ti')
await blurTitle()
expect(document.body.textContent).toContain('Use at least 3 characters.')
expect(document.body.textContent).not.toContain('3 to 200 characters.')
expect(document.body.textContent).toContain('2 / 200')
expect(requestMock).not.toHaveBeenCalled()
})
})
describe('Validation — an empty first paragraph on blur', () => {
it('shows the error once the field is left empty and blurred', async () => {
mountModal()
await flushPromises()
await fillTitle(TITLE)
await blurTitle()
await blurBody()
expect(document.body.textContent).toContain(
'Add a first paragraph. It can be a single sentence.',
)
expect(requestMock).not.toHaveBeenCalled()
})
})
describe('Validation — submitting with both fields invalid shows both errors and sends nothing', () => {
it('shows both errors and never sends the request', async () => {
mountModal()
await flushPromises()
await fillTitle('Ti')
await clickButton('Create draft')
expect(document.body.textContent).toContain('Use at least 3 characters.')
expect(document.body.textContent).toContain(
'Add a first paragraph. It can be a single sentence.',
)
expect(requestMock).not.toHaveBeenCalled()
})
})
describe('Submitting disables the form and cannot be dismissed', () => {
it('sends the request, disables the form and blocks Esc/backdrop dismissal', async () => {
let resolveRequest: ((article: Article) => void) | null = null
requestMock = vi.fn(
() =>
new Promise<Article>((resolve) => {
resolveRequest = resolve
}),
)
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
const wrapper = mount(Host, {
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
await flushPromises()
await fillValidForm()
await clickButton('Create draft')
expect(requestMock).toHaveBeenCalledWith(
'/v1/article/',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ title: TITLE, body: BODY }),
}),
)
expect(titleInput().disabled).toBe(true)
expect(bodyTextarea().disabled).toBe(true)
expect((findButton('Cancel') as HTMLButtonElement).disabled).toBe(true)
expect(findButton('Creating…')).toBeTruthy()
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
keyCode: 27,
which: 27,
bubbles: true,
cancelable: true,
}),
)
await flushPromises()
expect(document.querySelector('[role="dialog"]')).toBeTruthy()
resolveRequest?.(articleFixture())
await flushPromises()
wrapper.unmount()
})
})
describe('A successful create closes the modal and hands off to the editor with a toast', () => {
it('closes the modal and emits created with the response body', async () => {
requestMock = vi.fn().mockResolvedValue(articleFixture())
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
const wrapper = mount(Host, {
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
await flushPromises()
await fillValidForm()
await clickButton('Create draft')
await flushPromises()
expect(wrapper.emitted('created')).toEqual([[articleFixture()]])
expect(document.querySelector('[role="dialog"]')).toBeFalsy()
wrapper.unmount()
})
})
describe("Title rejected — a 400 that got past this form's own check", () => {
it('keeps the values, shows the title error and the form alert', async () => {
requestMock = vi.fn().mockRejectedValue(new ApiError(400))
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
const wrapper = mount(Host, {
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
await flushPromises()
await fillValidForm()
await clickButton('Create draft')
await flushPromises()
expect(titleInput().value).toBe(TITLE)
expect(bodyTextarea().value).toBe(BODY)
expect(document.body.textContent).toContain('Check this title and try again.')
expect(document.body.textContent).toContain(
"Something in the form isn't valid. Check the highlighted fields.",
)
expect(document.body.textContent).not.toContain(
'Add a first paragraph. It can be a single sentence.',
)
expect(titleInput().disabled).toBe(false)
wrapper.unmount()
})
})
describe('Error — a 500 or a network failure keeps the text and offers to retry', () => {
it.each([
{ name: 'a 500 response', error: () => new ApiError(500) },
{ name: 'a network failure', error: () => new NetworkError() },
])('keeps the values and shows the generic alert for $name', async ({ error }) => {
requestMock = vi.fn().mockRejectedValue(error())
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
const wrapper = mount(Host, {
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
await flushPromises()
await fillValidForm()
await clickButton('Create draft')
await flushPromises()
expect(titleInput().value).toBe(TITLE)
expect(bodyTextarea().value).toBe(BODY)
expect(document.body.textContent).toContain(
"Couldn't create the article. Your text is still here. Try again in a moment.",
)
expect(document.body.textContent).not.toContain('Check this title and try again.')
expect(document.body.textContent).not.toContain(
'Add a first paragraph. It can be a single sentence.',
)
wrapper.unmount()
})
})
describe('Retrying after an Error succeeds', () => {
it('sends the same body again and hands off to the editor on success', async () => {
requestMock = vi.fn().mockRejectedValueOnce(new ApiError(500))
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
const wrapper = mount(Host, {
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
await flushPromises()
await fillValidForm()
await clickButton('Create draft')
await flushPromises()
requestMock.mockResolvedValueOnce(articleFixture())
await clickButton('Create draft')
await flushPromises()
expect(requestMock).toHaveBeenCalledTimes(2)
expect(requestMock).toHaveBeenNthCalledWith(
2,
'/v1/article/',
expect.objectContaining({
body: JSON.stringify({ title: TITLE, body: BODY }),
}),
)
expect(wrapper.emitted('created')).toEqual([[articleFixture()]])
expect(document.querySelector('[role="dialog"]')).toBeFalsy()
wrapper.unmount()
})
})
describe('Cancel discards the fields with no confirmation', () => {
it('closes the modal immediately without sending a request', async () => {
mountModal()
await flushPromises()
await fillValidForm()
await clickButton('Cancel')
expect(document.querySelector('[role="dialog"]')).toBeFalsy()
expect(requestMock).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import PublishControl from '../PublishControl.vue'
function mountControl(props: {
online: boolean
dirty: boolean
disabled?: boolean
}) {
return mount(PublishControl, {
props,
global: { plugins: [uiPlugin] },
})
}
describe('Draft, clean', () => {
it('reads "Publish"', () => {
const wrapper = mountControl({ online: false, dirty: false })
expect(wrapper.text()).toBe('Publish')
})
})
describe('Draft, dirty', () => {
it('reads "Save and publish"', () => {
const wrapper = mountControl({ online: false, dirty: true })
expect(wrapper.text()).toBe('Save and publish')
})
})
describe('Online', () => {
it('reads "Unpublish" regardless of dirty state', () => {
const wrapper = mountControl({ online: true, dirty: true })
expect(wrapper.text()).toBe('Unpublish')
})
})
describe('Disabled', () => {
it('disables the button', () => {
const wrapper = mountControl({
online: false,
dirty: false,
disabled: true,
})
expect(
(wrapper.find('button').element as HTMLButtonElement).disabled,
).toBe(true)
})
})
@@ -0,0 +1,87 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import SaveStatus from '../SaveStatus.vue'
afterEach(() => {
vi.useRealTimers()
})
describe('Saved within the hour', () => {
it('reads "Saved at HH:MM"', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-26T11:10:00Z'))
const wrapper = mount(SaveStatus, {
props: {
state: 'saved',
savedAt: new Date('2026-09-26T11:02:00Z'),
},
global: { plugins: [uiPlugin] },
})
expect(wrapper.text()).toContain('Saved at 11:02')
})
})
describe('Saved over an hour ago', () => {
it('reads "Saved n min ago"', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-26T12:32:00Z'))
const wrapper = mount(SaveStatus, {
props: {
state: 'saved',
savedAt: new Date('2026-09-26T11:02:00Z'),
},
global: { plugins: [uiPlugin] },
})
expect(wrapper.text()).toContain('Saved 90 min ago')
})
})
describe('Dirty', () => {
it('reads "Unsaved changes"', () => {
const wrapper = mount(SaveStatus, {
props: { state: 'dirty' },
global: { plugins: [uiPlugin] },
})
expect(wrapper.text()).toContain('Unsaved changes')
})
})
describe('Saving', () => {
it('reads "Saving…"', () => {
const wrapper = mount(SaveStatus, {
props: { state: 'saving' },
global: { plugins: [uiPlugin] },
})
expect(wrapper.text()).toContain('Saving…')
})
})
describe('Error', () => {
it('reads "Save failed" and emits retry from the Retry button', async () => {
const wrapper = mount(SaveStatus, {
props: { state: 'error' },
global: { plugins: [uiPlugin] },
})
expect(wrapper.text()).toContain('Save failed')
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('retry')).toHaveLength(1)
})
})
describe('Offline', () => {
it('reads "Waiting for connection"', () => {
const wrapper = mount(SaveStatus, {
props: { state: 'offline' },
global: { plugins: [uiPlugin] },
})
expect(wrapper.text()).toContain('Waiting for connection')
})
})
@@ -0,0 +1,175 @@
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()
})
})
@@ -0,0 +1,246 @@
import { describe, it, expect, afterEach, beforeEach } 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 AuthShell from '@/components/AuthShell.vue'
import LoginView from '@/views/LoginView.vue'
import { resetAuthShellNavigation } from '@/composables/useAuthShellNavigation'
function stubMatchMedia(belowSm: boolean) {
window.matchMedia = ((query: string) => ({
matches: belowSm,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia
}
function mockResponse(status: number, body: unknown = null) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
} as unknown as Response
}
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountLogin() {
setActivePinia(createPinia())
global.fetch = (() =>
mockResponse(200, { open: false })) as unknown as typeof fetch
await router.push('/login')
const wrapper = mount(LoginView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
currentWrapper = wrapper
await flushPromises()
return wrapper
}
beforeEach(() => {
resetAuthShellNavigation()
})
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
await router.push('/login')
})
describe('AppLogo renders the brand mark as inline SVG, and theme-switches', () => {
it("renders as UPageCard's first element, inside the card, left-aligned, at 32px, not as an img, alt text, not a link", () => {
const wrapper = mount(AuthShell, {
props: { title: 'Log in', description: 'Welcome back.' },
global: { plugins: [uiPlugin] },
})
const card = wrapper.find('[data-testid="auth-card"]')
const svg = wrapper.find('svg')
expect(svg.exists()).toBe(true)
expect(svg.attributes('width')).toBe('32')
expect(card.element.contains(svg.element)).toBe(true)
const html = wrapper.html()
expect(html.indexOf('<svg')).toBeGreaterThan(
html.indexOf('data-testid="auth-card"'),
)
expect(html.indexOf('<svg')).toBeLessThan(html.indexOf('<h1'))
let centeringAncestor = svg.element.parentElement
while (centeringAncestor && centeringAncestor !== card.element) {
expect(centeringAncestor.className).not.toMatch(
/items-center|justify-center|text-center/,
)
centeringAncestor = centeringAncestor.parentElement
}
const header = wrapper.find('[data-slot="header"]')
expect(header.exists()).toBe(true)
expect(header.classes()).toContain('mb-6')
expect(header.classes()).not.toContain('mb-4')
expect(wrapper.find('img').exists()).toBe(false)
expect(svg.attributes('role')).toBe('img')
expect(svg.attributes('aria-label')).toBe('RenewCMS')
expect(svg.element.closest('a')).toBeNull()
})
})
describe("UPageCard's title is the page h1, and its description uses UPageCard's own styling", () => {
it('renders variant outline, the title through the title slot as h1, and the description through the prop', () => {
const wrapper = mount(AuthShell, {
props: {
title: 'Log in',
description: 'Welcome back. Log in to manage your articles.',
},
global: { plugins: [uiPlugin] },
})
const card = wrapper.find('[data-testid="auth-card"]')
expect(card.exists()).toBe(true)
const heading = wrapper.find('h1')
expect(heading.text()).toBe('Log in')
expect(heading.attributes('tabindex')).toBe('-1')
expect(wrapper.text()).toContain(
'Welcome back. Log in to manage your articles.',
)
expect(wrapper.findAll('p').length).toBe(0)
})
})
describe('The page sits on the muted background from sm up, and the card keeps its border', () => {
it('uses the muted background token, keeps the card border, capped width and 12vh top offset', () => {
stubMatchMedia(false)
const wrapper = mount(AuthShell, {
props: { title: 'Log in', description: 'Welcome back.' },
global: { plugins: [uiPlugin] },
})
const main = wrapper.find('main')
expect(main.classes()).toContain('bg-muted')
expect(main.classes()).not.toContain('bg-default')
expect(main.classes()).toContain('pt-[12vh]')
const card = wrapper.find('[data-testid="auth-card"]')
expect(card.classes()).toContain('max-w-[400px]')
expect(card.classes()).toContain('border')
const container = wrapper.find('[data-slot="container"]')
expect(container.classes()).not.toContain('p-5')
})
})
describe('Below sm, AuthShell drops the muted background, the card border and the card padding', () => {
it('uses the default background, an unbordered full-width card with the board padding split, and xl controls', async () => {
stubMatchMedia(true)
const wrapper = mount(AuthShell, {
props: { title: 'Log in', description: 'Welcome back.' },
slots: {
default: `<template #default="{ controlSize }"><button :data-size="controlSize">Submit</button></template>`,
},
global: { plugins: [uiPlugin] },
})
await flushPromises()
const main = wrapper.find('main')
expect(main.classes()).toContain('bg-default')
expect(main.classes()).not.toContain('bg-muted')
expect(main.classes()).not.toContain('pt-[12vh]')
const card = wrapper.find('[data-testid="auth-card"]')
expect(card.classes()).not.toContain('max-w-[400px]')
expect(card.classes()).toContain('ring-0')
const button = wrapper.find('button')
expect(button.attributes('data-size')).toBe('xl')
const container = wrapper.find('[data-slot="container"]')
expect(container.classes()).toContain('pt-2')
expect(container.classes()).toContain('px-5')
expect(container.classes()).toContain('pb-0')
})
})
describe('The login form is a UForm', () => {
it('wraps the Username field, the Password field and the submit button in a UForm, not a plain form', async () => {
const wrapper = await mountLogin()
const form = wrapper.find('form')
expect(form.exists()).toBe(true)
expect(form.attributes('id')).toMatch(/^v-/)
expect(form.find('input[placeholder="Your username"]').exists()).toBe(
true,
)
expect(
form.find('input[autocomplete="current-password"]').exists(),
).toBe(true)
expect(form.find('button[type="submit"]').exists()).toBe(true)
})
})
describe('"Forgot password?" renders in the Password field\'s hint slot', () => {
it('renders the link through the hint slot, and the label reads only Password', async () => {
const wrapper = await mountLogin()
const passwordInput = wrapper.find(
'input[autocomplete="current-password"]',
)
const fieldRoot = passwordInput.element.closest(
'[data-slot="root"][data-orientation]',
)
expect(fieldRoot).toBeTruthy()
const hint = fieldRoot!.querySelector('[data-slot="hint"]')
expect(hint).toBeTruthy()
expect(hint!.textContent?.trim()).toBe('Forgot password?')
expect(
hint!.querySelector('a[href="/register/reset/request"]'),
).toBeTruthy()
const label = fieldRoot!.querySelector('[data-slot="label"]')
expect(label!.textContent?.trim()).toBe('Password')
})
})
describe('Inputs are neutral by default and turn to the error edge only when their field has an error', () => {
it('starts neutral, then turns to the error edge with the field messages on invalid submit', async () => {
const wrapper = await mountLogin()
const usernameInput = wrapper.find('input[placeholder="Your username"]')
const passwordInput = wrapper.find(
'input[autocomplete="current-password"]',
)
expect(usernameInput.classes()).toContain('ring-accented')
expect(usernameInput.classes()).not.toContain('ring-error')
expect(passwordInput.classes()).toContain('ring-accented')
expect(passwordInput.classes()).not.toContain('ring-error')
await usernameInput.setValue('ma')
await wrapper.find('form').trigger('submit')
await flushPromises()
const usernameAfter = wrapper.find('input[placeholder="Your username"]')
const passwordAfter = wrapper.find(
'input[autocomplete="current-password"]',
)
expect(usernameAfter.classes()).toContain('ring-error')
expect(usernameAfter.classes()).not.toContain('ring-accented')
expect(passwordAfter.classes()).toContain('ring-error')
expect(passwordAfter.classes()).not.toContain('ring-accented')
expect(wrapper.text()).toContain('Use at least 3 characters.')
expect(wrapper.text()).toContain('Enter your password.')
})
})
@@ -0,0 +1,212 @@
import { describe, it, expect, afterEach, beforeEach } 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 AuthShell from '@/components/AuthShell.vue'
import LoginView from '@/views/LoginView.vue'
import { resetAuthShellNavigation } from '@/composables/useAuthShellNavigation'
function stubMatchMedia(belowSm: boolean) {
window.matchMedia = ((query: string) => ({
matches: belowSm,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia
}
function mockResponse(status: number, body: unknown = null) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
} as unknown as Response
}
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountLogin(path = '/login') {
setActivePinia(createPinia())
global.fetch = (() =>
mockResponse(200, { open: false })) as unknown as typeof fetch
await router.push(path)
const wrapper = mount(LoginView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
currentWrapper = wrapper
await flushPromises()
return wrapper
}
beforeEach(() => {
resetAuthShellNavigation()
})
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
await router.push('/login')
resetAuthShellNavigation()
})
describe('"Forgot password?" is underlined and muted', () => {
it('uses the muted toned color, underline and 3px offset, no other emphasis', async () => {
const wrapper = await mountLogin()
const link = wrapper.find('a[href="/register/reset/request"]')
expect(link.classes()).toContain('text-toned')
expect(link.classes()).toContain('underline')
expect(link.classes()).toContain('underline-offset-[3px]')
expect(link.classes()).not.toContain('text-primary')
expect(link.classes()).not.toContain('font-bold')
expect(link.classes()).not.toContain('font-semibold')
})
})
describe('A field error shows the circle-alert icon with no extra focus halo', () => {
it('precedes both field errors with the error-colored icon, and keeps a single error ring when focused', async () => {
const wrapper = await mountLogin()
await wrapper.find('input[placeholder="Your username"]').setValue('ma')
await wrapper.find('form').trigger('submit')
await flushPromises()
const fieldRoots = wrapper.findAll(
'[data-slot="root"][data-orientation]',
)
const usernameError = fieldRoots[0]!.find('[data-slot="error"]')
const passwordError = fieldRoots[1]!.find('[data-slot="error"]')
for (const error of [usernameError, passwordError]) {
const icon = error.find('svg')
expect(icon.exists()).toBe(true)
expect(icon.html()).toContain('M12 8v4m0 4h.01')
expect(icon.classes()).toContain('text-error')
}
const usernameInput = wrapper.find('input[placeholder="Your username"]')
await usernameInput.trigger('focus')
expect(usernameInput.classes()).toContain('ring-error')
expect(usernameInput.classes()).toContain('focus-visible:ring-error')
expect(usernameInput.classes()).not.toContain('ring-primary')
expect(usernameInput.classes()).not.toContain(
'focus-visible:ring-primary',
)
})
})
describe('The theme select is right-aligned in the footer with no chevron', () => {
it('renders as the last child, showing only the mode icon and label', async () => {
stubMatchMedia(false)
const wrapper = await mountLogin()
const footer = wrapper.find('footer')
const select = footer.find('button[aria-haspopup="listbox"]')
expect(select.exists()).toBe(true)
const children = Array.from(footer.element.children)
expect(children[children.length - 1]).toBe(select.element)
expect(select.text()).toContain('System')
const trailing = select.find('[data-slot="trailing"]')
expect(trailing.classes()).toContain('hidden')
})
})
describe('The alert renders inside the card, under the description', () => {
it.each([
{
path: '/login?redirect=/article/12/edit&reason=expired',
state: 'Session expired',
text: 'Your session expired',
},
{
path: '/login?reason=password-changed',
state: 'Password changed',
text: 'Password changed',
},
])(
'places the $state alert inside the card, after the title/description, before Username',
async ({ path, text }) => {
const wrapper = await mountLogin(path)
const card = wrapper.find('[data-testid="auth-card"]')
const alerts = wrapper.findAll('[role="alert"], [role="status"]')
expect(alerts.length).toBe(1)
const alert = alerts[0]!
expect(card.element.contains(alert.element)).toBe(true)
expect(alert.text()).toContain(text)
const rootsInCard = card.findAll(
'[data-slot="root"][data-orientation]',
)
expect(
rootsInCard.some((root) =>
root.classes().includes('bg-primary'),
),
).toBe(false)
const usernameInput = wrapper.find(
'input[placeholder="Your username"]',
)
const html = card.html()
expect(html.indexOf('Welcome back')).toBeGreaterThan(-1)
expect(html.indexOf('Welcome back')).toBeLessThan(
html.indexOf(text),
)
expect(html.indexOf(text)).toBeLessThan(html.indexOf('Username'))
expect(card.element.contains(usernameInput.element)).toBe(true)
expect(
alert
.classes()
.some((c) => /^bg-\(--rc-(info|success)-soft\)$/.test(c)),
).toBe(true)
},
)
})
describe('The h1 is not focused on first load', () => {
it('leaves focus on the document body after AuthShell mounts', () => {
const wrapper = mount(AuthShell, {
props: { title: 'Log in', description: 'Welcome back.' },
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
const heading = wrapper.find('h1')
expect(heading.element).not.toBe(document.activeElement)
wrapper.unmount()
})
})
describe('Focus moves to the h1 only on an in-app navigation between public screens', () => {
it('does not focus the first AuthShell mount, but focuses the second', () => {
const first = mount(AuthShell, {
props: { title: 'Log in', description: 'Welcome back.' },
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
expect(first.find('h1').element).not.toBe(document.activeElement)
first.unmount()
const second = mount(AuthShell, {
props: { title: 'Forgot password?', description: 'Reset it.' },
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
expect(second.find('h1').element).toBe(document.activeElement)
expect(second.find('h1').classes()).toContain('outline-none')
second.unmount()
})
})
@@ -0,0 +1,211 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import { ApiError, NetworkError, isOffline, useApi } from '@/composables/useApi'
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 setOnline(value: boolean) {
Object.defineProperty(navigator, 'onLine', { configurable: true, value })
}
async function expectApiError(
call: Promise<unknown>,
matcher: Record<string, unknown>,
) {
await expect(call).rejects.toBeInstanceOf(ApiError)
await expect(call).rejects.toMatchObject(matcher)
}
async function settle() {
await flushPromises()
await new Promise((resolve) => setTimeout(resolve, 20))
}
const fetchMock = vi.fn()
beforeEach(() => {
setActivePinia(createPinia())
fetchMock.mockReset()
global.fetch = fetchMock as unknown as typeof fetch
setOnline(true)
isOffline.value = false
})
afterEach(async () => {
await router.push('/login')
})
describe('useApi global 401/403/429/offline handling', () => {
it('A successful authenticated request returns the parsed body', async () => {
fetchMock.mockResolvedValue(mockResponse(200, { id: 5 }))
const result = await useApi().request('/v1/article/5')
expect(result).toEqual({ id: 5 })
expect(isOffline.value).toBe(false)
expect(fetchMock).toHaveBeenCalledWith(
'/v1/article/5',
expect.objectContaining({ credentials: 'include' }),
)
})
it('A 401 received mid-session redirects to login with the expired reason and clears the session', async () => {
const session = useSessionStore()
session.session = { verified: true }
session.status = 'ready'
await router.push('/article/12/edit')
fetchMock.mockResolvedValue(mockResponse(401))
const call = useApi().request('/v1/article/12')
await expectApiError(call, { status: 401 })
expect(session.session).toBeNull()
expect(session.status).toBe('ready')
await vi.waitFor(() =>
expect(router.currentRoute.value.fullPath).toBe(
'/login?redirect=/article/12/edit&reason=expired',
),
)
})
it('The boot call to getMe does not trigger the global 401 redirect', async () => {
const session = useSessionStore()
session.session = null
session.status = 'checking'
await router.push('/login')
const pathBefore = router.currentRoute.value.fullPath
fetchMock.mockResolvedValue(mockResponse(401))
const call = useApi().getMe()
await expectApiError(call, { status: 401 })
await settle()
expect(router.currentRoute.value.fullPath).toBe(pathBefore)
})
it('A 403 while the session is known to be unverified redirects to the pending page', async () => {
const session = useSessionStore()
session.session = { verified: false }
session.status = 'ready'
fetchMock.mockResolvedValue(mockResponse(403))
const call = useApi().request('/v1/article/')
await expectApiError(call, { status: 403 })
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/register/pending'),
)
expect(session.session).toEqual({ verified: false })
})
it.each([
{ name: 'null', session: null },
{ name: 'verified true', session: { verified: true } },
])(
'A 403 that is not the global unverified case passes through untouched ($name)',
async ({ session: sessionValue }) => {
const session = useSessionStore()
session.session = sessionValue
session.status = 'ready'
await router.push('/login')
const pathBefore = router.currentRoute.value.fullPath
fetchMock.mockResolvedValue(mockResponse(403))
const call = useApi().request('/v1/some/endpoint')
await expectApiError(call, { status: 403 })
await settle()
expect(router.currentRoute.value.fullPath).toBe(pathBefore)
expect(session.session).toEqual(sessionValue)
},
)
it('A 403 with onForbidden passthrough never redirects even when the session is unverified', async () => {
const session = useSessionStore()
session.session = { verified: false }
session.status = 'ready'
await router.push('/login')
const pathBefore = router.currentRoute.value.fullPath
fetchMock.mockResolvedValue(mockResponse(403))
const call = useApi().request('/v1/auth/register', {
method: 'DELETE',
onForbidden: 'passthrough',
})
await expectApiError(call, { status: 403 })
await settle()
expect(router.currentRoute.value.fullPath).toBe(pathBefore)
expect(session.session).toEqual({ verified: false })
})
it('A 429 on a rate-limited endpoint exposes the Retry-After seconds', async () => {
fetchMock.mockResolvedValue(
mockResponse(429, null, { 'Retry-After': '30' }),
)
const call = useApi().request('/v1/auth/login', { method: 'POST' })
await expectApiError(call, { status: 429, retryAfter: 30 })
})
it('A 429 with no Retry-After header exposes an undefined retryAfter', async () => {
fetchMock.mockResolvedValue(mockResponse(429))
const call = useApi().request('/v1/auth/password-reset', {
method: 'POST',
})
await expectApiError(call, { status: 429, retryAfter: undefined })
})
it('A network failure throws a NetworkError and marks the app offline', async () => {
setOnline(true)
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'))
const call = useApi().request('/v1/article/')
await expect(call).rejects.toBeInstanceOf(NetworkError)
await expect(call).rejects.not.toBeInstanceOf(ApiError)
expect(isOffline.value).toBe(true)
})
it('A later successful request clears the offline flag', async () => {
isOffline.value = true
setOnline(true)
fetchMock.mockResolvedValue(mockResponse(200, {}))
await useApi().request('/v1/article/')
expect(isOffline.value).toBe(false)
})
it("The browser's own online/offline events also drive the shared flag", () => {
isOffline.value = false
window.dispatchEvent(new Event('offline'))
expect(isOffline.value).toBe(true)
window.dispatchEvent(new Event('online'))
expect(isOffline.value).toBe(false)
})
})
@@ -0,0 +1,127 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import {
useArticleDraft,
readMirroredDraft,
} from '@/composables/useArticleDraft'
function mockResponse(status: number, body: unknown = null) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
} as unknown as Response
}
afterEach(() => {
sessionStorage.clear()
vi.restoreAllMocks()
})
describe('Loading an article without a mirrored draft starts clean', () => {
it('sets the body and clean state from the given article', () => {
setActivePinia(createPinia())
const draft = useArticleDraft(12)
draft.load('<p>Original</p>', new Date('2026-09-26T11:02:00Z'))
expect(draft.body.value).toBe('<p>Original</p>')
expect(draft.state.value).toBe('clean')
expect(draft.savedAt.value).toEqual(new Date('2026-09-26T11:02:00Z'))
})
})
describe('Loading an article with a mirrored draft restores it as dirty', () => {
it('prefers the sessionStorage body over the loaded one', () => {
setActivePinia(createPinia())
sessionStorage.setItem('renewcms:article-draft:12', '<p>Restored</p>')
const draft = useArticleDraft(12)
draft.load('<p>Original</p>', new Date('2026-09-26T11:02:00Z'))
expect(draft.body.value).toBe('<p>Restored</p>')
expect(draft.state.value).toBe('dirty')
})
})
describe('Editing moves the state to dirty', () => {
it('updates the body and sets state to dirty', () => {
setActivePinia(createPinia())
const draft = useArticleDraft(12)
draft.load('<p>Original</p>', new Date())
draft.edit('<p>Changed</p>')
expect(draft.body.value).toBe('<p>Changed</p>')
expect(draft.state.value).toBe('dirty')
})
})
describe('A successful save sets state saved and records the time', () => {
it('resolves "saved" and clears any mirrored draft', async () => {
setActivePinia(createPinia())
global.fetch = vi
.fn()
.mockResolvedValue(
mockResponse(200, { id: 12, body: '<p>Changed</p>' }),
)
const draft = useArticleDraft(12)
draft.load('<p>Original</p>', new Date())
draft.edit('<p>Changed</p>')
const outcome = await draft.save()
expect(outcome).toBe('saved')
expect(draft.state.value).toBe('saved')
expect(draft.savedAt.value).toBeInstanceOf(Date)
})
})
describe('A 500 response sets state error and keeps the body', () => {
it('resolves "error" without touching the body', async () => {
setActivePinia(createPinia())
global.fetch = vi.fn().mockResolvedValue(mockResponse(500))
const draft = useArticleDraft(12)
draft.load('<p>Original</p>', new Date())
draft.edit('<p>Changed</p>')
const outcome = await draft.save()
expect(outcome).toBe('error')
expect(draft.state.value).toBe('error')
expect(draft.body.value).toBe('<p>Changed</p>')
})
})
describe('A 404 response keeps the body and does not enter the error state', () => {
it('resolves "not-found" and stays dirty', async () => {
setActivePinia(createPinia())
global.fetch = vi.fn().mockResolvedValue(mockResponse(404))
const draft = useArticleDraft(12)
draft.load('<p>Original</p>', new Date())
draft.edit('<p>Changed</p>')
const outcome = await draft.save()
expect(outcome).toBe('not-found')
expect(draft.state.value).toBe('dirty')
})
})
describe('A 401 response mirrors the body to sessionStorage', () => {
it('resolves "unauthorized" and stores the body keyed by the article id', async () => {
setActivePinia(createPinia())
global.fetch = vi.fn().mockResolvedValue(mockResponse(401))
const draft = useArticleDraft(12)
draft.load('<p>Original</p>', new Date())
draft.edit('<p>Paragraph added just before the session expired.</p>')
const outcome = await draft.save()
expect(outcome).toBe('unauthorized')
expect(readMirroredDraft(12)).toBe(
'<p>Paragraph added just before the session expired.</p>',
)
})
})
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff. Show more