From d493a181e17a87e91e69e019bc7c0125d4d5bc8e Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Sat, 26 Sep 2026 21:04:51 +0200 Subject: [PATCH] fix: visuals reference matching overhaul and better workflow --- features/login_screen.feature | 3 + features/login_screen.qa.md | 17 ++-- features/pending_screen.feature | 23 ++--- features/pending_screen.qa.md | 12 +-- features/verify_screen.feature | 32 ++++--- features/verify_screen.qa.md | 17 ++-- web/src/assets/main.css | 10 ++ web/src/components/AuthShell.vue | 47 +++++---- web/src/components/ConfirmDialog.vue | 11 ++- web/src/components/PasswordStrengthHint.vue | 28 +++--- web/src/components/ResultPanel.vue | 27 ++++-- .../authShellLoginConformance.spec.ts | 4 +- .../__tests__/loginCardConformance.spec.ts | 56 +---------- web/src/lib/verifiedRedirect.ts | 5 + web/src/theme/ui.config.ts | 96 +++++++++++++++++-- web/src/views/HomeView.vue | 8 +- web/src/views/LoginView.vue | 46 +++++---- web/src/views/PendingView.vue | 17 +++- web/src/views/RegisterView.vue | 42 +++++--- web/src/views/VerifyView.vue | 90 +++++++++++------ web/src/views/__tests__/LoginView.spec.ts | 7 ++ web/src/views/__tests__/PendingView.spec.ts | 11 ++- web/src/views/__tests__/VerifyView.spec.ts | 30 +++--- web/visual/check.ts | 41 ++++++-- web/visual/states.ts | 22 ++++- 25 files changed, 461 insertions(+), 241 deletions(-) create mode 100644 web/src/lib/verifiedRedirect.ts 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(

{{ title }}

-
- -
+ - + -