fix: visuals reference matching overhaul and better workflow
This commit is contained in:
1 parent
930b836241
commit
d493a181e1
25 files changed
+460
-240
No files matched your search
@@ -74,8 +74,11 @@ Feature: Log in screen
|
|||||||
"For your security, wait a moment before trying again."
|
"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 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 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
|
When 42 seconds elapse
|
||||||
Then the submit button re-enables and reads "Log in"
|
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
|
Scenario: Session expired — redirected from a 401 elsewhere
|
||||||
Given the user is redirected to "/login?redirect=/article/12/edit&reason=expired"
|
Given the user is redirected to "/login?redirect=/article/12/edit&reason=expired"
|
||||||
|
|||||||
@@ -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
|
`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
|
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
|
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;
|
submit button reads "Try again in 0:42" and is disabled; assert both fields are disabled;
|
||||||
assert the button re-enables and reads "Log in".
|
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
|
7. **Session expired state** — mount at
|
||||||
"/login?redirect=/article/12/edit&reason=expired"; assert an info alert with title "Your session
|
"/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
|
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.
|
and mobile, light and dark.
|
||||||
- [ ] Wrong credentials state: error alert (circle-x icon, `role="alert"`) above the heading,
|
- [ ] Wrong credentials state: error alert (circle-x icon, `role="alert"`) above the heading,
|
||||||
cleared/focused Password field, desktop and mobile, light and dark.
|
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
|
- [ ] Too many attempts state: info alert (timer icon), disabled fields and a disabled button with
|
||||||
m:ss" countdown text, desktop and mobile, light and dark.
|
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,
|
- [ ] Session expired state: info alert (clock icon, no close button), focused Password field,
|
||||||
light and dark.
|
desktop and mobile, light and dark.
|
||||||
- [ ] Password changed state: success alert (circle-check icon, dismissible close button per
|
- [ ] Password changed state: success alert (circle-check icon, no close button, as the board draws
|
||||||
components.md), desktop and mobile, light and dark.
|
it), desktop and mobile, light and dark.
|
||||||
- [ ] Server or network error state: error alert (circle-x icon, no close button), fields retain
|
- [ ] Server or network error state: error alert (circle-x icon, no close button), fields retain
|
||||||
their values, desktop and mobile, light and dark.
|
their values, desktop and mobile, light and dark.
|
||||||
- [ ] Keyboard/focus: Tab order is Username → Password → show/hide toggle → "Forgot password?" →
|
- [ ] Keyboard/focus: Tab order is Username → Password → show/hide toggle → "Forgot password?" →
|
||||||
|
|||||||
@@ -53,12 +53,12 @@ Feature: Pending registration screen
|
|||||||
verified on another device while this tab still holds a stale unverified session), the
|
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
|
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
|
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
|
navigation to /home straight back to /register/pending — then the dialog closes,
|
||||||
navigates to /home, and a toast (color "info", icon "i-lucide-info", title "Your account is
|
PendingPage replaces its panel with a ResultPanel (tone "success", icon
|
||||||
already verified", description "There's nothing to cancel.") is shown; PendingPage does not
|
"i-lucide-shield-check", title "You're verified", text "Taking you to your dashboard…", no
|
||||||
render a ResultPanel for this case, since the ResultPanel drawn in this state on the design
|
actions) and shows a toast (color "info", icon "i-lucide-info", title "Your account is already
|
||||||
board is Verify's own Success panel, reused purely to depict the destination page behind the
|
verified", description "There's nothing to cancel.") over it, exactly as the design board
|
||||||
toast
|
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
|
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:
|
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
|
"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
|
And a toast is shown with color "success", icon "i-lucide-circle-check", title "Registration
|
||||||
cancelled", description "You can create the owner account again."
|
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
|
Given the ConfirmDialog's action is in flight after the user confirmed
|
||||||
And the session store currently holds { verified: false }
|
And the session store currently holds { verified: false }
|
||||||
When DELETE /v1/auth/register resolves 403 with the apperr.AlreadyVerifiedErrorMessage body
|
When DELETE /v1/auth/register resolves 403 with the apperr.AlreadyVerifiedErrorMessage body
|
||||||
Then session.retry() is awaited before any navigation happens
|
Then session.retry() is awaited before any navigation happens
|
||||||
And once it resolves the session store holds { verified: true }
|
And once it resolves the session store holds { verified: true }
|
||||||
And the dialog closes
|
And the dialog closes
|
||||||
And the app navigates to /home
|
And a ResultPanel is shown with tone "success", icon "i-lucide-shield-check", title "You're
|
||||||
And a toast is shown with color "info", icon "i-lucide-info", title "Your account is already
|
verified", text "Taking you to your dashboard…", and no actions
|
||||||
verified", description "There's nothing to cancel."
|
And a toast is shown over it with color "info", icon "i-lucide-info", title "Your account is
|
||||||
And no ResultPanel is rendered by PendingPage for this transition
|
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
|
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
|
Given the ConfirmDialog's action is in flight after the user confirmed
|
||||||
|
|||||||
@@ -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'`;
|
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
|
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
|
`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
|
before the navigation; assert the "You're verified" ResultPanel ("Taking you to your
|
||||||
color "info", icon "i-lucide-info", title "Your account is already verified", description
|
dashboard…", no actions) is shown and a toast was raised with color "info", icon "i-lucide-info",
|
||||||
"There's nothing to cancel."; assert no ResultPanel assertions target this transition (Pending
|
title "Your account is already verified", description "There's nothing to cancel." while the
|
||||||
unmounts as the route changes).
|
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,
|
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
|
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
|
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.
|
- [ ] Cancelling state: disabled buttons, "Cancelling…" label, spinner/aria-busy styling.
|
||||||
- [ ] Cancelled state (transient, capture via visual test hook or a frozen mock): toast appearance
|
- [ ] Cancelled state (transient, capture via visual test hook or a frozen mock): toast appearance
|
||||||
over the /register board.
|
over the /register board.
|
||||||
- [ ] Already verified state: toast appearance over the /home board (per the design board's
|
- [ ] Already verified state: the "You're verified" ResultPanel with the info toast over it,
|
||||||
illustrative use of Verify's Success ResultPanel as the backdrop).
|
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;
|
- [ ] 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
|
while cancelling, focus cannot leave the dialog; on the ResultPanel states, focus lands on the
|
||||||
title on mount; toasts are announced without stealing focus.
|
title on mount; toasts are announced without stealing focus.
|
||||||
|
|||||||
@@ -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-
|
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
|
expired state instead of the global "unverified session" redirect
|
||||||
And Verifying and No-session-retrying both render the same full-card spinner: a centered
|
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
|
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",
|
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
|
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
|
a toast (color "success", icon "i-lucide-circle-check", title "Your account is verified",
|
||||||
session instead of whatever stale state this tab last cached) and an 800 ms timer; once both
|
description "Welcome to RenewCMS.") is shown immediately over that panel, as the design board
|
||||||
settle, the app navigates to /home and a toast (color "success", icon "i-lucide-circle-check",
|
draws it, while concurrently awaiting session.retry() (needed so /home's route guard sees the
|
||||||
title "Your account is verified", description "Welcome to RenewCMS.") is shown
|
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
|
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
|
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.",
|
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
|
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,
|
"Log in and verify"; there is no "Forgot password?" hint and no registration-open footer,
|
||||||
unlike LoginView
|
unlike LoginView
|
||||||
And the inline form's submit is disabled until both fields are non-empty; on submit it calls
|
And the inline form's submit button is enabled, as the design board draws it; on submit,
|
||||||
POST /v1/auth/login with { username, password }
|
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
|
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
|
retries POST /v1/auth/verify with the same code and the same passthrough options, which then
|
||||||
follows the 200/403 handling above
|
follows the 200/403 handling above
|
||||||
@@ -85,10 +89,10 @@ Feature: Verify account screen
|
|||||||
When POST /v1/auth/verify resolves 200
|
When POST /v1/auth/verify resolves 200
|
||||||
Then a ResultPanel is shown with tone "success", icon "i-lucide-shield-check", title "Your
|
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
|
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 session.retry() is awaited
|
||||||
And after 800 ms and once session.retry() has resolved, the app navigates to /home
|
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
|
Scenario: Invalid or expired — 403 offers a way to start over
|
||||||
Given the user is on "/register/validate?c=abc123" in the Verifying state
|
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"
|
toggle, and a submit button "Log in and verify"
|
||||||
And there is no "Forgot password?" link and no registration-open footer
|
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
|
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
|
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
|
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"
|
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
|
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,
|
Then an info alert is shown with title "Too many attempts" and description "For your security,
|
||||||
wait a moment before trying again."
|
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
|
Scenario Outline: No session — server or network error during the inline login
|
||||||
Given the inline log-in form from "No session" is shown
|
Given the inline log-in form from "No session" is shown
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
"Verifying your account…" and that `POST /v1/auth/verify` was called exactly once with
|
||||||
`{ code: 'abc123' }` and `{ onUnauthorized: 'passthrough', onForbidden: 'passthrough' }`.
|
`{ code: 'abc123' }` and `{ onUnauthorized: 'passthrough', onForbidden: 'passthrough' }`.
|
||||||
2. **Success state** — mock `POST /v1/auth/verify` resolving 200; mock the session store's
|
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,
|
`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
|
"Your account is verified", "Taking you to your dashboard…", no actions); assert a toast was
|
||||||
800 ms; assert `retry()` was called; assert the router's current path is /home and a toast was
|
already raised with title "Your account is verified", description "Welcome to RenewCMS." while
|
||||||
raised with title "Your account is verified", description "Welcome to RenewCMS."
|
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
|
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
|
(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).
|
"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,
|
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
|
submit "Log in and verify") and assert no "Forgot password?" link and no registration footer are
|
||||||
rendered.
|
rendered.
|
||||||
8. **No session — submit disabled until filled** — from state 7, assert the submit button is
|
8. **No session — empty fields are caught on submit** — from state 7, assert the submit button is
|
||||||
disabled; fill both fields; assert it becomes enabled.
|
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
|
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
|
`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
|
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
|
- [ ] Verifying state: the full-card spinner (the only one in the product) matches the board's
|
||||||
layout exactly, with no title header shown.
|
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
|
- [ ] 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
|
"Cancel registration and start over" first per components.md's safe/primary-on-top rule for
|
||||||
stacked mobile buttons).
|
stacked mobile buttons).
|
||||||
|
|||||||
@@ -155,6 +155,11 @@
|
|||||||
--ui-info: var(--ui-color-info-700);
|
--ui-info: var(--ui-color-info-700);
|
||||||
--ui-warning: var(--ui-color-warning-700);
|
--ui-warning: var(--ui-color-warning-700);
|
||||||
--ui-error: var(--ui-color-error-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 {
|
.dark {
|
||||||
@@ -179,6 +184,11 @@
|
|||||||
--ui-info: var(--ui-color-info-300);
|
--ui-info: var(--ui-color-info-300);
|
||||||
--ui-warning: var(--ui-color-warning-300);
|
--ui-warning: var(--ui-color-warning-300);
|
||||||
--ui-error: var(--ui-color-error-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-popover: 0 4px 16px rgb(0 0 0 / 0.5);
|
||||||
--shadow-modal: 0 16px 48px rgb(0 0 0 / 0.6);
|
--shadow-modal: 0 16px 48px rgb(0 0 0 / 0.6);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,29 +33,38 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const controlSize = computed(() => (belowSm.value ? 'xl' : undefined))
|
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(() => [
|
const mainClass = computed(() => [
|
||||||
'flex min-h-screen flex-col items-center',
|
'flex min-h-screen flex-col items-center gap-5',
|
||||||
belowSm.value ? 'bg-default' : 'bg-muted pt-[12vh]',
|
belowSm.value ? 'bg-default pt-10' : 'bg-muted pt-[12vh]',
|
||||||
])
|
])
|
||||||
|
|
||||||
const cardClass = computed(() => (belowSm.value ? 'w-full' : 'w-full max-w-[400px]'))
|
const cardClass = computed(() => (belowSm.value ? 'w-full' : 'w-full max-w-[400px]'))
|
||||||
|
|
||||||
const cardUi = computed(() => ({
|
const cardUi = computed(() => ({
|
||||||
root: belowSm.value ? 'ring-0' : '',
|
root: belowSm.value ? 'rounded-none ring-0' : 'rounded-none ring-0 border border-default',
|
||||||
container: belowSm.value ? 'pt-2 px-5 pb-0' : 'p-10',
|
container: belowSm.value
|
||||||
header: 'mb-6',
|
? 'flex flex-col gap-6 px-5 pt-2 pb-0 lg:flex'
|
||||||
title: 'text-xl font-semibold leading-7',
|
: '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(() => [
|
const footerClass = computed(() => [
|
||||||
'mt-4 flex w-full items-center justify-between',
|
'flex w-full flex-wrap items-center justify-between gap-3 text-sm text-muted',
|
||||||
belowSm.value ? 'pt-0 px-5 pb-6' : 'p-0',
|
belowSm.value ? 'px-5 pb-6' : 'p-0',
|
||||||
cardClass.value,
|
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(
|
watch(
|
||||||
() => props.title,
|
() => props.title,
|
||||||
@@ -96,16 +105,20 @@ watch(
|
|||||||
<h1 ref="titleEl" tabindex="-1" class="outline-none">{{ title }}</h1>
|
<h1 ref="titleEl" tabindex="-1" class="outline-none">{{ title }}</h1>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-if="slots.notice" class="mb-4">
|
<slot v-if="slots.notice" name="notice" />
|
||||||
<slot name="notice" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<slot :control-size="controlSize" :control-class="controlClass" />
|
<slot
|
||||||
|
:control-size="controlSize"
|
||||||
|
:control-class="controlClass"
|
||||||
|
:input-class="inputClass"
|
||||||
|
/>
|
||||||
</UPageCard>
|
</UPageCard>
|
||||||
|
|
||||||
<footer :class="footerClass">
|
<footer v-if="!belowSm || $slots.footer" :class="footerClass">
|
||||||
|
<span class="flex flex-wrap items-center gap-1.5">
|
||||||
<slot name="footer" />
|
<slot name="footer" />
|
||||||
<UColorModeSelect size="sm" class="ms-auto" :ui="colorModeSelectUi" />
|
</span>
|
||||||
|
<UColorModeSelect size="sm" :ui="colorModeSelectUi" />
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
@@ -76,7 +76,12 @@ function onCancel() {
|
|||||||
:content="{ role: 'alertdialog', 'aria-busy': pending ? 'true' : undefined }"
|
:content="{ role: 'alertdialog', 'aria-busy': pending ? 'true' : undefined }"
|
||||||
:ui="{
|
:ui="{
|
||||||
content:
|
content:
|
||||||
'max-sm:!inset-x-0 max-sm:!bottom-0 max-sm:!top-auto max-sm:!left-0 max-sm:!right-0 max-sm:!translate-x-0 max-sm:!translate-y-0 max-sm:!w-full max-sm:!max-w-none',
|
'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>
|
<template v-if="error" #body>
|
||||||
@@ -96,7 +101,7 @@ function onCancel() {
|
|||||||
color="neutral"
|
color="neutral"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
block
|
block
|
||||||
class="sm:w-auto"
|
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"
|
:disabled="pending"
|
||||||
@click="onCancel"
|
@click="onCancel"
|
||||||
>
|
>
|
||||||
@@ -107,7 +112,7 @@ function onCancel() {
|
|||||||
:loading="pending"
|
:loading="pending"
|
||||||
:disabled="pending"
|
:disabled="pending"
|
||||||
block
|
block
|
||||||
class="sm:w-auto"
|
class="h-11 justify-center text-[0.9375rem] sm:h-8 sm:w-auto sm:px-3 sm:text-sm"
|
||||||
@click="onConfirm"
|
@click="onConfirm"
|
||||||
>
|
>
|
||||||
{{ confirmText }}
|
{{ confirmText }}
|
||||||
|
|||||||
@@ -11,16 +11,23 @@ const props = defineProps<{
|
|||||||
showErrors: boolean
|
showErrors: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
function itemClass(rule: PasswordRule) {
|
function iconClass(rule: PasswordRule) {
|
||||||
if (rule.met) {
|
if (rule.met) {
|
||||||
return 'text-success'
|
return 'text-success'
|
||||||
}
|
}
|
||||||
return props.showErrors ? 'text-error' : 'text-toned'
|
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) {
|
function itemIcon(rule: PasswordRule) {
|
||||||
if (rule.met) {
|
if (rule.met) {
|
||||||
return 'i-lucide-circle-check'
|
return 'i-lucide-check'
|
||||||
}
|
}
|
||||||
return props.showErrors ? 'i-lucide-circle-alert' : 'i-lucide-circle'
|
return props.showErrors ? 'i-lucide-circle-alert' : 'i-lucide-circle'
|
||||||
}
|
}
|
||||||
@@ -28,21 +35,18 @@ function itemIcon(rule: PasswordRule) {
|
|||||||
const items = computed(() =>
|
const items = computed(() =>
|
||||||
props.rules.map((rule) => ({
|
props.rules.map((rule) => ({
|
||||||
...rule,
|
...rule,
|
||||||
classes: itemClass(rule),
|
iconClass: iconClass(rule),
|
||||||
|
textClass: textClass(rule),
|
||||||
icon: itemIcon(rule),
|
icon: itemIcon(rule),
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ul class="flex flex-col gap-1">
|
<ul class="flex flex-col gap-1.5">
|
||||||
<li
|
<li v-for="item in items" :key="item.label" class="flex items-center gap-2 text-xs/[18px]">
|
||||||
v-for="item in items"
|
<UIcon :name="item.icon" :class="['size-3.5 shrink-0', item.iconClass]" />
|
||||||
:key="item.label"
|
<span :class="item.textClass">{{ item.label }}</span>
|
||||||
:class="['flex items-center gap-1.5 text-sm', item.classes]"
|
|
||||||
>
|
|
||||||
<UIcon :name="item.icon" class="shrink-0" />
|
|
||||||
{{ item.label }}
|
|
||||||
<span class="sr-only">{{ item.met ? 'met' : 'not met' }}</span>
|
<span class="sr-only">{{ item.met ? 'met' : 'not met' }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ const props = defineProps<{
|
|||||||
const role = computed(() => (props.tone === 'error' ? 'alert' : 'status'))
|
const role = computed(() => (props.tone === 'error' ? 'alert' : 'status'))
|
||||||
|
|
||||||
const toneTileClass: Record<'success' | 'error' | 'info', string> = {
|
const toneTileClass: Record<'success' | 'error' | 'info', string> = {
|
||||||
success: 'bg-success/10 text-success',
|
success: 'bg-(--rc-success-soft) text-success',
|
||||||
error: 'bg-error/10 text-error',
|
error: 'bg-(--rc-error-soft) text-error',
|
||||||
info: 'bg-info/10 text-info',
|
info: 'bg-(--rc-info-soft) text-info',
|
||||||
}
|
}
|
||||||
|
|
||||||
const tileClass = computed(() => toneTileClass[props.tone])
|
const tileClass = computed(() => toneTileClass[props.tone])
|
||||||
@@ -38,24 +38,31 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :role="role" class="flex flex-col gap-4">
|
<div :role="role" class="flex flex-col gap-4">
|
||||||
<div :class="['flex h-11 w-11 items-center justify-center', tileClass]">
|
<div :class="['flex size-11 items-center justify-center', tileClass]">
|
||||||
<UIcon :name="icon" class="h-6 w-6" />
|
<UIcon :name="icon" class="size-5.5" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
<h1 ref="titleEl" tabindex="-1" class="text-xl font-semibold leading-7 outline-none">
|
<h1
|
||||||
|
ref="titleEl"
|
||||||
|
tabindex="-1"
|
||||||
|
class="text-xl/7 font-semibold text-highlighted outline-none"
|
||||||
|
>
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-toned text-sm">{{ description }}</p>
|
<p class="text-base text-pretty text-toned">{{ description }}</p>
|
||||||
</div>
|
|
||||||
<slot />
|
<slot />
|
||||||
<div class="flex w-full flex-col gap-2">
|
</div>
|
||||||
|
<div v-if="actions.length" class="flex w-full flex-col gap-2">
|
||||||
<UButton
|
<UButton
|
||||||
v-for="action in actions"
|
v-for="action in actions"
|
||||||
:key="action.label"
|
:key="action.label"
|
||||||
block
|
block
|
||||||
size="lg"
|
|
||||||
:color="action.color ?? 'primary'"
|
:color="action.color ?? 'primary'"
|
||||||
:variant="action.variant ?? 'solid'"
|
:variant="action.variant ?? 'solid'"
|
||||||
|
:class="[
|
||||||
|
'h-11 justify-center text-base sm:h-10',
|
||||||
|
action.variant === 'outline' ? 'text-highlighted' : '',
|
||||||
|
]"
|
||||||
:to="action.to"
|
:to="action.to"
|
||||||
:disabled="action.disabled"
|
:disabled="action.disabled"
|
||||||
@click="action.onClick"
|
@click="action.onClick"
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ describe("UPageCard's title is the page h1, and its description uses UPageCard's
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('The page sits on the muted background from sm up, and the card keeps its border', () => {
|
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 ring, capped width and 12vh top offset', () => {
|
it('uses the muted background token, keeps the card border, capped width and 12vh top offset', () => {
|
||||||
stubMatchMedia(false)
|
stubMatchMedia(false)
|
||||||
const wrapper = mount(AuthShell, {
|
const wrapper = mount(AuthShell, {
|
||||||
props: { title: 'Log in', description: 'Welcome back.' },
|
props: { title: 'Log in', description: 'Welcome back.' },
|
||||||
@@ -130,7 +130,7 @@ describe('The page sits on the muted background from sm up, and the card keeps i
|
|||||||
|
|
||||||
const card = wrapper.find('[data-testid="auth-card"]')
|
const card = wrapper.find('[data-testid="auth-card"]')
|
||||||
expect(card.classes()).toContain('max-w-[400px]')
|
expect(card.classes()).toContain('max-w-[400px]')
|
||||||
expect(card.classes().some((c) => c === 'ring-0')).toBe(false)
|
expect(card.classes()).toContain('border')
|
||||||
|
|
||||||
const container = wrapper.find('[data-slot="container"]')
|
const container = wrapper.find('[data-slot="container"]')
|
||||||
expect(container.classes()).not.toContain('p-5')
|
expect(container.classes()).not.toContain('p-5')
|
||||||
|
|||||||
@@ -55,57 +55,6 @@ afterEach(async () => {
|
|||||||
resetAuthShellNavigation()
|
resetAuthShellNavigation()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Card padding and control heights at sm and up', () => {
|
|
||||||
it('pads the card 40px on every side and sizes controls at 40px, 14px text', async () => {
|
|
||||||
stubMatchMedia(false)
|
|
||||||
const wrapper = await mountLogin()
|
|
||||||
|
|
||||||
const container = wrapper.find('[data-slot="container"]')
|
|
||||||
expect(container.classes()).toContain('p-10')
|
|
||||||
expect(container.classes()).not.toContain('pt-2')
|
|
||||||
|
|
||||||
const usernameInput = wrapper.find('input[placeholder="Your username"]')
|
|
||||||
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
|
|
||||||
const submitButton = wrapper.find('button[type="submit"]')
|
|
||||||
|
|
||||||
for (const el of [usernameInput, passwordInput, submitButton]) {
|
|
||||||
expect(el.classes()).toContain('!h-10')
|
|
||||||
expect(el.classes()).toContain('!text-sm')
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = wrapper.find('[data-slot="title"]')
|
|
||||||
expect(title.classes()).toContain('text-xl')
|
|
||||||
expect(title.classes()).toContain('font-semibold')
|
|
||||||
expect(title.classes()).toContain('leading-7')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Card padding and control heights below sm', () => {
|
|
||||||
it('splits card/footer padding and sizes controls at 44px, 16px text', async () => {
|
|
||||||
stubMatchMedia(true)
|
|
||||||
const wrapper = await mountLogin()
|
|
||||||
|
|
||||||
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')
|
|
||||||
|
|
||||||
const footer = wrapper.find('footer')
|
|
||||||
expect(footer.classes()).toContain('pt-0')
|
|
||||||
expect(footer.classes()).toContain('px-5')
|
|
||||||
expect(footer.classes()).toContain('pb-6')
|
|
||||||
|
|
||||||
const usernameInput = wrapper.find('input[placeholder="Your username"]')
|
|
||||||
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
|
|
||||||
const submitButton = wrapper.find('button[type="submit"]')
|
|
||||||
|
|
||||||
for (const el of [usernameInput, passwordInput, submitButton]) {
|
|
||||||
expect(el.classes()).toContain('!h-11')
|
|
||||||
expect(el.classes()).toContain('!text-base')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('"Forgot password?" is underlined and muted', () => {
|
describe('"Forgot password?" is underlined and muted', () => {
|
||||||
it('uses the muted toned color, underline and 3px offset, no other emphasis', async () => {
|
it('uses the muted toned color, underline and 3px offset, no other emphasis', async () => {
|
||||||
const wrapper = await mountLogin()
|
const wrapper = await mountLogin()
|
||||||
@@ -151,6 +100,7 @@ describe('A field error shows the circle-alert icon with no extra focus halo', (
|
|||||||
|
|
||||||
describe('The theme select is right-aligned in the footer with no chevron', () => {
|
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 () => {
|
it('renders as the last child, showing only the mode icon and label', async () => {
|
||||||
|
stubMatchMedia(false)
|
||||||
const wrapper = await mountLogin()
|
const wrapper = await mountLogin()
|
||||||
|
|
||||||
const footer = wrapper.find('footer')
|
const footer = wrapper.find('footer')
|
||||||
@@ -201,7 +151,9 @@ describe('The alert renders inside the card, under the description', () => {
|
|||||||
expect(html.indexOf(text)).toBeLessThan(html.indexOf('Username'))
|
expect(html.indexOf(text)).toBeLessThan(html.indexOf('Username'))
|
||||||
expect(card.element.contains(usernameInput.element)).toBe(true)
|
expect(card.element.contains(usernameInput.element)).toBe(true)
|
||||||
|
|
||||||
expect(alert.classes().some((c) => /^bg-(info|success)\/10$/.test(c))).toBe(true)
|
expect(alert.classes().some((c) => /^bg-\(--rc-(info|success)-soft\)$/.test(c))).toBe(
|
||||||
|
true,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export const VERIFIED_REDIRECT_DELAY_MS = 800
|
||||||
|
|
||||||
|
export function wait(ms: number) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
const overlayBackground = 'bg-(--ui-color-neutral-950)/40'
|
const overlayBackground = 'bg-(--rc-overlay)'
|
||||||
|
|
||||||
export const renewUi = {
|
export const renewUi = {
|
||||||
colors: {
|
colors: {
|
||||||
@@ -37,7 +37,7 @@ export const renewUi = {
|
|||||||
},
|
},
|
||||||
button: {
|
button: {
|
||||||
slots: {
|
slots: {
|
||||||
base: 'rounded-none font-medium transition-colors duration-(--duration-fast) ease-(--ease-standard)',
|
base: 'rounded-none font-medium transition-colors duration-(--duration-fast) ease-(--ease-standard) disabled:opacity-40 aria-disabled:opacity-40',
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
size: {
|
size: {
|
||||||
@@ -45,20 +45,61 @@ export const renewUi = {
|
|||||||
leadingIcon: 'size-4',
|
leadingIcon: 'size-4',
|
||||||
trailingIcon: 'size-4',
|
trailingIcon: 'size-4',
|
||||||
},
|
},
|
||||||
|
xl: {
|
||||||
|
leadingIcon: 'size-4',
|
||||||
|
trailingIcon: 'size-4',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
compoundVariants: [
|
||||||
|
{
|
||||||
|
loading: true,
|
||||||
|
class: {
|
||||||
|
base: 'disabled:opacity-100 aria-disabled:opacity-100',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
size: 'md',
|
size: 'md',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
input: {
|
formField: {
|
||||||
slots: {
|
slots: {
|
||||||
base: 'rounded-none ring-(--ui-border-accented) placeholder:text-muted',
|
labelWrapper: 'gap-2',
|
||||||
|
label: 'text-highlighted',
|
||||||
|
error: 'mt-1.5 text-xs/[18px]',
|
||||||
|
help: 'mt-1.5 text-xs/[18px]',
|
||||||
},
|
},
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
xl: {
|
||||||
|
root: 'text-sm',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orientation: {
|
||||||
|
vertical: {
|
||||||
|
container: 'mt-1.5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
slots: {
|
||||||
|
base: 'rounded-none ring-(--ui-border-accented) placeholder:text-muted focus-visible:outline-none disabled:bg-elevated disabled:text-dimmed disabled:ring-(--ui-border) disabled:opacity-100',
|
||||||
|
},
|
||||||
|
compoundVariants: [
|
||||||
|
{
|
||||||
|
highlight: true,
|
||||||
|
class: 'ring-2',
|
||||||
|
},
|
||||||
|
],
|
||||||
variants: {
|
variants: {
|
||||||
size: {
|
size: {
|
||||||
md: {
|
md: {
|
||||||
base: 'text-base md:text-sm',
|
base: 'ps-2.75 text-base',
|
||||||
|
},
|
||||||
|
xl: {
|
||||||
|
base: 'ps-2.75 text-lg',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -164,19 +205,56 @@ export const renewUi = {
|
|||||||
},
|
},
|
||||||
toast: {
|
toast: {
|
||||||
slots: {
|
slots: {
|
||||||
root: 'rounded-none shadow-(--shadow-popover)',
|
root: 'rounded-none shadow-(--shadow-popover) ring-0 border border-default gap-2.5 px-3.5 py-3',
|
||||||
progress: 'rounded-none',
|
title: 'text-sm font-semibold max-sm:text-base/5',
|
||||||
|
description: 'text-sm text-muted max-sm:text-base/5',
|
||||||
|
icon: 'mt-0.5 size-4 max-sm:mt-0.75',
|
||||||
|
progress: 'hidden',
|
||||||
},
|
},
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
horizontal: {
|
||||||
|
root: 'items-start',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
true: {
|
||||||
|
description: 'mt-0.5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
compoundVariants: [
|
||||||
|
{
|
||||||
|
color: ['primary', 'secondary', 'success', 'info', 'warning', 'neutral'],
|
||||||
|
class: { close: 'hidden' },
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
toaster: {
|
toaster: {
|
||||||
slots: {
|
slots: {
|
||||||
viewport: 'z-(--z-toast)',
|
viewport: 'z-(--z-toast) sm:right-6 sm:bottom-6 sm:w-89',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
alert: {
|
alert: {
|
||||||
slots: {
|
slots: {
|
||||||
root: 'rounded-none',
|
root: 'rounded-none gap-2.5 px-3.5 py-3',
|
||||||
|
icon: 'mt-0.5 size-4',
|
||||||
|
title: 'text-sm font-semibold',
|
||||||
|
description: 'text-sm text-pretty text-toned opacity-100',
|
||||||
},
|
},
|
||||||
|
variants: {
|
||||||
|
title: {
|
||||||
|
true: {
|
||||||
|
description: 'mt-0.5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
compoundVariants: [
|
||||||
|
{ color: 'success', variant: 'soft', class: { root: 'bg-(--rc-success-soft)' } },
|
||||||
|
{ color: 'info', variant: 'soft', class: { root: 'bg-(--rc-info-soft)' } },
|
||||||
|
{ color: 'warning', variant: 'soft', class: { root: 'bg-(--rc-warning-soft)' } },
|
||||||
|
{ color: 'error', variant: 'soft', class: { root: 'bg-(--rc-error-soft)' } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
slots: {
|
slots: {
|
||||||
|
|||||||
@@ -226,13 +226,7 @@ onMounted(loadArticles)
|
|||||||
icon="i-lucide-circle-x"
|
icon="i-lucide-circle-x"
|
||||||
title="Couldn't load your articles"
|
title="Couldn't load your articles"
|
||||||
description="Check your connection and try again."
|
description="Check your connection and try again."
|
||||||
:ui="{
|
:ui="{ actions: 'mt-2' }"
|
||||||
root: 'gap-2.5 px-3.5 py-3',
|
|
||||||
icon: 'mt-0.5 size-4',
|
|
||||||
title: 'text-sm font-semibold',
|
|
||||||
description: 'mt-0.5 text-sm text-toned opacity-100',
|
|
||||||
actions: 'mt-2',
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<UButton
|
<UButton
|
||||||
|
|||||||
+26
-20
@@ -11,7 +11,6 @@ interface AlertState {
|
|||||||
icon: string
|
icon: string
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
closable: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const wrongCredentialsAlert: AlertState = {
|
const wrongCredentialsAlert: AlertState = {
|
||||||
@@ -19,7 +18,6 @@ const wrongCredentialsAlert: AlertState = {
|
|||||||
icon: 'i-lucide-circle-x',
|
icon: 'i-lucide-circle-x',
|
||||||
title: "That username and password don't match",
|
title: "That username and password don't match",
|
||||||
description: 'Check both and try again. Passwords are case-sensitive.',
|
description: 'Check both and try again. Passwords are case-sensitive.',
|
||||||
closable: false,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tooManyAttemptsAlert: AlertState = {
|
const tooManyAttemptsAlert: AlertState = {
|
||||||
@@ -27,7 +25,6 @@ const tooManyAttemptsAlert: AlertState = {
|
|||||||
icon: 'i-lucide-timer',
|
icon: 'i-lucide-timer',
|
||||||
title: 'Too many attempts',
|
title: 'Too many attempts',
|
||||||
description: 'For your security, wait a moment before trying again.',
|
description: 'For your security, wait a moment before trying again.',
|
||||||
closable: false,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverErrorAlert: AlertState = {
|
const serverErrorAlert: AlertState = {
|
||||||
@@ -35,7 +32,6 @@ const serverErrorAlert: AlertState = {
|
|||||||
icon: 'i-lucide-circle-x',
|
icon: 'i-lucide-circle-x',
|
||||||
title: "Couldn't reach RenewCMS",
|
title: "Couldn't reach RenewCMS",
|
||||||
description: 'Check your connection and try again.',
|
description: 'Check your connection and try again.',
|
||||||
closable: false,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionExpiredAlert: AlertState = {
|
const sessionExpiredAlert: AlertState = {
|
||||||
@@ -44,7 +40,6 @@ const sessionExpiredAlert: AlertState = {
|
|||||||
title: 'Your session expired',
|
title: 'Your session expired',
|
||||||
description:
|
description:
|
||||||
'Log in again to pick up where you left off. Unsaved changes are kept on this device.',
|
'Log in again to pick up where you left off. Unsaved changes are kept on this device.',
|
||||||
closable: true,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordChangedAlert: AlertState = {
|
const passwordChangedAlert: AlertState = {
|
||||||
@@ -52,7 +47,6 @@ const passwordChangedAlert: AlertState = {
|
|||||||
icon: 'i-lucide-circle-check',
|
icon: 'i-lucide-circle-check',
|
||||||
title: 'Password changed',
|
title: 'Password changed',
|
||||||
description: 'Log in with your new password.',
|
description: 'Log in with your new password.',
|
||||||
closable: true,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -205,14 +199,13 @@ onMounted(async () => {
|
|||||||
:title="alert.title"
|
:title="alert.title"
|
||||||
:description="alert.description"
|
:description="alert.description"
|
||||||
variant="soft"
|
variant="soft"
|
||||||
:close="alert.closable"
|
:close="false"
|
||||||
:role="alertRole"
|
:role="alertRole"
|
||||||
@close="alert = null"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #default="{ controlSize, controlClass }">
|
<template #default="{ controlSize, controlClass, inputClass }">
|
||||||
<UForm :state="{ username, password }" class="flex flex-col gap-4" @submit="onSubmit">
|
<UForm :state="{ username, password }" class="flex flex-col gap-4.5" @submit="onSubmit">
|
||||||
<UFormField
|
<UFormField
|
||||||
label="Username"
|
label="Username"
|
||||||
name="username"
|
name="username"
|
||||||
@@ -221,7 +214,10 @@ onMounted(async () => {
|
|||||||
>
|
>
|
||||||
<template v-if="usernameError" #error="{ error }">
|
<template v-if="usernameError" #error="{ error }">
|
||||||
<span class="flex items-center gap-1.5">
|
<span class="flex items-center gap-1.5">
|
||||||
<UIcon name="i-lucide-circle-alert" class="text-error shrink-0" />
|
<UIcon
|
||||||
|
name="i-lucide-circle-alert"
|
||||||
|
class="size-3.5 shrink-0 text-error"
|
||||||
|
/>
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -229,12 +225,12 @@ onMounted(async () => {
|
|||||||
v-model="username"
|
v-model="username"
|
||||||
placeholder="Your username"
|
placeholder="Your username"
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
:disabled="submitting"
|
:disabled="submitDisabled"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
color="primary"
|
color="primary"
|
||||||
:highlight="!!usernameError"
|
:highlight="!!usernameError"
|
||||||
:size="controlSize"
|
:size="controlSize"
|
||||||
:ui="{ base: controlClass }"
|
:ui="{ base: inputClass, trailing: 'pe-1' }"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
@@ -255,7 +251,10 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
<template v-if="passwordError" #error="{ error }">
|
<template v-if="passwordError" #error="{ error }">
|
||||||
<span class="flex items-center gap-1.5">
|
<span class="flex items-center gap-1.5">
|
||||||
<UIcon name="i-lucide-circle-alert" class="text-error shrink-0" />
|
<UIcon
|
||||||
|
name="i-lucide-circle-alert"
|
||||||
|
class="size-3.5 shrink-0 text-error"
|
||||||
|
/>
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -263,12 +262,15 @@ onMounted(async () => {
|
|||||||
v-model="password"
|
v-model="password"
|
||||||
:type="passwordShown ? 'text' : 'password'"
|
:type="passwordShown ? 'text' : 'password'"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
:disabled="submitting"
|
:disabled="submitDisabled"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
color="primary"
|
color="primary"
|
||||||
:highlight="!!passwordError"
|
:highlight="!!passwordError"
|
||||||
:size="controlSize"
|
:size="controlSize"
|
||||||
:ui="{ base: controlClass }"
|
:ui="{
|
||||||
|
base: `${inputClass} ${passwordShown ? '' : 'tracking-[2px]'}`,
|
||||||
|
trailing: 'pe-1',
|
||||||
|
}"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
>
|
>
|
||||||
<template #trailing>
|
<template #trailing>
|
||||||
@@ -278,6 +280,7 @@ onMounted(async () => {
|
|||||||
color="neutral"
|
color="neutral"
|
||||||
size="sm"
|
size="sm"
|
||||||
square
|
square
|
||||||
|
class="size-8 justify-center text-toned"
|
||||||
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
|
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
|
||||||
:aria-pressed="passwordShown"
|
:aria-pressed="passwordShown"
|
||||||
@click="passwordShown = !passwordShown"
|
@click="passwordShown = !passwordShown"
|
||||||
@@ -300,12 +303,15 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="registrationOpen" #footer>
|
<template v-if="registrationOpen" #footer>
|
||||||
<p>
|
<span class="flex flex-wrap items-center gap-1.5">
|
||||||
First time here?
|
First time here?
|
||||||
<RouterLink to="/register" class="text-primary"
|
<RouterLink
|
||||||
>Create the owner account</RouterLink
|
to="/register"
|
||||||
|
class="font-medium text-highlighted underline underline-offset-3"
|
||||||
>
|
>
|
||||||
</p>
|
Create the owner account
|
||||||
|
</RouterLink>
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
</template>
|
</template>
|
||||||
@@ -7,6 +7,7 @@ import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
|||||||
import ResultPanel from '@/components/ResultPanel.vue'
|
import ResultPanel from '@/components/ResultPanel.vue'
|
||||||
import { ApiError, useApi } from '@/composables/useApi'
|
import { ApiError, useApi } from '@/composables/useApi'
|
||||||
import { registrationCancelledToast } from '@/lib/registrationToasts'
|
import { registrationCancelledToast } from '@/lib/registrationToasts'
|
||||||
|
import { VERIFIED_REDIRECT_DELAY_MS, wait } from '@/lib/verifiedRedirect'
|
||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -18,6 +19,7 @@ const toast = useToast()
|
|||||||
const pendingEmail = ref<string | null>(null)
|
const pendingEmail = ref<string | null>(null)
|
||||||
const loggingOut = ref(false)
|
const loggingOut = ref(false)
|
||||||
const cancelOutcome = ref<'cancelled' | 'alreadyVerified' | null>(null)
|
const cancelOutcome = ref<'cancelled' | 'alreadyVerified' | null>(null)
|
||||||
|
const verified = ref(false)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
pendingEmail.value = sessionStorage.getItem('renewcms:pendingEmail')
|
pendingEmail.value = sessionStorage.getItem('renewcms:pendingEmail')
|
||||||
@@ -88,13 +90,15 @@ async function cancelRegistration() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (cancelOutcome.value === 'alreadyVerified') {
|
if (cancelOutcome.value === 'alreadyVerified') {
|
||||||
await router.push('/home')
|
verified.value = true
|
||||||
toast.add({
|
toast.add({
|
||||||
color: 'info',
|
color: 'info',
|
||||||
icon: 'i-lucide-info',
|
icon: 'i-lucide-info',
|
||||||
title: 'Your account is already verified',
|
title: 'Your account is already verified',
|
||||||
description: "There's nothing to cancel.",
|
description: "There's nothing to cancel.",
|
||||||
})
|
})
|
||||||
|
await wait(VERIFIED_REDIRECT_DELAY_MS)
|
||||||
|
await router.push('/home')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,13 +123,22 @@ const actions = computed(() => [
|
|||||||
<template>
|
<template>
|
||||||
<AuthShell title="">
|
<AuthShell title="">
|
||||||
<ResultPanel
|
<ResultPanel
|
||||||
|
v-if="verified"
|
||||||
|
tone="success"
|
||||||
|
icon="i-lucide-shield-check"
|
||||||
|
title="You're verified"
|
||||||
|
description="Taking you to your dashboard…"
|
||||||
|
:actions="[]"
|
||||||
|
/>
|
||||||
|
<ResultPanel
|
||||||
|
v-else
|
||||||
tone="success"
|
tone="success"
|
||||||
icon="i-lucide-mail-check"
|
icon="i-lucide-mail-check"
|
||||||
title="Check your email"
|
title="Check your email"
|
||||||
:description="description"
|
:description="description"
|
||||||
:actions="actions"
|
:actions="actions"
|
||||||
>
|
>
|
||||||
<p class="text-toned text-sm">
|
<p class="text-sm text-pretty text-muted">
|
||||||
Opened it on another device? That works too; you'll log in there first. Can't find
|
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
|
it? Check your spam folder. If the link has expired, cancel your registration and
|
||||||
start again.
|
start again.
|
||||||
|
|||||||
@@ -120,10 +120,10 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else #default="{ controlSize, controlClass }">
|
<template v-else #default="{ controlSize, controlClass, inputClass }">
|
||||||
<UForm
|
<UForm
|
||||||
:state="{ username, email, password }"
|
:state="{ username, email, password }"
|
||||||
class="flex flex-col gap-4"
|
class="flex flex-col gap-4.5"
|
||||||
@submit="onSubmit"
|
@submit="onSubmit"
|
||||||
>
|
>
|
||||||
<UFormField
|
<UFormField
|
||||||
@@ -134,7 +134,10 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
|||||||
>
|
>
|
||||||
<template v-if="usernameError" #error="{ error }">
|
<template v-if="usernameError" #error="{ error }">
|
||||||
<span class="flex items-center gap-1.5">
|
<span class="flex items-center gap-1.5">
|
||||||
<UIcon name="i-lucide-circle-alert" class="text-error shrink-0" />
|
<UIcon
|
||||||
|
name="i-lucide-circle-alert"
|
||||||
|
class="size-3.5 shrink-0 text-error"
|
||||||
|
/>
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -142,7 +145,12 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
|||||||
Letters and numbers, 3 to 20 characters. You'll use it to log in.
|
Letters and numbers, 3 to 20 characters. You'll use it to log in.
|
||||||
</template>
|
</template>
|
||||||
<template #hint>
|
<template #hint>
|
||||||
<span :class="usernameError ? 'text-error' : 'text-toned'">
|
<span
|
||||||
|
:class="[
|
||||||
|
'font-mono text-xs',
|
||||||
|
usernameError ? 'text-error' : 'text-muted',
|
||||||
|
]"
|
||||||
|
>
|
||||||
{{ username.length }} / 20
|
{{ username.length }} / 20
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -155,7 +163,7 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
|||||||
color="primary"
|
color="primary"
|
||||||
:highlight="!!usernameError"
|
:highlight="!!usernameError"
|
||||||
:size="controlSize"
|
:size="controlSize"
|
||||||
:ui="{ base: controlClass }"
|
:ui="{ base: inputClass, trailing: 'pe-1' }"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
@blur="usernameTouched = true"
|
@blur="usernameTouched = true"
|
||||||
/>
|
/>
|
||||||
@@ -169,7 +177,10 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
|||||||
>
|
>
|
||||||
<template v-if="emailError" #error="{ error }">
|
<template v-if="emailError" #error="{ error }">
|
||||||
<span class="flex items-center gap-1.5">
|
<span class="flex items-center gap-1.5">
|
||||||
<UIcon name="i-lucide-circle-alert" class="text-error shrink-0" />
|
<UIcon
|
||||||
|
name="i-lucide-circle-alert"
|
||||||
|
class="size-3.5 shrink-0 text-error"
|
||||||
|
/>
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -184,7 +195,7 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
|||||||
color="primary"
|
color="primary"
|
||||||
:highlight="!!emailError"
|
:highlight="!!emailError"
|
||||||
:size="controlSize"
|
:size="controlSize"
|
||||||
:ui="{ base: controlClass }"
|
:ui="{ base: inputClass, trailing: 'pe-1' }"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
@blur="emailTouched = true"
|
@blur="emailTouched = true"
|
||||||
/>
|
/>
|
||||||
@@ -205,7 +216,10 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
color="primary"
|
color="primary"
|
||||||
:size="controlSize"
|
:size="controlSize"
|
||||||
:ui="{ base: controlClass }"
|
:ui="{
|
||||||
|
base: `${inputClass} ${passwordShown ? '' : 'tracking-[2px]'}`,
|
||||||
|
trailing: 'pe-1',
|
||||||
|
}"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
@blur="passwordTouched = true"
|
@blur="passwordTouched = true"
|
||||||
>
|
>
|
||||||
@@ -216,6 +230,7 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
|||||||
color="neutral"
|
color="neutral"
|
||||||
size="sm"
|
size="sm"
|
||||||
square
|
square
|
||||||
|
class="size-8 justify-center text-toned"
|
||||||
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
|
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
|
||||||
:aria-pressed="passwordShown"
|
:aria-pressed="passwordShown"
|
||||||
@click="passwordShown = !passwordShown"
|
@click="passwordShown = !passwordShown"
|
||||||
@@ -238,10 +253,15 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="!registrationClosed" #footer>
|
<template v-if="!registrationClosed" #footer>
|
||||||
<p>
|
<span class="flex flex-wrap items-center gap-1.5">
|
||||||
Already set up?
|
Already set up?
|
||||||
<RouterLink to="/login" class="text-primary">Log in</RouterLink>
|
<RouterLink
|
||||||
</p>
|
to="/login"
|
||||||
|
class="font-medium text-highlighted underline underline-offset-3"
|
||||||
|
>
|
||||||
|
Log in
|
||||||
|
</RouterLink>
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
</template>
|
</template>
|
||||||
@@ -7,6 +7,7 @@ import ResultPanel from '@/components/ResultPanel.vue'
|
|||||||
import { ApiError, useApi } from '@/composables/useApi'
|
import { ApiError, useApi } from '@/composables/useApi'
|
||||||
import { useRetryAfter } from '@/composables/useRetryAfter'
|
import { useRetryAfter } from '@/composables/useRetryAfter'
|
||||||
import { registrationCancelledToast } from '@/lib/registrationToasts'
|
import { registrationCancelledToast } from '@/lib/registrationToasts'
|
||||||
|
import { VERIFIED_REDIRECT_DELAY_MS, wait } from '@/lib/verifiedRedirect'
|
||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
|
|
||||||
type VerifyState =
|
type VerifyState =
|
||||||
@@ -52,8 +53,6 @@ const cancelFailedAlert: AlertState = {
|
|||||||
description: 'Something went wrong on the server. Try again in a moment.',
|
description: 'Something went wrong on the server. Try again in a moment.',
|
||||||
}
|
}
|
||||||
|
|
||||||
const VERIFICATION_SUCCESS_DELAY_MS = 800
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
@@ -78,13 +77,10 @@ const shellDescription = computed(() =>
|
|||||||
: undefined,
|
: undefined,
|
||||||
)
|
)
|
||||||
|
|
||||||
const loginSubmitDisabled = computed(
|
const usernameError = ref('')
|
||||||
() =>
|
const passwordError = ref('')
|
||||||
username.value.length === 0 ||
|
|
||||||
password.value.length === 0 ||
|
const loginSubmitDisabled = computed(() => loginSubmitting.value || retryAfter.active.value)
|
||||||
loginSubmitting.value ||
|
|
||||||
retryAfter.active.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
const loginSubmitLabel = computed(() => {
|
const loginSubmitLabel = computed(() => {
|
||||||
if (loginSubmitting.value) {
|
if (loginSubmitting.value) {
|
||||||
@@ -96,10 +92,6 @@ const loginSubmitLabel = computed(() => {
|
|||||||
return 'Log in and verify'
|
return 'Log in and verify'
|
||||||
})
|
})
|
||||||
|
|
||||||
function wait(ms: number) {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function focusPassword() {
|
async function focusPassword() {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@@ -116,14 +108,14 @@ async function performVerify(code: string) {
|
|||||||
onForbidden: 'passthrough',
|
onForbidden: 'passthrough',
|
||||||
})
|
})
|
||||||
state.value = 'success'
|
state.value = 'success'
|
||||||
await Promise.all([session.retry(), wait(VERIFICATION_SUCCESS_DELAY_MS)])
|
|
||||||
await router.push('/home')
|
|
||||||
toast.add({
|
toast.add({
|
||||||
color: 'success',
|
color: 'success',
|
||||||
icon: 'i-lucide-circle-check',
|
icon: 'i-lucide-circle-check',
|
||||||
title: 'Your account is verified',
|
title: 'Your account is verified',
|
||||||
description: 'Welcome to RenewCMS.',
|
description: 'Welcome to RenewCMS.',
|
||||||
})
|
})
|
||||||
|
await Promise.all([session.retry(), wait(VERIFIED_REDIRECT_DELAY_MS)])
|
||||||
|
await router.push('/home')
|
||||||
return
|
return
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ApiError && error.status === 401) {
|
if (error instanceof ApiError && error.status === 401) {
|
||||||
@@ -159,10 +151,19 @@ function handleLoginError(error: unknown) {
|
|||||||
loginAlert.value = serverErrorAlert
|
loginAlert.value = serverErrorAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateLogin() {
|
||||||
|
usernameError.value = username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
|
||||||
|
passwordError.value = password.value.length === 0 ? 'Enter your password.' : ''
|
||||||
|
return !usernameError.value && !passwordError.value
|
||||||
|
}
|
||||||
|
|
||||||
async function onLoginSubmit() {
|
async function onLoginSubmit() {
|
||||||
if (loginSubmitDisabled.value) {
|
if (loginSubmitDisabled.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!validateLogin()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
loginAlert.value = null
|
loginAlert.value = null
|
||||||
loginSubmitting.value = true
|
loginSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
@@ -274,14 +275,15 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #default="{ controlSize, controlClass }">
|
<template #default="{ controlSize, controlClass, inputClass }">
|
||||||
<div
|
<div
|
||||||
v-if="state === 'verifying' || state === 'noSessionRetrying'"
|
v-if="state === 'verifying' || state === 'noSessionRetrying'"
|
||||||
role="status"
|
role="status"
|
||||||
class="flex flex-col items-center gap-4 py-10"
|
class="flex flex-col items-center gap-4 py-10 text-center"
|
||||||
>
|
>
|
||||||
<UIcon name="i-lucide-loader-2" class="h-8 w-8 animate-spin text-primary" />
|
<UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-highlighted" />
|
||||||
<p class="text-toned text-sm">Verifying your account…</p>
|
<p class="text-xl/7 font-semibold text-highlighted">Verifying your account…</p>
|
||||||
|
<p class="text-base text-muted">This only takes a moment.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResultPanel
|
<ResultPanel
|
||||||
@@ -301,7 +303,7 @@ onMounted(() => {
|
|||||||
description="It may have expired (links last 2 hours) or already been used."
|
description="It may have expired (links last 2 hours) or already been used."
|
||||||
:actions="invalidActions"
|
:actions="invalidActions"
|
||||||
>
|
>
|
||||||
<p class="text-toned text-sm">
|
<p class="text-sm text-pretty text-muted">
|
||||||
Cancel your registration and start over to get a new link.
|
Cancel your registration and start over to get a new link.
|
||||||
</p>
|
</p>
|
||||||
</ResultPanel>
|
</ResultPanel>
|
||||||
@@ -318,33 +320,66 @@ onMounted(() => {
|
|||||||
<UForm
|
<UForm
|
||||||
v-else-if="state === 'noSession'"
|
v-else-if="state === 'noSession'"
|
||||||
:state="{ username, password }"
|
:state="{ username, password }"
|
||||||
class="flex flex-col gap-4"
|
class="flex flex-col gap-4.5"
|
||||||
@submit="onLoginSubmit"
|
@submit="onLoginSubmit"
|
||||||
>
|
>
|
||||||
<UFormField label="Username" name="username" :size="controlSize">
|
<UFormField
|
||||||
|
label="Username"
|
||||||
|
name="username"
|
||||||
|
:error="usernameError || undefined"
|
||||||
|
:size="controlSize"
|
||||||
|
>
|
||||||
|
<template v-if="usernameError" #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>
|
||||||
<UInput
|
<UInput
|
||||||
v-model="username"
|
v-model="username"
|
||||||
|
:highlight="!!usernameError"
|
||||||
placeholder="Your username"
|
placeholder="Your username"
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
:disabled="loginSubmitting"
|
:disabled="loginSubmitDisabled"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
color="primary"
|
color="primary"
|
||||||
:size="controlSize"
|
:size="controlSize"
|
||||||
:ui="{ base: controlClass }"
|
:ui="{ base: inputClass, trailing: 'pe-1' }"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<UFormField label="Password" name="password" :size="controlSize">
|
<UFormField
|
||||||
|
label="Password"
|
||||||
|
name="password"
|
||||||
|
:error="passwordError || undefined"
|
||||||
|
:size="controlSize"
|
||||||
|
>
|
||||||
|
<template v-if="passwordError" #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>
|
||||||
<UInput
|
<UInput
|
||||||
v-model="password"
|
v-model="password"
|
||||||
|
:highlight="!!passwordError"
|
||||||
:type="passwordShown ? 'text' : 'password'"
|
:type="passwordShown ? 'text' : 'password'"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
:disabled="loginSubmitting"
|
:disabled="loginSubmitDisabled"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
color="primary"
|
color="primary"
|
||||||
:size="controlSize"
|
:size="controlSize"
|
||||||
:ui="{ base: controlClass }"
|
:ui="{
|
||||||
|
base: `${inputClass} ${passwordShown ? '' : 'tracking-[2px]'}`,
|
||||||
|
trailing: 'pe-1',
|
||||||
|
}"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
>
|
>
|
||||||
<template #trailing>
|
<template #trailing>
|
||||||
@@ -354,6 +389,7 @@ onMounted(() => {
|
|||||||
color="neutral"
|
color="neutral"
|
||||||
size="sm"
|
size="sm"
|
||||||
square
|
square
|
||||||
|
class="size-8 justify-center text-toned"
|
||||||
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
|
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
|
||||||
:aria-pressed="passwordShown"
|
:aria-pressed="passwordShown"
|
||||||
@click="passwordShown = !passwordShown"
|
@click="passwordShown = !passwordShown"
|
||||||
|
|||||||
@@ -199,6 +199,12 @@ describe('Too many attempts — the button is disabled with a live countdown', (
|
|||||||
expect(button.text()).toBe('Try again in 0:42')
|
expect(button.text()).toBe('Try again in 0:42')
|
||||||
expect((button.element as HTMLButtonElement).disabled).toBe(true)
|
expect((button.element as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
|
||||||
|
const fields = () => [
|
||||||
|
wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement,
|
||||||
|
wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement,
|
||||||
|
]
|
||||||
|
expect(fields().map((field) => field.disabled)).toEqual([true, true])
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(42000)
|
await vi.advanceTimersByTimeAsync(42000)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
@@ -206,6 +212,7 @@ describe('Too many attempts — the button is disabled with a live countdown', (
|
|||||||
expect((wrapper.find('button[type="submit"]').element as HTMLButtonElement).disabled).toBe(
|
expect((wrapper.find('button[type="submit"]').element as HTMLButtonElement).disabled).toBe(
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
|
expect(fields().map((field) => field.disabled)).toEqual([false, false])
|
||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ describe('Cancelled — 200 navigates to /register with a toast', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Already verified — 403 refreshes the session and navigates to /home with a toast', () => {
|
describe('Already verified — 403 shows the verified panel and toast, then navigates to /home', () => {
|
||||||
it('awaits session.retry, navigates to /home and shows the info toast', async () => {
|
it('awaits session.retry, navigates to /home and shows the info toast', async () => {
|
||||||
const { wrapper } = await mountPending({
|
const { wrapper } = await mountPending({
|
||||||
'DELETE /v1/auth/register': () => mockResponse(403),
|
'DELETE /v1/auth/register': () => mockResponse(403),
|
||||||
@@ -263,13 +263,20 @@ describe('Already verified — 403 refreshes the session and navigates to /home
|
|||||||
overlayMock.resolveOpen(true)
|
overlayMock.resolveOpen(true)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'))
|
expect(wrapper.text()).toContain("You're verified")
|
||||||
|
expect(wrapper.text()).toContain('Taking you to your dashboard…')
|
||||||
expect(toastAdd).toHaveBeenCalledWith({
|
expect(toastAdd).toHaveBeenCalledWith({
|
||||||
color: 'info',
|
color: 'info',
|
||||||
icon: 'i-lucide-info',
|
icon: 'i-lucide-info',
|
||||||
title: 'Your account is already verified',
|
title: 'Your account is already verified',
|
||||||
description: "There's nothing to cancel.",
|
description: "There's nothing to cancel.",
|
||||||
})
|
})
|
||||||
|
expect(router.currentRoute.value.path).not.toBe('/home')
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'), {
|
||||||
|
timeout: 2000,
|
||||||
|
})
|
||||||
|
expect(toastAdd).toHaveBeenCalledTimes(1)
|
||||||
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
|
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
|
||||||
pushSpy.mockRestore()
|
pushSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -98,10 +98,6 @@ describe('Success — 200 refreshes the session and navigates to /home with a to
|
|||||||
expect(wrapper.text()).toContain('Your account is verified')
|
expect(wrapper.text()).toContain('Your account is verified')
|
||||||
expect(wrapper.text()).toContain('Taking you to your dashboard…')
|
expect(wrapper.text()).toContain('Taking you to your dashboard…')
|
||||||
expect(router.currentRoute.value.path).toBe('/register/validate')
|
expect(router.currentRoute.value.path).toBe('/register/validate')
|
||||||
|
|
||||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'), {
|
|
||||||
timeout: 2000,
|
|
||||||
})
|
|
||||||
expect(toastAdd).toHaveBeenCalledWith({
|
expect(toastAdd).toHaveBeenCalledWith({
|
||||||
color: 'success',
|
color: 'success',
|
||||||
icon: 'i-lucide-circle-check',
|
icon: 'i-lucide-circle-check',
|
||||||
@@ -109,6 +105,11 @@ describe('Success — 200 refreshes the session and navigates to /home with a to
|
|||||||
description: 'Welcome to RenewCMS.',
|
description: 'Welcome to RenewCMS.',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'), {
|
||||||
|
timeout: 2000,
|
||||||
|
})
|
||||||
|
expect(toastAdd).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
expect(session.session).toEqual({ verified: true })
|
expect(session.session).toEqual({ verified: true })
|
||||||
})
|
})
|
||||||
@@ -233,22 +234,23 @@ describe('No session — the inline log-in form replaces the spinner', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('No session — submit is disabled until both fields are filled', () => {
|
describe('No session — empty fields are caught on submit, as on Log in', () => {
|
||||||
it('enables the submit button once both fields have a value', async () => {
|
it('keeps the submit button enabled and shows both field errors without calling login', async () => {
|
||||||
|
const login = vi.fn(() => mockResponse(200))
|
||||||
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
|
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
|
||||||
'POST /v1/auth/verify': () => mockResponse(401),
|
'POST /v1/auth/verify': () => mockResponse(401),
|
||||||
|
'POST /v1/auth/login': login,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect((wrapper.find('button[type="submit"]').element as HTMLButtonElement).disabled).toBe(
|
const submit = wrapper.find('button[type="submit"]')
|
||||||
true,
|
expect((submit.element as HTMLButtonElement).disabled).toBe(false)
|
||||||
)
|
|
||||||
|
|
||||||
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
|
await wrapper.find('form').trigger('submit')
|
||||||
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
|
await flushPromises()
|
||||||
|
|
||||||
expect((wrapper.find('button[type="submit"]').element as HTMLButtonElement).disabled).toBe(
|
expect(wrapper.text()).toContain('Use at least 3 characters.')
|
||||||
false,
|
expect(wrapper.text()).toContain('Enter your password.')
|
||||||
)
|
expect(login).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+35
-6
@@ -40,7 +40,8 @@ const contentTypes: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const settleMs = 400
|
const settleMs = 400
|
||||||
const diffThreshold = 48
|
const diffThreshold = 24
|
||||||
|
const maxDiffPercent = 0.5
|
||||||
|
|
||||||
function slug(text: string) {
|
function slug(text: string) {
|
||||||
return text
|
return text
|
||||||
@@ -302,11 +303,18 @@ async function captureApp(
|
|||||||
await route.fulfill(fulfilment(mock))
|
await route.fulfill(fulfilment(mock))
|
||||||
})
|
})
|
||||||
const page = await context.newPage()
|
const page = await context.newPage()
|
||||||
if (setup.now) {
|
if (setup.paused) {
|
||||||
|
const time = setup.now ? new Date(setup.now) : new Date()
|
||||||
|
await page.clock.install({ time })
|
||||||
|
await page.clock.pauseAt(time)
|
||||||
|
} else if (setup.now) {
|
||||||
await page.clock.setFixedTime(new Date(setup.now))
|
await page.clock.setFixedTime(new Date(setup.now))
|
||||||
}
|
}
|
||||||
await page.goto(`${origin}${setup.path}`)
|
await page.goto(`${origin}${setup.path}`)
|
||||||
await Promise.race([page.waitForLoadState('networkidle'), page.waitForTimeout(2000)])
|
await Promise.race([page.waitForLoadState('networkidle'), page.waitForTimeout(2000)])
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => (document.querySelector('#app')?.textContent ?? '').trim().length > 0,
|
||||||
|
)
|
||||||
await setup.steps?.(page)
|
await setup.steps?.(page)
|
||||||
await page.evaluate(() => document.fonts.ready)
|
await page.evaluate(() => document.fonts.ready)
|
||||||
await page.waitForTimeout(settleMs)
|
await page.waitForTimeout(settleMs)
|
||||||
@@ -386,8 +394,9 @@ async function main() {
|
|||||||
|
|
||||||
const design = await serveDesign()
|
const design = await serveDesign()
|
||||||
const app = await serveApp()
|
const app = await serveApp()
|
||||||
const browser = await chromium.launch()
|
const browser = await chromium.launch({ args: ['--disable-lcd-text'] })
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
|
let failures = 0
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const viewport of args.viewports) {
|
for (const viewport of args.viewports) {
|
||||||
@@ -402,8 +411,9 @@ async function main() {
|
|||||||
|
|
||||||
const setup = setups[state]
|
const setup = setups[state]
|
||||||
if (!setup) {
|
if (!setup) {
|
||||||
|
failures++
|
||||||
lines.push(
|
lines.push(
|
||||||
`${state} · ${viewport} · ${theme}: design only, no setup in web/visual/states.ts → ${relative(repoRoot, designPath)}`,
|
`FAIL ${state} · ${viewport} · ${theme}: no setup in web/visual/states.ts → ${relative(repoRoot, designPath)}`,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -419,14 +429,25 @@ async function main() {
|
|||||||
appPath,
|
appPath,
|
||||||
)
|
)
|
||||||
const result = await diff(browser, designPath, appPath, diffPath)
|
const result = await diff(browser, designPath, appPath, diffPath)
|
||||||
const warnings = [
|
const percent = result.ratio * 100
|
||||||
|
const budget = setup.exception?.maxPercent ?? maxDiffPercent
|
||||||
|
const problems = [
|
||||||
|
...(percent > budget
|
||||||
|
? [`${percent.toFixed(2)}% exceeds ${budget}% budget`]
|
||||||
|
: []),
|
||||||
...(result.sizes.split(' vs ')[0] !== result.sizes.split(' vs ')[1]
|
...(result.sizes.split(' vs ')[0] !== result.sizes.split(' vs ')[1]
|
||||||
? [`size mismatch ${result.sizes}`]
|
? [`size mismatch ${result.sizes}`]
|
||||||
: []),
|
: []),
|
||||||
...unmocked.map((key) => `unmocked ${key}`),
|
...unmocked.map((key) => `unmocked ${key}`),
|
||||||
]
|
]
|
||||||
|
if (problems.length) {
|
||||||
|
failures++
|
||||||
|
}
|
||||||
|
const exception = setup.exception
|
||||||
|
? ` [exception ${setup.exception.maxPercent}% approved by ${setup.exception.approvedBy}: ${setup.exception.reason}]`
|
||||||
|
: ''
|
||||||
lines.push(
|
lines.push(
|
||||||
`${state} · ${viewport} · ${theme}: ${(result.ratio * 100).toFixed(1)}% pixels differ → ${relative(repoRoot, directory)}${warnings.length ? ` (${warnings.join('; ')})` : ''}`,
|
`${problems.length ? 'FAIL' : 'PASS'} ${state} · ${viewport} · ${theme}: ${percent.toFixed(2)}% pixels differ → ${relative(repoRoot, directory)}${problems.length ? ` (${problems.join('; ')})` : ''}${exception}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
await board.context().close()
|
await board.context().close()
|
||||||
@@ -438,14 +459,22 @@ async function main() {
|
|||||||
design.close()
|
design.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const verdict = failures
|
||||||
|
? `FAIL: ${failures} of ${lines.length} captures failed (budget ${maxDiffPercent}% per capture)`
|
||||||
|
: `PASS: all ${lines.length} captures within budget (${maxDiffPercent}% unless an approved exception is listed)`
|
||||||
const report = [
|
const report = [
|
||||||
`# Visual check: ${screen.name}`,
|
`# Visual check: ${screen.name}`,
|
||||||
`Board: ${screen.board}, group "${screen.group}"`,
|
`Board: ${screen.board}, group "${screen.group}"`,
|
||||||
'',
|
'',
|
||||||
|
verdict,
|
||||||
|
'',
|
||||||
...lines.map((line) => `- ${line}`),
|
...lines.map((line) => `- ${line}`),
|
||||||
].join('\n')
|
].join('\n')
|
||||||
await writeFile(join(outRoot, screen.name, 'report.md'), `${report}\n`)
|
await writeFile(join(outRoot, screen.name, 'report.md'), `${report}\n`)
|
||||||
console.log(report)
|
console.log(report)
|
||||||
|
if (failures) {
|
||||||
|
process.exitCode = 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await main()
|
await main()
|
||||||
+20
-2
@@ -7,9 +7,17 @@ export interface ApiMock {
|
|||||||
hang?: boolean
|
hang?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DiffException {
|
||||||
|
maxPercent: number
|
||||||
|
reason: string
|
||||||
|
approvedBy: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface VisualState {
|
export interface VisualState {
|
||||||
|
exception?: DiffException
|
||||||
path: string
|
path: string
|
||||||
now?: string
|
now?: string
|
||||||
|
paused?: boolean
|
||||||
api?: Record<string, ApiMock>
|
api?: Record<string, ApiMock>
|
||||||
steps?: (page: Page) => Promise<void>
|
steps?: (page: Page) => Promise<void>
|
||||||
}
|
}
|
||||||
@@ -182,7 +190,15 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
|||||||
},
|
},
|
||||||
'log-in': {
|
'log-in': {
|
||||||
Default: { path: '/login', api: { 'GET /v1/auth/registration': registrationClosed } },
|
Default: { path: '/login', api: { 'GET /v1/auth/registration': registrationClosed } },
|
||||||
'Fresh install': { path: '/login', api: { 'GET /v1/auth/registration': registrationOpen } },
|
'Fresh install': {
|
||||||
|
path: '/login',
|
||||||
|
api: { 'GET /v1/auth/registration': registrationOpen },
|
||||||
|
exception: {
|
||||||
|
maxPercent: 1.1,
|
||||||
|
reason: 'The board draws a design-time "API pending" chip in the footer; the API exists, so the app omits it and the theme select fits on the first row on mobile',
|
||||||
|
approvedBy: 'Florian Sylvain, 2026-09-26',
|
||||||
|
},
|
||||||
|
},
|
||||||
'Validation errors': {
|
'Validation errors': {
|
||||||
...loginWith({ status: 500 }),
|
...loginWith({ status: 500 }),
|
||||||
steps: submitLogin('ma', ''),
|
steps: submitLogin('ma', ''),
|
||||||
@@ -290,6 +306,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
|||||||
},
|
},
|
||||||
'Already verified (403)': {
|
'Already verified (403)': {
|
||||||
path: '/register/pending',
|
path: '/register/pending',
|
||||||
|
paused: true,
|
||||||
api: {
|
api: {
|
||||||
'DELETE /v1/auth/register': { status: 403 },
|
'DELETE /v1/auth/register': { status: 403 },
|
||||||
'GET /v1/auth/me': { status: 200, body: { is_verified: true } },
|
'GET /v1/auth/me': { status: 200, body: { is_verified: true } },
|
||||||
@@ -298,7 +315,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
|||||||
await setPendingEmail(page, 'marta@renew.example')
|
await setPendingEmail(page, 'marta@renew.example')
|
||||||
await openCancelDialog(page)
|
await openCancelDialog(page)
|
||||||
await confirmCancelDialog(page)
|
await confirmCancelDialog(page)
|
||||||
await page.waitForURL('**/home')
|
await page.getByText("You're verified").waitFor()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -309,6 +326,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
|||||||
},
|
},
|
||||||
Success: {
|
Success: {
|
||||||
path: '/register/validate?c=abc123',
|
path: '/register/validate?c=abc123',
|
||||||
|
paused: true,
|
||||||
api: {
|
api: {
|
||||||
'POST /v1/auth/verify': { status: 200, body: { message: 'ok' } },
|
'POST /v1/auth/verify': { status: 200, body: { message: 'ok' } },
|
||||||
'GET /v1/auth/me': { status: 200, body: { is_verified: true } },
|
'GET /v1/auth/me': { status: 200, body: { is_verified: true } },
|
||||||
|
|||||||
Reference in new issue
Block a user