fix: visuals reference matching overhaul and better workflow

This commit is contained in:
fsylvain committed 2026-09-26 21:04:51 +02:00
1 parent 930b836241
commit d493a181e1
25 files changed
+460 -240

No files matched your search

+3
View File
@@ -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"
+9 -8
View File
@@ -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?" →
+12 -11
View File
@@ -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
+6 -6
View File
@@ -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.
+19 -13
View File
@@ -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
+10 -7
View File
@@ -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).
+10
View File
@@ -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);
} }
+29 -16
View File
@@ -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>
+8 -3
View File
@@ -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 }}
+16 -12
View File
@@ -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>
+17 -10
View File
@@ -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,
)
}, },
) )
}) })
+5
View File
@@ -0,0 +1,5 @@
export const VERIFIED_REDIRECT_DELAY_MS = 800
export function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
+87 -9
View File
@@ -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: {
+1 -7
View File
@@ -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
View File
@@ -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>
+15 -2
View File
@@ -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.
+31 -11
View File
@@ -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>
+63 -27
View File
@@ -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()
}) })
}) })
+9 -2
View File
@@ -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()
}) })
+16 -14
View File
@@ -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
View File
@@ -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
View File
@@ -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 } },