diff --git a/features/login_screen.feature b/features/login_screen.feature
index 6326467..2b87894 100644
--- a/features/login_screen.feature
+++ b/features/login_screen.feature
@@ -74,8 +74,11 @@ Feature: Log in screen
"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"
diff --git a/features/login_screen.qa.md b/features/login_screen.qa.md
index d06836a..8cab362 100644
--- a/features/login_screen.qa.md
+++ b/features/login_screen.qa.md
@@ -28,8 +28,9 @@ route starts at the path each case needs.
`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; advance fake timers by 42 seconds;
- assert the button re-enables and reads "Log in".
+ 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
@@ -71,12 +72,12 @@ Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Log in".
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 button with the live "Try again in
- m:ss" countdown text, desktop and mobile, light and dark.
-- [ ] Session expired state: info alert (clock icon), focused Password field, desktop and mobile,
- light and dark.
-- [ ] Password changed state: success alert (circle-check icon, dismissible close button per
- components.md), 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?" →
diff --git a/features/pending_screen.feature b/features/pending_screen.feature
index ef75aac..b6e224f 100644
--- a/features/pending_screen.feature
+++ b/features/pending_screen.feature
@@ -53,12 +53,12 @@ Feature: Pending registration screen
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, the app
- navigates to /home, and a toast (color "info", icon "i-lucide-info", title "Your account is
- already verified", description "There's nothing to cancel.") is shown; PendingPage does not
- render a ResultPanel for this case, since the ResultPanel drawn in this state on the design
- board is Verify's own Success panel, reused purely to depict the destination page behind the
- toast
+ 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
@@ -126,17 +126,18 @@ Feature: Pending registration screen
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 refreshes the session and navigates to /home with a toast
+ 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 the app navigates to /home
- And a toast is shown with color "info", icon "i-lucide-info", title "Your account is already
- verified", description "There's nothing to cancel."
- And no ResultPanel is rendered by PendingPage for this transition
+ 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
diff --git a/features/pending_screen.qa.md b/features/pending_screen.qa.md
index e560a9e..da3ae6e 100644
--- a/features/pending_screen.qa.md
+++ b/features/pending_screen.qa.md
@@ -34,10 +34,10 @@ a test Pinia session store. Seed/clear `sessionStorage["renewcms:pendingEmail"]`
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 router's current path is /home; assert a toast was raised with
- color "info", icon "i-lucide-info", title "Your account is already verified", description
- "There's nothing to cancel."; assert no ResultPanel assertions target this transition (Pending
- unmounts as the route changes).
+ 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
@@ -63,8 +63,8 @@ Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Pending".
- [ ] 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: toast appearance over the /home board (per the design board's
- illustrative use of Verify's Success ResultPanel as the backdrop).
+- [ ] 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.
diff --git a/features/verify_screen.feature b/features/verify_screen.feature
index c24b0bf..6f01a24 100644
--- a/features/verify_screen.feature
+++ b/features/verify_screen.feature
@@ -23,14 +23,16 @@ Feature: Verify account screen
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 and the text "Verifying your account…",
+ "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
- 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 a toast (color "success", icon "i-lucide-circle-check",
- title "Your account is verified", description "Welcome to RenewCMS.") is shown
+ 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.",
@@ -61,8 +63,10 @@ Feature: Verify account screen
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 is disabled until both fields are non-empty; on submit it calls
- POST /v1/auth/login with { username, password }
+ 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
@@ -85,10 +89,10 @@ Feature: Verify account screen
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
- And a toast is shown with color "success", icon "i-lucide-circle-check", title "Your account is
- verified", description "Welcome to RenewCMS."
Scenario: Invalid or expired — 403 offers a way to start over
Given the user is on "/register/validate?c=abc123" in the Verifying state
@@ -142,11 +146,12 @@ Feature: Verify account screen
toggle, and a submit button "Log in and verify"
And there is no "Forgot password?" link and no registration-open footer
- Scenario: No session — submit is disabled until both fields are filled
+ 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 disabled
- When the user types "marta" into Username and "limewash2026" into Password
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"
@@ -171,7 +176,8 @@ Feature: Verify account screen
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 is disabled until the countdown ends
+ 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
diff --git a/features/verify_screen.qa.md b/features/verify_screen.qa.md
index 7aa1d12..fce19ee 100644
--- a/features/verify_screen.qa.md
+++ b/features/verify_screen.qa.md
@@ -7,14 +7,15 @@ Mock `useApi` (`POST /v1/auth/verify`, `POST /v1/auth/login`, `DELETE /v1/auth/r
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 text
+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); advance fake timers by
- 800 ms; assert `retry()` was called; assert the router's current path is /home and a toast was
- raised with title "Your account is verified", description "Welcome to RenewCMS."
+ "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).
@@ -38,8 +39,9 @@ needs; use fake timers for the 800 ms success delay and the 429 countdown.
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 — submit disabled until filled** — from state 7, assert the submit button is
- disabled; fill both fields; assert it becomes enabled.
+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
@@ -67,7 +69,8 @@ Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Verify". R
- [ ] 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, transient toast over the /home board.
+- [ ] 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).
diff --git a/web/src/assets/main.css b/web/src/assets/main.css
index eb004bd..4ed6cd9 100644
--- a/web/src/assets/main.css
+++ b/web/src/assets/main.css
@@ -155,6 +155,11 @@
--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 {
@@ -179,6 +184,11 @@
--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);
}
diff --git a/web/src/components/AuthShell.vue b/web/src/components/AuthShell.vue
index 2e5b942..e8afbf4 100644
--- a/web/src/components/AuthShell.vue
+++ b/web/src/components/AuthShell.vue
@@ -33,29 +33,38 @@ onUnmounted(() => {
})
const controlSize = computed(() => (belowSm.value ? 'xl' : undefined))
-const controlClass = computed(() => (belowSm.value ? '!h-11 !text-base' : '!h-10 !text-sm'))
+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',
- belowSm.value ? 'bg-default' : 'bg-muted pt-[12vh]',
+ '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 ? 'ring-0' : '',
- container: belowSm.value ? 'pt-2 px-5 pb-0' : 'p-10',
- header: 'mb-6',
- title: 'text-xl font-semibold leading-7',
+ 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(() => [
- 'mt-4 flex w-full items-center justify-between',
- belowSm.value ? 'pt-0 px-5 pb-6' : 'p-0',
+ '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 = { trailing: 'hidden' }
+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,
@@ -96,16 +105,20 @@ watch(