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
+461 -241

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."
And the Username field still shows "marta" and the Password field still shows "limewash"
And the submit button reads "Try again in 0:42" and is disabled
And the Username and Password fields are disabled while the countdown runs, as the board draws
them
When 42 seconds elapse
Then the submit button re-enables and reads "Log in"
And the Username and Password fields are editable again
Scenario: Session expired — redirected from a 401 elsewhere
Given the user is redirected to "/login?redirect=/article/12/edit&reason=expired"
+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
with title "Too many attempts" and description "For your security, wait a moment before trying
again."; assert Username still reads "marta" and Password still reads "limewash"; assert the
submit button reads "Try again in 0:42" and is disabled; advance fake timers by 42 seconds;
assert the button re-enables and reads "Log in".
submit button reads "Try again in 0:42" and is disabled; assert both fields are disabled;
advance fake timers by 42 seconds; assert the button re-enables and reads "Log in" and both fields
are editable again.
7. **Session expired state** — mount at
"/login?redirect=/article/12/edit&reason=expired"; assert an info alert with title "Your session
expired" and description "Log in again to pick up where you left off. Unsaved changes are kept on
@@ -71,12 +72,12 @@ Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Log in".
and mobile, light and dark.
- [ ] Wrong credentials state: error alert (circle-x icon, `role="alert"`) above the heading,
cleared/focused Password field, desktop and mobile, light and dark.
- [ ] Too many attempts state: info alert (timer icon), disabled button with the live "Try again in
m:ss" countdown text, desktop and mobile, light and dark.
- [ ] Session expired state: info alert (clock icon), focused Password field, desktop and mobile,
light and dark.
- [ ] Password changed state: success alert (circle-check icon, dismissible close button per
components.md), desktop and mobile, light and dark.
- [ ] Too many attempts state: info alert (timer icon), disabled fields and a disabled button with
the live "Try again in m:ss" countdown text, desktop and mobile, light and dark.
- [ ] Session expired state: info alert (clock icon, no close button), focused Password field,
desktop and mobile, light and dark.
- [ ] Password changed state: success alert (circle-check icon, no close button, as the board draws
it), desktop and mobile, light and dark.
- [ ] Server or network error state: error alert (circle-x icon, no close button), fields retain
their values, desktop and mobile, light and dark.
- [ ] Keyboard/focus: Tab order is Username → Password → show/hide toggle → "Forgot password?" →
+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
action first awaits session.retry() so the session store's cached verified flag is refreshed
to true — without this, the router guard's stale local state would immediately bounce a
navigation to /home straight back to /register/pending — then the dialog closes, the app
navigates to /home, and a toast (color "info", icon "i-lucide-info", title "Your account is
already verified", description "There's nothing to cancel.") is shown; PendingPage does not
render a ResultPanel for this case, since the ResultPanel drawn in this state on the design
board is Verify's own Success panel, reused purely to depict the destination page behind the
toast
navigation to /home straight back to /register/pending — then the dialog closes,
PendingPage replaces its panel with a ResultPanel (tone "success", icon
"i-lucide-shield-check", title "You're verified", text "Taking you to your dashboard…", no
actions) and shows a toast (color "info", icon "i-lucide-info", title "Your account is already
verified", description "There's nothing to cancel.") over it, exactly as the design board
draws this state, then navigates to /home after the same 800 ms Verify's Success uses
And on a 500 response or a request that never reaches the server, the action rejects, the
dialog stays open, and it shows the failure inline (per ConfirmDialog's general contract:
"the action runs inside the dialog, so errors show there instead of a toast"), re-enabling
@@ -126,17 +126,18 @@ Feature: Pending registration screen
And a toast is shown with color "success", icon "i-lucide-circle-check", title "Registration
cancelled", description "You can create the owner account again."
Scenario: Already verified — 403 refreshes the session and navigates to /home with a toast
Scenario: Already verified — 403 shows the verified panel and toast, then navigates to /home
Given the ConfirmDialog's action is in flight after the user confirmed
And the session store currently holds { verified: false }
When DELETE /v1/auth/register resolves 403 with the apperr.AlreadyVerifiedErrorMessage body
Then session.retry() is awaited before any navigation happens
And once it resolves the session store holds { verified: true }
And the dialog closes
And the app navigates to /home
And a toast is shown with color "info", icon "i-lucide-info", title "Your account is already
verified", description "There's nothing to cancel."
And no ResultPanel is rendered by PendingPage for this transition
And a ResultPanel is shown with tone "success", icon "i-lucide-shield-check", title "You're
verified", text "Taking you to your dashboard…", and no actions
And a toast is shown over it with color "info", icon "i-lucide-info", title "Your account is
already verified", description "There's nothing to cancel."
And after 800 ms the app navigates to /home
Scenario Outline: Cancel failed — the dialog stays open and shows the failure inline
Given the ConfirmDialog's action is in flight after the user confirmed
+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'`;
confirm the dialog; mock `DELETE /v1/auth/register` resolving 403; mock the session store's
`retry()` to resolve and set `session.session = { verified: true }`; assert `retry()` was awaited
before the navigation; assert the router's current path is /home; assert a toast was raised with
color "info", icon "i-lucide-info", title "Your account is already verified", description
"There's nothing to cancel."; assert no ResultPanel assertions target this transition (Pending
unmounts as the route changes).
before the navigation; assert the "You're verified" ResultPanel ("Taking you to your
dashboard…", no actions) is shown and a toast was raised with color "info", icon "i-lucide-info",
title "Your account is already verified", description "There's nothing to cancel." while the
path is not yet /home; then assert the router reaches /home after 800 ms with no second toast.
9. **Cancel failed state** — two sub-cases: (a) a 500 response, (b) a rejected fetch. In each,
confirm the dialog, resolve/reject the mock accordingly, and assert the dialog stays open, an
inline failure indicator is shown inside it (not a toast — assert no toast was raised), and both
@@ -63,8 +63,8 @@ Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Pending".
- [ ] Cancelling state: disabled buttons, "Cancelling…" label, spinner/aria-busy styling.
- [ ] Cancelled state (transient, capture via visual test hook or a frozen mock): toast appearance
over the /register board.
- [ ] Already verified state: toast appearance over the /home board (per the design board's
illustrative use of Verify's Success ResultPanel as the backdrop).
- [ ] Already verified state: the "You're verified" ResultPanel with the info toast over it,
captured before the 800 ms redirect (the visual setup freezes timers with `paused: true`).
- [ ] Keyboard/focus: focus starts on "Keep my account" in the dialog and Enter does not confirm;
while cancelling, focus cannot leave the dialog; on the ResultPanel states, focus lands on the
title on mount; toasts are announced without stealing focus.
+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-
expired state instead of the global "unverified session" redirect
And Verifying and No-session-retrying both render the same full-card spinner: a centered
"i-lucide-loader-2" icon with a spin animation and the text "Verifying your account…",
"i-lucide-loader-2" icon with a spin animation, the title "Verifying your account…" and the
line "This only takes a moment.", as the design board's AuthCard spinner draws it,
role="status", replacing AuthShell's whole card body with no title header shown
And on a 200, VerifyPage shows a ResultPanel with tone "success", icon "i-lucide-shield-check",
title "Your account is verified", text "Taking you to your dashboard…", and no actions, while
concurrently awaiting session.retry() (needed so /home's route guard sees the now-verified
session instead of whatever stale state this tab last cached) and an 800 ms timer; once both
settle, the app navigates to /home and a toast (color "success", icon "i-lucide-circle-check",
title "Your account is verified", description "Welcome to RenewCMS.") is shown
a toast (color "success", icon "i-lucide-circle-check", title "Your account is verified",
description "Welcome to RenewCMS.") is shown immediately over that panel, as the design board
draws it, while concurrently awaiting session.retry() (needed so /home's route guard sees the
now-verified session instead of whatever stale state this tab last cached) and an 800 ms
timer; once both settle, the app navigates to /home
And on a 403 (user.ErrInvalidVerificationCode or user.ErrVerificationCodeExpired), VerifyPage
shows a ResultPanel with tone "error", icon "i-lucide-link-2-off", title "This verification
link doesn't work", text "It may have expired (links last 2 hours) or already been used.",
@@ -61,8 +63,10 @@ Feature: Verify account screen
username") and a Password field (password input with a show/hide toggle), and a submit button
"Log in and verify"; there is no "Forgot password?" hint and no registration-open footer,
unlike LoginView
And the inline form's submit is disabled until both fields are non-empty; on submit it calls
POST /v1/auth/login with { username, password }
And the inline form's submit button is enabled, as the design board draws it; on submit,
LoginView's own validation runs first (Username under 3 characters shows "Use at least 3
characters.", an empty Password shows "Enter your password.", and nothing is sent), and a
valid form calls POST /v1/auth/login with { username, password }
And a 200 from that login call transitions to the No-session-retrying spinner and immediately
retries POST /v1/auth/verify with the same code and the same passthrough options, which then
follows the 200/403 handling above
@@ -85,10 +89,10 @@ Feature: Verify account screen
When POST /v1/auth/verify resolves 200
Then a ResultPanel is shown with tone "success", icon "i-lucide-shield-check", title "Your
account is verified", text "Taking you to your dashboard…", and no actions
And a toast is shown over it with color "success", icon "i-lucide-circle-check", title "Your
account is verified", description "Welcome to RenewCMS."
And session.retry() is awaited
And after 800 ms and once session.retry() has resolved, the app navigates to /home
And a toast is shown with color "success", icon "i-lucide-circle-check", title "Your account is
verified", description "Welcome to RenewCMS."
Scenario: Invalid or expired — 403 offers a way to start over
Given the user is on "/register/validate?c=abc123" in the Verifying state
@@ -142,11 +146,12 @@ Feature: Verify account screen
toggle, and a submit button "Log in and verify"
And there is no "Forgot password?" link and no registration-open footer
Scenario: No session — submit is disabled until both fields are filled
Scenario: No session — empty fields are caught on submit, as on Log in
Given the inline log-in form from "No session" is shown
Then the submit button is disabled
When the user types "marta" into Username and "limewash2026" into Password
Then the submit button is enabled
When the user submits with both fields empty
Then Username shows "Use at least 3 characters." and Password shows "Enter your password."
And POST /v1/auth/login is not called
Scenario: No session — successful login retries verification with the same code
Given the inline log-in form from "No session" is shown on "/register/validate?c=abc123"
@@ -171,7 +176,8 @@ Feature: Verify account screen
And POST /v1/auth/login resolves 429 with Retry-After 30
Then an info alert is shown with title "Too many attempts" and description "For your security,
wait a moment before trying again."
And the submit button reads "Try again in 0:30" and is disabled until the countdown ends
And the submit button reads "Try again in 0:30" and it and both fields are disabled until the
countdown ends, mirroring LoginView
Scenario Outline: No session — server or network error during the inline login
Given the inline log-in form from "No session" is shown
+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
needs; use fake timers for the 800 ms success delay and the 429 countdown.
1. **Verifying state** — mount at "/register/validate?c=abc123"; assert the full-card spinner text
1. **Verifying state** — mount at "/register/validate?c=abc123"; assert the full-card spinner title
"Verifying your account…" and that `POST /v1/auth/verify` was called exactly once with
`{ code: 'abc123' }` and `{ onUnauthorized: 'passthrough', onForbidden: 'passthrough' }`.
2. **Success state** — mock `POST /v1/auth/verify` resolving 200; mock the session store's
`retry()` to resolve; mount and let it settle; assert the ResultPanel (success, shield-check,
"Your account is verified", "Taking you to your dashboard…", no actions); advance fake timers by
800 ms; assert `retry()` was called; assert the router's current path is /home and a toast was
raised with title "Your account is verified", description "Welcome to RenewCMS."
"Your account is verified", "Taking you to your dashboard…", no actions); assert a toast was
already raised with title "Your account is verified", description "Welcome to RenewCMS." while
the path is still /register/validate; advance by 800 ms; assert `retry()` was called and the
router's current path is /home, with no second toast.
3. **Invalid or expired state** — mock `POST /v1/auth/verify` resolving 403; assert the ResultPanel
(error, link-2-off, "This verification link doesn't work", the text and note above) with actions
"Cancel registration and start over" (primary) and "Log out" (secondary).
@@ -38,8 +39,9 @@ needs; use fake timers for the 800 ms success delay and the 429 countdown.
to finish verifying", description, Username placeholder "Your username", Password with show/hide,
submit "Log in and verify") and assert no "Forgot password?" link and no registration footer are
rendered.
8. **No session — submit disabled until filled** — from state 7, assert the submit button is
disabled; fill both fields; assert it becomes enabled.
8. **No session — empty fields are caught on submit** — from state 7, assert the submit button is
enabled; submit with both fields empty; assert "Use at least 3 characters." and "Enter your
password." are shown and `POST /v1/auth/login` was not called.
9. **No session — successful login retries** — from state 7, fill valid credentials; mock
`POST /v1/auth/login` resolving 200; mock a second `POST /v1/auth/verify` call; submit; assert the
spinner "Verifying your account…" reappears and the second `POST /v1/auth/verify` call was made
@@ -67,7 +69,8 @@ Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Verify". R
- [ ] Verifying state: the full-card spinner (the only one in the product) matches the board's
layout exactly, with no title header shown.
- [ ] Success state: ResultPanel with no actions, transient toast over the /home board.
- [ ] Success state: ResultPanel with no actions and the success toast over it, captured before
the 800 ms redirect (the visual setup freezes timers with `paused: true`).
- [ ] Invalid or expired state: ResultPanel with note and both actions, button order (primary
"Cancel registration and start over" first per components.md's safe/primary-on-top rule for
stacked mobile buttons).
+10
View File
@@ -155,6 +155,11 @@
--ui-info: var(--ui-color-info-700);
--ui-warning: var(--ui-color-warning-700);
--ui-error: var(--ui-color-error-700);
--rc-success-soft: #eaefeb;
--rc-info-soft: #ebeef0;
--rc-warning-soft: #f0ede6;
--rc-error-soft: #f3eae9;
--rc-overlay: rgb(10 10 10 / 0.4);
}
.dark {
@@ -179,6 +184,11 @@
--ui-info: var(--ui-color-info-300);
--ui-warning: var(--ui-color-warning-300);
--ui-error: var(--ui-color-error-300);
--rc-success-soft: #202420;
--rc-info-soft: #202325;
--rc-warning-soft: #26221c;
--rc-error-soft: #28201e;
--rc-overlay: rgb(0 0 0 / 0.6);
--shadow-popover: 0 4px 16px rgb(0 0 0 / 0.5);
--shadow-modal: 0 16px 48px rgb(0 0 0 / 0.6);
}
+30 -17
View File
@@ -33,29 +33,38 @@ onUnmounted(() => {
})
const controlSize = computed(() => (belowSm.value ? 'xl' : undefined))
const controlClass = computed(() => (belowSm.value ? '!h-11 !text-base' : '!h-10 !text-sm'))
const controlClass = computed(() => (belowSm.value ? '!h-11 !text-base' : '!h-10 !text-base'))
const inputClass = computed(() => (belowSm.value ? '!h-11 !text-lg' : '!h-10 !text-base'))
const mainClass = computed(() => [
'flex min-h-screen flex-col items-center',
belowSm.value ? 'bg-default' : 'bg-muted pt-[12vh]',
'flex min-h-screen flex-col items-center gap-5',
belowSm.value ? 'bg-default pt-10' : 'bg-muted pt-[12vh]',
])
const cardClass = computed(() => (belowSm.value ? 'w-full' : 'w-full max-w-[400px]'))
const cardUi = computed(() => ({
root: belowSm.value ? 'ring-0' : '',
container: belowSm.value ? 'pt-2 px-5 pb-0' : 'p-10',
header: 'mb-6',
title: 'text-xl font-semibold leading-7',
root: belowSm.value ? 'rounded-none ring-0' : 'rounded-none ring-0 border border-default',
container: belowSm.value
? 'flex flex-col gap-6 px-5 pt-2 pb-0 lg:flex'
: 'flex flex-col gap-6 p-10 sm:p-10 lg:flex',
header: props.title ? 'mb-6' : 'mb-0',
title: 'text-xl/7 font-semibold',
description: 'mt-1.5 text-base text-muted',
}))
const footerClass = computed(() => [
'mt-4 flex w-full items-center justify-between',
belowSm.value ? 'pt-0 px-5 pb-6' : 'p-0',
'flex w-full flex-wrap items-center justify-between gap-3 text-sm text-muted',
belowSm.value ? 'px-5 pb-6' : 'p-0',
cardClass.value,
])
const colorModeSelectUi = { trailing: 'hidden' }
const colorModeSelectUi = {
base: 'h-7 ps-7 pe-2 text-sm text-toned',
leading: 'ps-2',
leadingIcon: 'size-3.5 text-toned',
trailing: 'hidden',
}
watch(
() => props.title,
@@ -96,16 +105,20 @@ watch(
<h1 ref="titleEl" tabindex="-1" class="outline-none">{{ title }}</h1>
</template>
<div v-if="slots.notice" class="mb-4">
<slot name="notice" />
</div>
<slot v-if="slots.notice" name="notice" />
<slot :control-size="controlSize" :control-class="controlClass" />
<slot
:control-size="controlSize"
:control-class="controlClass"
:input-class="inputClass"
/>
</UPageCard>
<footer :class="footerClass">
<slot name="footer" />
<UColorModeSelect size="sm" class="ms-auto" :ui="colorModeSelectUi" />
<footer v-if="!belowSm || $slots.footer" :class="footerClass">
<span class="flex flex-wrap items-center gap-1.5">
<slot name="footer" />
</span>
<UColorModeSelect size="sm" :ui="colorModeSelectUi" />
</footer>
</main>
</template>
+8 -3
View File
@@ -76,7 +76,12 @@ function onCancel() {
:content="{ role: 'alertdialog', 'aria-busy': pending ? 'true' : undefined }"
:ui="{
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>
@@ -96,7 +101,7 @@ function onCancel() {
color="neutral"
variant="outline"
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"
@click="onCancel"
>
@@ -107,7 +112,7 @@ function onCancel() {
:loading="pending"
:disabled="pending"
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"
>
{{ confirmText }}
+16 -12
View File
@@ -11,16 +11,23 @@ const props = defineProps<{
showErrors: boolean
}>()
function itemClass(rule: PasswordRule) {
function iconClass(rule: PasswordRule) {
if (rule.met) {
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) {
if (rule.met) {
return 'i-lucide-circle-check'
return 'i-lucide-check'
}
return props.showErrors ? 'i-lucide-circle-alert' : 'i-lucide-circle'
}
@@ -28,21 +35,18 @@ function itemIcon(rule: PasswordRule) {
const items = computed(() =>
props.rules.map((rule) => ({
...rule,
classes: itemClass(rule),
iconClass: iconClass(rule),
textClass: textClass(rule),
icon: itemIcon(rule),
})),
)
</script>
<template>
<ul class="flex flex-col gap-1">
<li
v-for="item in items"
:key="item.label"
:class="['flex items-center gap-1.5 text-sm', item.classes]"
>
<UIcon :name="item.icon" class="shrink-0" />
{{ item.label }}
<ul class="flex flex-col gap-1.5">
<li v-for="item in items" :key="item.label" class="flex items-center gap-2 text-xs/[18px]">
<UIcon :name="item.icon" :class="['size-3.5 shrink-0', item.iconClass]" />
<span :class="item.textClass">{{ item.label }}</span>
<span class="sr-only">{{ item.met ? 'met' : 'not met' }}</span>
</li>
</ul>
+17 -10
View File
@@ -21,9 +21,9 @@ const props = defineProps<{
const role = computed(() => (props.tone === 'error' ? 'alert' : 'status'))
const toneTileClass: Record<'success' | 'error' | 'info', string> = {
success: 'bg-success/10 text-success',
error: 'bg-error/10 text-error',
info: 'bg-info/10 text-info',
success: 'bg-(--rc-success-soft) text-success',
error: 'bg-(--rc-error-soft) text-error',
info: 'bg-(--rc-info-soft) text-info',
}
const tileClass = computed(() => toneTileClass[props.tone])
@@ -38,24 +38,31 @@ onMounted(async () => {
<template>
<div :role="role" class="flex flex-col gap-4">
<div :class="['flex h-11 w-11 items-center justify-center', tileClass]">
<UIcon :name="icon" class="h-6 w-6" />
<div :class="['flex size-11 items-center justify-center', tileClass]">
<UIcon :name="icon" class="size-5.5" />
</div>
<div class="flex flex-col gap-1.5">
<h1 ref="titleEl" tabindex="-1" class="text-xl font-semibold leading-7 outline-none">
<h1
ref="titleEl"
tabindex="-1"
class="text-xl/7 font-semibold text-highlighted outline-none"
>
{{ title }}
</h1>
<p class="text-toned text-sm">{{ description }}</p>
<p class="text-base text-pretty text-toned">{{ description }}</p>
<slot />
</div>
<slot />
<div class="flex w-full flex-col gap-2">
<div v-if="actions.length" class="flex w-full flex-col gap-2">
<UButton
v-for="action in actions"
:key="action.label"
block
size="lg"
:color="action.color ?? 'primary'"
:variant="action.variant ?? 'solid'"
:class="[
'h-11 justify-center text-base sm:h-10',
action.variant === 'outline' ? 'text-highlighted' : '',
]"
:to="action.to"
:disabled="action.disabled"
@click="action.onClick"
@@ -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', () => {
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)
const wrapper = mount(AuthShell, {
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"]')
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"]')
expect(container.classes()).not.toContain('p-5')
@@ -55,57 +55,6 @@ afterEach(async () => {
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', () => {
it('uses the muted toned color, underline and 3px offset, no other emphasis', async () => {
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', () => {
it('renders as the last child, showing only the mode icon and label', async () => {
stubMatchMedia(false)
const wrapper = await mountLogin()
const footer = wrapper.find('footer')
@@ -201,7 +151,9 @@ describe('The alert renders inside the card, under the description', () => {
expect(html.indexOf(text)).toBeLessThan(html.indexOf('Username'))
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 = {
colors: {
@@ -37,7 +37,7 @@ export const renewUi = {
},
button: {
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: {
size: {
@@ -45,20 +45,61 @@ export const renewUi = {
leadingIcon: '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: {
size: 'md',
},
},
input: {
formField: {
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: {
size: {
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: {
slots: {
root: 'rounded-none shadow-(--shadow-popover)',
progress: 'rounded-none',
root: 'rounded-none shadow-(--shadow-popover) ring-0 border border-default gap-2.5 px-3.5 py-3',
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: {
slots: {
viewport: 'z-(--z-toast)',
viewport: 'z-(--z-toast) sm:right-6 sm:bottom-6 sm:w-89',
},
},
alert: {
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: {
slots: {
+1 -7
View File
@@ -226,13 +226,7 @@ onMounted(loadArticles)
icon="i-lucide-circle-x"
title="Couldn't load your articles"
description="Check your connection and try again."
:ui="{
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',
}"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
+26 -20
View File
@@ -11,7 +11,6 @@ interface AlertState {
icon: string
title: string
description: string
closable: boolean
}
const wrongCredentialsAlert: AlertState = {
@@ -19,7 +18,6 @@ const wrongCredentialsAlert: AlertState = {
icon: 'i-lucide-circle-x',
title: "That username and password don't match",
description: 'Check both and try again. Passwords are case-sensitive.',
closable: false,
}
const tooManyAttemptsAlert: AlertState = {
@@ -27,7 +25,6 @@ const tooManyAttemptsAlert: AlertState = {
icon: 'i-lucide-timer',
title: 'Too many attempts',
description: 'For your security, wait a moment before trying again.',
closable: false,
}
const serverErrorAlert: AlertState = {
@@ -35,7 +32,6 @@ const serverErrorAlert: AlertState = {
icon: 'i-lucide-circle-x',
title: "Couldn't reach RenewCMS",
description: 'Check your connection and try again.',
closable: false,
}
const sessionExpiredAlert: AlertState = {
@@ -44,7 +40,6 @@ const sessionExpiredAlert: AlertState = {
title: 'Your session expired',
description:
'Log in again to pick up where you left off. Unsaved changes are kept on this device.',
closable: true,
}
const passwordChangedAlert: AlertState = {
@@ -52,7 +47,6 @@ const passwordChangedAlert: AlertState = {
icon: 'i-lucide-circle-check',
title: 'Password changed',
description: 'Log in with your new password.',
closable: true,
}
const route = useRoute()
@@ -205,14 +199,13 @@ onMounted(async () => {
:title="alert.title"
:description="alert.description"
variant="soft"
:close="alert.closable"
:close="false"
:role="alertRole"
@close="alert = null"
/>
</template>
<template #default="{ controlSize, controlClass }">
<UForm :state="{ username, password }" class="flex flex-col gap-4" @submit="onSubmit">
<template #default="{ controlSize, controlClass, inputClass }">
<UForm :state="{ username, password }" class="flex flex-col gap-4.5" @submit="onSubmit">
<UFormField
label="Username"
name="username"
@@ -221,7 +214,10 @@ onMounted(async () => {
>
<template v-if="usernameError" #error="{ error }">
<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 }}
</span>
</template>
@@ -229,12 +225,12 @@ onMounted(async () => {
v-model="username"
placeholder="Your username"
autocomplete="username"
:disabled="submitting"
:disabled="submitDisabled"
variant="outline"
color="primary"
:highlight="!!usernameError"
:size="controlSize"
:ui="{ base: controlClass }"
:ui="{ base: inputClass, trailing: 'pe-1' }"
class="w-full"
/>
</UFormField>
@@ -255,7 +251,10 @@ onMounted(async () => {
</template>
<template v-if="passwordError" #error="{ error }">
<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 }}
</span>
</template>
@@ -263,12 +262,15 @@ onMounted(async () => {
v-model="password"
:type="passwordShown ? 'text' : 'password'"
autocomplete="current-password"
:disabled="submitting"
:disabled="submitDisabled"
variant="outline"
color="primary"
:highlight="!!passwordError"
:size="controlSize"
:ui="{ base: controlClass }"
:ui="{
base: `${inputClass} ${passwordShown ? '' : 'tracking-[2px]'}`,
trailing: 'pe-1',
}"
class="w-full"
>
<template #trailing>
@@ -278,6 +280,7 @@ onMounted(async () => {
color="neutral"
size="sm"
square
class="size-8 justify-center text-toned"
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
:aria-pressed="passwordShown"
@click="passwordShown = !passwordShown"
@@ -300,12 +303,15 @@ onMounted(async () => {
</template>
<template v-if="registrationOpen" #footer>
<p>
<span class="flex flex-wrap items-center gap-1.5">
First time here?
<RouterLink to="/register" class="text-primary"
>Create the owner account</RouterLink
<RouterLink
to="/register"
class="font-medium text-highlighted underline underline-offset-3"
>
</p>
Create the owner account
</RouterLink>
</span>
</template>
</AuthShell>
</template>
+15 -2
View File
@@ -7,6 +7,7 @@ import ConfirmDialog from '@/components/ConfirmDialog.vue'
import ResultPanel from '@/components/ResultPanel.vue'
import { ApiError, useApi } from '@/composables/useApi'
import { registrationCancelledToast } from '@/lib/registrationToasts'
import { VERIFIED_REDIRECT_DELAY_MS, wait } from '@/lib/verifiedRedirect'
import { useSessionStore } from '@/stores/session'
const router = useRouter()
@@ -18,6 +19,7 @@ const toast = useToast()
const pendingEmail = ref<string | null>(null)
const loggingOut = ref(false)
const cancelOutcome = ref<'cancelled' | 'alreadyVerified' | null>(null)
const verified = ref(false)
onMounted(() => {
pendingEmail.value = sessionStorage.getItem('renewcms:pendingEmail')
@@ -88,13 +90,15 @@ async function cancelRegistration() {
}
if (cancelOutcome.value === 'alreadyVerified') {
await router.push('/home')
verified.value = true
toast.add({
color: 'info',
icon: 'i-lucide-info',
title: 'Your account is already verified',
description: "There's nothing to cancel.",
})
await wait(VERIFIED_REDIRECT_DELAY_MS)
await router.push('/home')
}
}
@@ -119,13 +123,22 @@ const actions = computed(() => [
<template>
<AuthShell title="">
<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"
icon="i-lucide-mail-check"
title="Check your email"
:description="description"
: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
it? Check your spam folder. If the link has expired, cancel your registration and
start again.
+31 -11
View File
@@ -120,10 +120,10 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
/>
</template>
<template v-else #default="{ controlSize, controlClass }">
<template v-else #default="{ controlSize, controlClass, inputClass }">
<UForm
:state="{ username, email, password }"
class="flex flex-col gap-4"
class="flex flex-col gap-4.5"
@submit="onSubmit"
>
<UFormField
@@ -134,7 +134,10 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
>
<template v-if="usernameError" #error="{ error }">
<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 }}
</span>
</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.
</template>
<template #hint>
<span :class="usernameError ? 'text-error' : 'text-toned'">
<span
:class="[
'font-mono text-xs',
usernameError ? 'text-error' : 'text-muted',
]"
>
{{ username.length }} / 20
</span>
</template>
@@ -155,7 +163,7 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
color="primary"
:highlight="!!usernameError"
:size="controlSize"
:ui="{ base: controlClass }"
:ui="{ base: inputClass, trailing: 'pe-1' }"
class="w-full"
@blur="usernameTouched = true"
/>
@@ -169,7 +177,10 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
>
<template v-if="emailError" #error="{ error }">
<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 }}
</span>
</template>
@@ -184,7 +195,7 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
color="primary"
:highlight="!!emailError"
:size="controlSize"
:ui="{ base: controlClass }"
:ui="{ base: inputClass, trailing: 'pe-1' }"
class="w-full"
@blur="emailTouched = true"
/>
@@ -205,7 +216,10 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
variant="outline"
color="primary"
:size="controlSize"
:ui="{ base: controlClass }"
:ui="{
base: `${inputClass} ${passwordShown ? '' : 'tracking-[2px]'}`,
trailing: 'pe-1',
}"
class="w-full"
@blur="passwordTouched = true"
>
@@ -216,6 +230,7 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
color="neutral"
size="sm"
square
class="size-8 justify-center text-toned"
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
:aria-pressed="passwordShown"
@click="passwordShown = !passwordShown"
@@ -238,10 +253,15 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
</template>
<template v-if="!registrationClosed" #footer>
<p>
<span class="flex flex-wrap items-center gap-1.5">
Already set up?
<RouterLink to="/login" class="text-primary">Log in</RouterLink>
</p>
<RouterLink
to="/login"
class="font-medium text-highlighted underline underline-offset-3"
>
Log in
</RouterLink>
</span>
</template>
</AuthShell>
</template>
+63 -27
View File
@@ -7,6 +7,7 @@ import ResultPanel from '@/components/ResultPanel.vue'
import { ApiError, useApi } from '@/composables/useApi'
import { useRetryAfter } from '@/composables/useRetryAfter'
import { registrationCancelledToast } from '@/lib/registrationToasts'
import { VERIFIED_REDIRECT_DELAY_MS, wait } from '@/lib/verifiedRedirect'
import { useSessionStore } from '@/stores/session'
type VerifyState =
@@ -52,8 +53,6 @@ const cancelFailedAlert: AlertState = {
description: 'Something went wrong on the server. Try again in a moment.',
}
const VERIFICATION_SUCCESS_DELAY_MS = 800
const route = useRoute()
const router = useRouter()
const api = useApi()
@@ -78,13 +77,10 @@ const shellDescription = computed(() =>
: undefined,
)
const loginSubmitDisabled = computed(
() =>
username.value.length === 0 ||
password.value.length === 0 ||
loginSubmitting.value ||
retryAfter.active.value,
)
const usernameError = ref('')
const passwordError = ref('')
const loginSubmitDisabled = computed(() => loginSubmitting.value || retryAfter.active.value)
const loginSubmitLabel = computed(() => {
if (loginSubmitting.value) {
@@ -96,10 +92,6 @@ const loginSubmitLabel = computed(() => {
return 'Log in and verify'
})
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function focusPassword() {
await nextTick()
await nextTick()
@@ -116,14 +108,14 @@ async function performVerify(code: string) {
onForbidden: 'passthrough',
})
state.value = 'success'
await Promise.all([session.retry(), wait(VERIFICATION_SUCCESS_DELAY_MS)])
await router.push('/home')
toast.add({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Your account is verified',
description: 'Welcome to RenewCMS.',
})
await Promise.all([session.retry(), wait(VERIFIED_REDIRECT_DELAY_MS)])
await router.push('/home')
return
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
@@ -159,10 +151,19 @@ function handleLoginError(error: unknown) {
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() {
if (loginSubmitDisabled.value) {
return
}
if (!validateLogin()) {
return
}
loginAlert.value = null
loginSubmitting.value = true
try {
@@ -274,14 +275,15 @@ onMounted(() => {
/>
</template>
<template #default="{ controlSize, controlClass }">
<template #default="{ controlSize, controlClass, inputClass }">
<div
v-if="state === 'verifying' || state === 'noSessionRetrying'"
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" />
<p class="text-toned text-sm">Verifying your account…</p>
<UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-highlighted" />
<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>
<ResultPanel
@@ -301,7 +303,7 @@ onMounted(() => {
description="It may have expired (links last 2 hours) or already been used."
: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.
</p>
</ResultPanel>
@@ -318,33 +320,66 @@ onMounted(() => {
<UForm
v-else-if="state === 'noSession'"
:state="{ username, password }"
class="flex flex-col gap-4"
class="flex flex-col gap-4.5"
@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
v-model="username"
:highlight="!!usernameError"
placeholder="Your username"
autocomplete="username"
:disabled="loginSubmitting"
:disabled="loginSubmitDisabled"
variant="outline"
color="primary"
:size="controlSize"
:ui="{ base: controlClass }"
:ui="{ base: inputClass, trailing: 'pe-1' }"
class="w-full"
/>
</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
v-model="password"
:highlight="!!passwordError"
:type="passwordShown ? 'text' : 'password'"
autocomplete="current-password"
:disabled="loginSubmitting"
:disabled="loginSubmitDisabled"
variant="outline"
color="primary"
:size="controlSize"
:ui="{ base: controlClass }"
:ui="{
base: `${inputClass} ${passwordShown ? '' : 'tracking-[2px]'}`,
trailing: 'pe-1',
}"
class="w-full"
>
<template #trailing>
@@ -354,6 +389,7 @@ onMounted(() => {
color="neutral"
size="sm"
square
class="size-8 justify-center text-toned"
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
:aria-pressed="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.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 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(
false,
)
expect(fields().map((field) => field.disabled)).toEqual([false, false])
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 () => {
const { wrapper } = await mountPending({
'DELETE /v1/auth/register': () => mockResponse(403),
@@ -263,13 +263,20 @@ describe('Already verified — 403 refreshes the session and navigates to /home
overlayMock.resolveOpen(true)
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({
color: 'info',
icon: 'i-lucide-info',
title: 'Your account is already verified',
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')
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('Taking you to your dashboard…')
expect(router.currentRoute.value.path).toBe('/register/validate')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'), {
timeout: 2000,
})
expect(toastAdd).toHaveBeenCalledWith({
color: 'success',
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.',
})
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'), {
timeout: 2000,
})
expect(toastAdd).toHaveBeenCalledTimes(1)
const session = useSessionStore()
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', () => {
it('enables the submit button once both fields have a value', async () => {
describe('No session — empty fields are caught on submit, as on Log in', () => {
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', {
'POST /v1/auth/verify': () => mockResponse(401),
'POST /v1/auth/login': login,
})
expect((wrapper.find('button[type="submit"]').element as HTMLButtonElement).disabled).toBe(
true,
)
const submit = wrapper.find('button[type="submit"]')
expect((submit.element as HTMLButtonElement).disabled).toBe(false)
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect((wrapper.find('button[type="submit"]').element as HTMLButtonElement).disabled).toBe(
false,
)
expect(wrapper.text()).toContain('Use at least 3 characters.')
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 diffThreshold = 48
const diffThreshold = 24
const maxDiffPercent = 0.5
function slug(text: string) {
return text
@@ -302,11 +303,18 @@ async function captureApp(
await route.fulfill(fulfilment(mock))
})
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.goto(`${origin}${setup.path}`)
await Promise.race([page.waitForLoadState('networkidle'), page.waitForTimeout(2000)])
await page.waitForFunction(
() => (document.querySelector('#app')?.textContent ?? '').trim().length > 0,
)
await setup.steps?.(page)
await page.evaluate(() => document.fonts.ready)
await page.waitForTimeout(settleMs)
@@ -386,8 +394,9 @@ async function main() {
const design = await serveDesign()
const app = await serveApp()
const browser = await chromium.launch()
const browser = await chromium.launch({ args: ['--disable-lcd-text'] })
const lines: string[] = []
let failures = 0
try {
for (const viewport of args.viewports) {
@@ -402,8 +411,9 @@ async function main() {
const setup = setups[state]
if (!setup) {
failures++
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
}
@@ -419,14 +429,25 @@ async function main() {
appPath,
)
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]
? [`size mismatch ${result.sizes}`]
: []),
...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(
`${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()
@@ -438,14 +459,22 @@ async function main() {
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 = [
`# Visual check: ${screen.name}`,
`Board: ${screen.board}, group "${screen.group}"`,
'',
verdict,
'',
...lines.map((line) => `- ${line}`),
].join('\n')
await writeFile(join(outRoot, screen.name, 'report.md'), `${report}\n`)
console.log(report)
if (failures) {
process.exitCode = 1
}
}
await main()
+20 -2
View File
@@ -7,9 +7,17 @@ export interface ApiMock {
hang?: boolean
}
export interface DiffException {
maxPercent: number
reason: string
approvedBy: string
}
export interface VisualState {
exception?: DiffException
path: string
now?: string
paused?: boolean
api?: Record<string, ApiMock>
steps?: (page: Page) => Promise<void>
}
@@ -182,7 +190,15 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
},
'log-in': {
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': {
...loginWith({ status: 500 }),
steps: submitLogin('ma', ''),
@@ -290,6 +306,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
},
'Already verified (403)': {
path: '/register/pending',
paused: true,
api: {
'DELETE /v1/auth/register': { status: 403 },
'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 openCancelDialog(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: {
path: '/register/validate?c=abc123',
paused: true,
api: {
'POST /v1/auth/verify': { status: 200, body: { message: 'ok' } },
'GET /v1/auth/me': { status: 200, body: { is_verified: true } },