fix: login match design reference

This commit is contained in:
fsylvain committed 2026-09-25 21:28:12 +02:00
1 parent 001f9f4774
commit f1e22f7073
16 files changed
+1152 -26

No files matched your search

+7
View File
@@ -66,6 +66,13 @@ tasks:
cmds:
- bun run vitest run
visual:check:
desc: Screenshot a design screen's states next to the built SPA (Playwright) → tmp/visual/<screen>/
dir: web
deps: [web:install]
cmds:
- bun visual/check.ts {{.CLI_ARGS}}
lint:
desc: gofmt check, go vet, eslint (--fix) and vue-tsc
cmds:
+19 -1
View File
@@ -57,6 +57,8 @@ task --list # everything available
task test # Go tests; extra args pass through: task test -- -run TestCreateArticle -v
task cover # coverage of the use cases → tmp/coverage.out
task mutate # gremlins mutation testing → tmp/gremlins.json
task test:web # SPA component tests (Vitest)
task visual:check -- log-in # screenshot design vs SPA for a screen → tmp/visual/log-in/
task lint # gofmt check, go vet, eslint --fix, vue-tsc
task fmt # gofmt -w + prettier
task run # build the production binary and run it (SPA embedded) on :8080
@@ -67,7 +69,23 @@ task clean # remove bin/, tmp/ and the built SPA
- All Go tests live in [`test/`](../test) (package `test`, black-box). They use in-memory fakes of the domain `Repository` interfaces, so they need no database, SMTP server or network.
- HTTP-level tests use `net/http/httptest` against a chi router. Call `validator.InitValidator()` before invoking a handler.
- The frontend has no test runner yet. `task lint:web` runs eslint and a type check.
- SPA component tests use Vitest + Vue Test Utils (`task test:web`). `task lint:web` runs eslint and a type check.
## Visual check against the design
`task visual:check -- <screen> [state]` compares the SPA with the Claude Design boards in [`docs/design/source`](design/source). The screen is a file name from [`docs/design/screens`](design/screens) (`log-in`, `home`, …), and the state is one of its headings.
- It builds the SPA into `tmp/visual/dist` and serves both the build and the boards locally, so no API or dev server is needed.
- For every state it screenshots:
- the board's frame for that state;
- the SPA in the same state, at 1440×900 and 390×844, in light and dark.
- It saves `design-*.png`, `app-*.png` and `diff-*.png` (differing pixels in red) to `tmp/visual/<screen>/<state>/`, and a summary to `tmp/visual/<screen>/report.md`.
- [`web/visual/states.ts`](../web/visual/states.ts) says how to reach each state in the SPA: the route, the mocked `/v1` responses (keyed `'METHOD /v1/path'`) and any user steps. A state without an entry is captured on the design side only, and marked "design only" in the report.
- Narrow a run with `--viewport=desktop|mobile` and `--theme=light|dark`.
- Lucide icons are served from the local `@iconify-json/lucide` set, for both the boards and the SPA. api.iconify.design rate-limits repeated runs.
- The first time on a machine, run `bunx playwright install chromium` in `web/`.
The pixel percentage only points at where to look, because font anti-aliasing always differs a little. Judge by comparing the image pairs.
## Data
@@ -0,0 +1,94 @@
Feature: AuthShell and log-in screen conform to the design spec
As the maintainer of RenewCMS
I want AuthShell and LoginView built from the exact primitives docs/design/components.md
specifies — AppLogo, UPageCard's own title/description styling, the muted page background,
UForm with "Forgot password?" in the Password field's #hint slot, and the neutral-by-default,
red-on-error input border
So that the log-in screen (and every other AuthShell-based screen) matches the visual spec of
record instead of ad hoc markup that happens to look similar
Background:
Given AuthShell is the layout for every public route, per docs/design/routes.md
And AuthShell composes a UPageCard variant="outline" as the centered card, with AppLogo
rendered as the card's first element — left-aligned above the title/description block,
sharing the card's own 24px gap — not as a separate element centered outside/above the
card, per docs/design/components.md's AuthShell section and
docs/design/source/AuthCard.dc.html
And AppLogo is a reusable component (props include size) rendering the brand mark from
docs/design/source/brand/favicon.svg as inline SVG, not an <img>, so it can theme-switch
And UPageCard's title renders through its own title slot as an h1 (tabindex="-1", so route
changes can move focus to it, per AuthShell's a11y spec), and its description renders
through UPageCard's description prop — neither is freeform markup in the default slot
And from the sm breakpoint up, the centered <main> sits on the muted page background
(--ui-bg-muted), the same canvas the rest of the app uses; below sm it sits on --ui-bg
instead, per docs/design/components.md's AuthShell "Responsive" line
And LoginView's form is a UForm wrapping the Username and Password UFormFields and the
submit button
And the Password UFormField's "Forgot password?" link renders through the field's #hint
slot, in the position the Auth board draws it — right-aligned on the label row — per
docs/design/source/RenewCMS Screens Auth.dc.html, group "Log in"
And every UInput on the page uses the neutral control-edge border (--ui-border-accented) by
default, and turns to the error edge (--ui-error) only when its field has an error, per
docs/design/components.md's Input section (":highlight bound to the field error") and
foundations.md's Borders and elevation
Scenario: AppLogo renders the brand mark as inline SVG, and theme-switches
Given a public screen renders inside AuthShell
When AuthShell mounts
Then an AppLogo renders as UPageCard's first element, inside the card, left-aligned, sharing
the card's 24px gap with the title/description block below it — not centered above the card
And the logo is inline SVG sourced from docs/design/source/brand/favicon.svg, at 32px, not as
an <img> tag
And the logo carries alt="RenewCMS" (role="img" aria-label="RenewCMS" on the SVG)
And the logo is not wrapped in a link, since there is nothing public to go back to
And in light mode the black square has no edge stroke
And in dark mode the black square has a 1px edge in --color-chalk-600 (#444444), so it
doesn't disappear into the dark background
Scenario: UPageCard's title is the page h1, and its description uses UPageCard's own styling
Given AuthShell renders with title "Log in" and description
"Welcome back. Log in to manage your articles."
When AuthShell mounts
Then the card is a UPageCard with variant="outline"
And UPageCard's title slot renders an h1 with tabindex="-1" containing "Log in"
And UPageCard's description prop is "Welcome back. Log in to manage your articles."
And neither the title nor the description is rendered as separate freeform markup inside
UPageCard's default slot
Scenario: The page sits on the muted background from sm up, and on the default background below sm
Given AuthShell renders any public screen at a viewport of sm or wider
When AuthShell mounts
Then the centered <main> uses the muted background token (--ui-bg-muted)
And the card keeps its border, is capped at 400px, and its top sits 12vh from the top
Scenario: Below sm, AuthShell drops the muted background, the card border and the card padding
Given AuthShell renders any public screen at a viewport below sm
When AuthShell mounts
Then the centered <main> uses the default background token (--ui-bg), not the muted one
And the card has no border and fills the screen with 20px padding
And the fields and buttons on the page render at the xl size
Scenario: The login form is a UForm
Given the user is on /login
When the login form renders
Then the form is a UForm, not a plain HTML <form>, wrapping the Username UFormField, the
Password UFormField and the submit button
Scenario: "Forgot password?" renders in the Password field's hint slot
Given the user is on /login
When the login form renders
Then the Password UFormField's "Forgot password?" link is rendered through the field's
#hint slot
And the Password field's label reads only "Password", with no link inside it
Scenario: Inputs are neutral by default and turn to the error edge only when their field has an error
Given the user is on /login with no validation errors yet
When the Username and Password UInput fields render
Then both use the neutral control-edge border (--ui-border-accented), with :highlight false
When the user submits with Username "ma" and Password empty (the Validation errors state,
per docs/design/screens/log-in.md)
Then the Username UInput's edge turns to the error color (--ui-error), with :highlight true,
alongside the message "Use at least 3 characters."
And the Password UInput's edge turns to the error color (--ui-error), with :highlight true,
alongside the message "Enter your password."
@@ -0,0 +1,87 @@
# QA: AuthShell and log-in screen conform to the design spec
## Automated (Vitest + Vue Test Utils, `task test:web`)
No `/v1` calls are relevant to these assertions; mount AuthShell and LoginView directly (LoginView
already needs `useApi`/`GET /v1/auth/registration` mocked per features/login_screen.qa.md's setup,
reused here only as scaffolding, not as the thing under test).
1. **AppLogo renders inline SVG and theme-switches** — mount AuthShell with title "Log in";
assert an inline `<svg>` (not `<img>`) renders as UPageCard's first child element, left-aligned
(not centered) above the title/description block, sharing the card's 24px gap, at 32px, with
`role="img" aria-label="RenewCMS"`, and is not inside an `<a>`; assert its outer square has no
stroke in light mode; switch to dark mode (toggle the `.dark` class the way
`nuxt_ui_shell_foundation`'s color-mode tests do) and assert a 1px stroke using
`--color-chalk-600` appears.
2. **UPageCard title is an h1 with tabindex="-1"** — mount AuthShell with title "Log in" and
description "Welcome back. Log in to manage your articles."; assert
`wrapper.find('h1').attributes('tabindex')` is `"-1"` and its text is "Log in"; assert the
description text "Welcome back. Log in to manage your articles." renders via UPageCard's
`description` prop (not as a sibling `<p>` hand-authored in AuthShell's template). This
replaces the older assertion in `AuthShell.spec.ts` that only checked for any `<h1>`; keep that
assertion passing (h1 text still equals the title) while adding the tabindex check.
3. **Muted background at sm and up** — mount AuthShell at a `>= 640px` viewport (mock
`useBreakpoints`/matchMedia the way other shell tests do); assert `<main>`'s class list
includes the muted background utility (e.g. `bg-muted`); assert the card renders with a
border and is capped at 400px width, offset 12vh from the top.
4. **Default background, borderless card, 20px padding, xl controls below sm** — mount AuthShell
at a `< 640px` viewport; assert `<main>`'s class list includes the default background utility
(`bg-default`) and not the muted one; assert the card has no border class; assert the card's
padding is 20px; assert the Username/Password inputs and submit button render at `size="xl"`.
5. **Login form is a UForm** — mount LoginView at /login; assert the form wrapping the fields is
a UForm component instance, not a bare `<form>` element with only `@submit.prevent` wired by
hand.
6. **Forgot password in the #hint slot** — mount LoginView at /login; assert the Password
UFormField is passed content (or renders content) in its `hint` slot containing a
"Forgot password?" link to /register/reset/request; assert the field's rendered label text is
exactly "Password" with no link inside it.
7. **Inputs default to neutral, then highlight red on error** — mount LoginView at /login; assert
the Username and Password `UInput` components render with `highlight` falsy (or absent) and
the theme's default ring class (`ring-(--ui-border-accented)`); then trigger the Validation
errors state (type "ma" into Username, submit with Password empty); assert both `UInput`s now
receive `:highlight="true"` (or the field's error is otherwise truthy) so the edge renders in
`--ui-error`, alongside "Use at least 3 characters." and "Enter your password." respectively.
### Implementation finding for coder
`web/src/views/LoginView.vue`'s Username and Password `UInput`s currently receive no `:highlight`
prop at all — `UFormField` gets `:error="usernameError"` / `:error="passwordError"`, but that value
is never forwarded to the inner `UInput`. Today the edge never turns red in the Validation errors
state; this isn't a stale/inverted binding, it's a missing one. Wiring `:highlight="!!usernameError"`
/ `:highlight="!!passwordError"` (or equivalent) onto each `UInput` is the fix scenario 7 above
pins down.
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Log in", plus
`docs/design/source/RenewCMS Components Product.dc.html`'s AuthShell drawing and
`docs/design/source/RenewCMS Components Primitives.dc.html`'s Input drawing.
- [ ] AppLogo renders inside UPageCard as its first element, left-aligned above the title (not
centered above the card), sharing the card's 24px gap; the mark matches the favicon.svg
drawing exactly (white R on a black square), desktop and mobile; in dark mode the 1px
chalk-600 edge is visible around the square, in light mode it is absent.
- [ ] UPageCard title ("Log in") renders at the card-title size/weight from the AuthShell drawing
and receives focus (visible focus ring) on route change; description text matches
"Welcome back. Log in to manage your articles." styling, desktop and mobile, light and dark.
- [ ] At sm and up: 400px card with its border, 12vh top offset, on the muted page canvas, light
and dark.
- [ ] Below sm: card fills the screen borderless with 20px padding on the default (not muted)
background; fields and the submit button render at the touch (xl, 44px) size, light and
dark.
- [ ] The Password field's "Forgot password?" link sits right-aligned on the label row (the hint
position), matching the Auth board, desktop and mobile, light and dark.
- [ ] Username and Password fields show the neutral control-edge border by default; in the
Validation errors state (short username, empty password) both fields' edges turn to the
error color alongside their messages, in both light and dark.
- [ ] Keyboard/focus: Tab order remains Username → Password → show/hide toggle → "Forgot
password?" → submit; UPageCard's h1 is reachable/focusable via tabindex="-1" on route
change, matching AuthShell's existing a11y spec.
## Manual verification
- **AppLogo reused in DashboardShell** — out of scope for this slice (AuthShell/login only).
`DashboardShell.vue`'s sidebar header is currently a plain `<span class="font-semibold">
RenewCMS</span>` (not AppLogo), and its footer is a bare `<span>Log out</span>` instead of the
UserMenu component components.md specifies. Both need their own "Built from" conformance pass in
a later DashboardShell slice; AppLogo (built here with a `size` prop) will be ready to drop in
at that point without changes.
+9
View File
@@ -13,6 +13,8 @@
"vue-router": "^4.6.3",
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.136",
"@iconify/utils": "^3.1.7",
"@tsconfig/node24": "^24.0.3",
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2",
@@ -25,6 +27,7 @@
"jiti": "^2.6.1",
"jsdom": "25",
"npm-run-all2": "^8.0.4",
"playwright": "^1.63.0",
"prettier": "3.6.2",
"typescript": "~5.9.0",
"vite": "^7.2.4",
@@ -199,6 +202,8 @@
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@iconify-json/lucide": ["@iconify-json/lucide@1.2.136", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-Wk+OeDVKkw+1LELZqRR3gXWIy5jNx5XjNMvVc9KoygJYmGqclv36FYqseRuJuxBIKDggdoZWNBxNRrOkiRbtwQ=="],
"@iconify/collections": ["@iconify/collections@1.0.741", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-AUzucHF84kVFMzUQUgBsjmoy53U2ecvDZksFRsEOjfSBvJ5Hy+j8GeJ3dDSvBzNgLZ5BSxR6Zvh38VwrHdFbeg=="],
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
@@ -1015,6 +1020,10 @@
"pkg-types": ["pkg-types@2.3.3", "", { "dependencies": { "confbox": "^0.3.1", "exsolve": "^1.1.1", "pathe": "^2.0.3" } }, "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w=="],
"playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="],
"playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
+3
View File
@@ -24,6 +24,8 @@
"vue-router": "^4.6.3"
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.136",
"@iconify/utils": "^3.1.7",
"@tsconfig/node24": "^24.0.3",
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2",
@@ -36,6 +38,7 @@
"jiti": "^2.6.1",
"jsdom": "25",
"npm-run-all2": "^8.0.4",
"playwright": "^1.63.0",
"prettier": "3.6.2",
"typescript": "~5.9.0",
"vite": "^7.2.4",
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
withDefaults(
defineProps<{
size?: number
}>(),
{ size: 32 },
)
</script>
<template>
<svg
role="img"
aria-label="RenewCMS"
:width="size"
:height="size"
viewBox="0 0 16 16"
shape-rendering="crispEdges"
xmlns="http://www.w3.org/2000/svg"
>
<rect
width="16"
height="16"
fill="#111111"
class="dark:stroke-1 dark:stroke-(--color-chalk-600)"
/>
<path
fill="#ffffff"
fill-rule="evenodd"
d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"
/>
</svg>
</template>
+61 -9
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { useSlots, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, useSlots, watch } from 'vue'
import AppLogo from '@/components/AppLogo.vue'
const props = defineProps<{
title: string
@@ -7,6 +8,41 @@ const props = defineProps<{
}>()
const slots = useSlots()
const titleEl = ref<HTMLHeadingElement | null>(null)
const belowSm = ref(false)
let mediaQuery: MediaQueryList | null = null
function updateBelowSm() {
belowSm.value = mediaQuery?.matches ?? false
}
onMounted(() => {
if (typeof window.matchMedia === 'function') {
mediaQuery = window.matchMedia('(max-width: 39.9975rem)')
updateBelowSm()
mediaQuery.addEventListener('change', updateBelowSm)
}
titleEl.value?.focus()
})
onUnmounted(() => {
mediaQuery?.removeEventListener('change', updateBelowSm)
})
const controlSize = computed(() => (belowSm.value ? 'xl' : undefined))
const mainClass = computed(() => [
'flex min-h-screen flex-col items-center pt-[12vh]',
belowSm.value ? 'bg-default px-5' : 'bg-muted px-0',
])
const cardClass = computed(() => (belowSm.value ? 'w-full' : 'w-full max-w-[400px]'))
const cardUi = computed(() => ({
root: belowSm.value ? 'ring-0' : '',
container: belowSm.value ? 'p-5' : '',
header: 'mb-6',
}))
watch(
() => props.title,
@@ -15,26 +51,42 @@ watch(
},
{ immediate: true },
)
watch(
() => props.title,
() => {
titleEl.value?.focus()
},
)
</script>
<template>
<main class="flex min-h-screen flex-col items-center bg-default px-5 pt-[12vh] sm:px-0">
<UAlert v-if="slots.notice" class="mb-4 w-full sm:max-w-[400px]">
<main :class="mainClass">
<UAlert v-if="slots.notice" :class="['mb-4', cardClass]">
<template #description>
<slot name="notice" />
</template>
</UAlert>
<UPageCard variant="outline" class="w-full sm:max-w-[400px]">
<span role="img" aria-label="RenewCMS" class="i-lucide-square inline-block h-8 w-8" />
<UPageCard
variant="outline"
:description="description"
:ui="cardUi"
data-testid="auth-card"
:class="cardClass"
>
<template #header>
<AppLogo :size="32" />
</template>
<h1>{{ title }}</h1>
<p v-if="description">{{ description }}</p>
<template #title>
<h1 ref="titleEl" tabindex="-1">{{ title }}</h1>
</template>
<slot />
<slot :control-size="controlSize" />
</UPageCard>
<footer class="mt-4 flex w-full items-center justify-between sm:max-w-[400px]">
<footer :class="['mt-4 flex w-full items-center justify-between', cardClass]">
<slot name="footer" />
<UColorModeSelect size="sm" />
</footer>
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import AppLogo from '../AppLogo.vue'
describe('AppLogo renders the brand mark as inline SVG, and theme-switches', () => {
it('renders an inline svg at 32px by default, not an img tag', () => {
const wrapper = mount(AppLogo)
expect(wrapper.find('img').exists()).toBe(false)
const svg = wrapper.find('svg')
expect(svg.exists()).toBe(true)
expect(svg.attributes('width')).toBe('32')
expect(svg.attributes('height')).toBe('32')
})
it('carries alt text through role=img and aria-label, and is not wrapped in a link', () => {
const wrapper = mount(AppLogo)
const svg = wrapper.find('svg')
expect(svg.attributes('role')).toBe('img')
expect(svg.attributes('aria-label')).toBe('RenewCMS')
expect(wrapper.find('a').exists()).toBe(false)
})
it('sizes the svg through the size prop', () => {
const wrapper = mount(AppLogo, { props: { size: 48 } })
const svg = wrapper.find('svg')
expect(svg.attributes('width')).toBe('48')
expect(svg.attributes('height')).toBe('48')
})
it('has no stroke by default, and a dark-mode 1px chalk-600 edge on the black square', () => {
const wrapper = mount(AppLogo)
const square = wrapper.find('rect')
expect(square.attributes('fill')).toBe('#111111')
expect(square.classes()).toContain('dark:stroke-(--color-chalk-600)')
expect(square.classes()).toContain('dark:stroke-1')
expect(square.attributes('stroke')).toBeUndefined()
})
})
@@ -17,7 +17,9 @@ describe('AuthShell renders its title as the page heading and sets the document
it('renders an h1 containing the title and sets document.title', () => {
const wrapper = mountShell({ title: 'Log in' })
expect(wrapper.find('h1').text()).toBe('Log in')
const heading = wrapper.find('h1')
expect(heading.text()).toBe('Log in')
expect(heading.attributes('tabindex')).toBe('-1')
expect(document.title).toBe('Log in · RenewCMS')
})
})
@@ -36,7 +38,7 @@ describe('AuthShell renders its notice and footer slots', () => {
expect(alertDescription.text()).toBe('Your session expired.')
const html = wrapper.html()
expect(html.indexOf('Your session expired.')).toBeLessThan(html.indexOf('<h1>'))
expect(html.indexOf('Your session expired.')).toBeLessThan(html.indexOf('<h1'))
const footerLink = wrapper.find('a[href="/register/reset/request"]')
expect(footerLink.exists()).toBe(true)
@@ -0,0 +1,219 @@
import { describe, it, expect, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import AuthShell from '@/components/AuthShell.vue'
import LoginView from '@/views/LoginView.vue'
function stubMatchMedia(belowSm: boolean) {
window.matchMedia = ((query: string) => ({
matches: belowSm,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia
}
function mockResponse(status: number, body: unknown = null) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
} as unknown as Response
}
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountLogin() {
setActivePinia(createPinia())
global.fetch = (() => mockResponse(200, { open: false })) as unknown as typeof fetch
await router.push('/login')
const wrapper = mount(LoginView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
currentWrapper = wrapper
await flushPromises()
return wrapper
}
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
await router.push('/login')
})
describe('AppLogo renders the brand mark as inline SVG, and theme-switches', () => {
it("renders as UPageCard's first element, inside the card, left-aligned, at 32px, not as an img, alt text, not a link", () => {
const wrapper = mount(AuthShell, {
props: { title: 'Log in', description: 'Welcome back.' },
global: { plugins: [uiPlugin] },
})
const card = wrapper.find('[data-testid="auth-card"]')
const svg = wrapper.find('svg')
expect(svg.exists()).toBe(true)
expect(svg.attributes('width')).toBe('32')
expect(card.element.contains(svg.element)).toBe(true)
const html = wrapper.html()
expect(html.indexOf('<svg')).toBeGreaterThan(html.indexOf('data-testid="auth-card"'))
expect(html.indexOf('<svg')).toBeLessThan(html.indexOf('<h1'))
let centeringAncestor = svg.element.parentElement
while (centeringAncestor && centeringAncestor !== card.element) {
expect(centeringAncestor.className).not.toMatch(
/items-center|justify-center|text-center/,
)
centeringAncestor = centeringAncestor.parentElement
}
const header = wrapper.find('[data-slot="header"]')
expect(header.exists()).toBe(true)
expect(header.classes()).toContain('mb-6')
expect(header.classes()).not.toContain('mb-4')
expect(wrapper.find('img').exists()).toBe(false)
expect(svg.attributes('role')).toBe('img')
expect(svg.attributes('aria-label')).toBe('RenewCMS')
expect(svg.element.closest('a')).toBeNull()
})
})
describe("UPageCard's title is the page h1, and its description uses UPageCard's own styling", () => {
it('renders variant outline, the title through the title slot as h1, and the description through the prop', () => {
const wrapper = mount(AuthShell, {
props: {
title: 'Log in',
description: 'Welcome back. Log in to manage your articles.',
},
global: { plugins: [uiPlugin] },
})
const card = wrapper.find('[data-testid="auth-card"]')
expect(card.exists()).toBe(true)
const heading = wrapper.find('h1')
expect(heading.text()).toBe('Log in')
expect(heading.attributes('tabindex')).toBe('-1')
expect(wrapper.text()).toContain('Welcome back. Log in to manage your articles.')
expect(wrapper.findAll('p').length).toBe(0)
})
})
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', () => {
stubMatchMedia(false)
const wrapper = mount(AuthShell, {
props: { title: 'Log in', description: 'Welcome back.' },
global: { plugins: [uiPlugin] },
})
const main = wrapper.find('main')
expect(main.classes()).toContain('bg-muted')
expect(main.classes()).not.toContain('bg-default')
expect(main.classes()).toContain('pt-[12vh]')
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)
const container = wrapper.find('[data-slot="container"]')
expect(container.classes()).not.toContain('p-5')
})
})
describe('Below sm, AuthShell drops the muted background, the card border and the card padding', () => {
it('uses the default background, an unbordered full-width card with 20px padding, and xl controls', async () => {
stubMatchMedia(true)
const wrapper = mount(AuthShell, {
props: { title: 'Log in', description: 'Welcome back.' },
slots: {
default: `<template #default="{ controlSize }"><button :data-size="controlSize">Submit</button></template>`,
},
global: { plugins: [uiPlugin] },
})
await flushPromises()
const main = wrapper.find('main')
expect(main.classes()).toContain('bg-default')
expect(main.classes()).not.toContain('bg-muted')
const card = wrapper.find('[data-testid="auth-card"]')
expect(card.classes()).not.toContain('max-w-[400px]')
expect(card.classes()).toContain('ring-0')
const button = wrapper.find('button')
expect(button.attributes('data-size')).toBe('xl')
const container = wrapper.find('[data-slot="container"]')
expect(container.classes()).toContain('p-5')
})
})
describe('The login form is a UForm', () => {
it('wraps the Username field, the Password field and the submit button in a UForm, not a plain form', async () => {
const wrapper = await mountLogin()
const form = wrapper.find('form')
expect(form.exists()).toBe(true)
expect(form.attributes('id')).toMatch(/^v-/)
expect(form.find('input[placeholder="Your username"]').exists()).toBe(true)
expect(form.find('input[autocomplete="current-password"]').exists()).toBe(true)
expect(form.find('button[type="submit"]').exists()).toBe(true)
})
})
describe('"Forgot password?" renders in the Password field\'s hint slot', () => {
it('renders the link through the hint slot, and the label reads only Password', async () => {
const wrapper = await mountLogin()
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
const fieldRoot = passwordInput.element.closest('[data-slot="root"][data-orientation]')
expect(fieldRoot).toBeTruthy()
const hint = fieldRoot!.querySelector('[data-slot="hint"]')
expect(hint).toBeTruthy()
expect(hint!.textContent?.trim()).toBe('Forgot password?')
expect(hint!.querySelector('a[href="/register/reset/request"]')).toBeTruthy()
const label = fieldRoot!.querySelector('[data-slot="label"]')
expect(label!.textContent?.trim()).toBe('Password')
})
})
describe('Inputs are neutral by default and turn to the error edge only when their field has an error', () => {
it('starts neutral, then turns to the error edge with the field messages on invalid submit', async () => {
const wrapper = await mountLogin()
const usernameInput = wrapper.find('input[placeholder="Your username"]')
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
expect(usernameInput.classes()).toContain('ring-accented')
expect(usernameInput.classes()).not.toContain('ring-error')
expect(passwordInput.classes()).toContain('ring-accented')
expect(passwordInput.classes()).not.toContain('ring-error')
await usernameInput.setValue('ma')
await wrapper.find('form').trigger('submit')
await flushPromises()
const usernameAfter = wrapper.find('input[placeholder="Your username"]')
const passwordAfter = wrapper.find('input[autocomplete="current-password"]')
expect(usernameAfter.classes()).toContain('ring-error')
expect(usernameAfter.classes()).not.toContain('ring-accented')
expect(passwordAfter.classes()).toContain('ring-error')
expect(passwordAfter.classes()).not.toContain('ring-accented')
expect(wrapper.text()).toContain('Use at least 3 characters.')
expect(wrapper.text()).toContain('Enter your password.')
})
})
+36 -14
View File
@@ -68,7 +68,6 @@ const usernameError = ref('')
const passwordError = ref('')
const submitting = ref(false)
const passwordShown = ref(false)
const formEl = ref<HTMLFormElement | null>(null)
const initialReason = typeof route.query.reason === 'string' ? route.query.reason : null
@@ -94,10 +93,10 @@ const submitLabel = computed(() => {
const submitDisabled = computed(() => submitting.value || retryAfter.active.value)
function focusField(selector: string) {
nextTick(() => {
formEl.value?.querySelector<HTMLInputElement>(selector)?.focus()
})
async function focusField(selector: string) {
await nextTick()
await nextTick()
document.querySelector<HTMLInputElement>(selector)?.focus()
}
function focusPassword() {
@@ -212,31 +211,47 @@ onMounted(async () => {
/>
</template>
<form ref="formEl" class="flex flex-col gap-4" @submit.prevent="onSubmit">
<UFormField label="Username" name="username" :error="usernameError">
<template #default="{ controlSize }">
<UForm :state="{ username, password }" class="flex flex-col gap-4" @submit="onSubmit">
<UFormField
label="Username"
name="username"
:error="usernameError || undefined"
:size="controlSize"
>
<UInput
v-model="username"
placeholder="Your username"
autocomplete="username"
:disabled="submitting"
variant="outline"
color="primary"
:highlight="!!usernameError"
:size="controlSize"
class="w-full"
/>
</UFormField>
<UFormField name="password" :error="passwordError">
<template #label>
<div class="flex w-full items-center justify-between">
<span>Password</span>
<UFormField
label="Password"
name="password"
:error="passwordError || undefined"
:size="controlSize"
>
<template #hint>
<RouterLink to="/register/reset/request" class="text-primary text-sm">
Forgot password?
</RouterLink>
</div>
</template>
<UInput
v-model="password"
:type="passwordShown ? 'text' : 'password'"
autocomplete="current-password"
:disabled="submitting"
variant="outline"
color="primary"
:highlight="!!passwordError"
:size="controlSize"
class="w-full"
>
<template #trailing>
@@ -254,10 +269,17 @@ onMounted(async () => {
</UInput>
</UFormField>
<UButton type="submit" block :loading="submitting" :disabled="submitDisabled">
<UButton
type="submit"
block
:size="controlSize"
:loading="submitting"
:disabled="submitDisabled"
>
{{ submitLabel }}
</UButton>
</form>
</UForm>
</template>
<template v-if="registrationOpen" #footer>
<p>
+3
View File
@@ -6,6 +6,9 @@
},
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.visual.json"
}
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": ["visual/**/*.ts"],
"compilerOptions": {
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.visual.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2024", "DOM", "DOM.Iterable"],
"types": ["node"]
}
}
+447
View File
@@ -0,0 +1,447 @@
import { createServer } from 'node:http'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import type { AddressInfo } from 'node:net'
import { extname, join, relative, resolve } from 'node:path'
import { build, preview } from 'vite'
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'
import { icons as lucide } from '@iconify-json/lucide'
import { getIconData, getIcons, iconToHTML, iconToSVG, replaceIDs } from '@iconify/utils'
import { visualStates, type ApiMock, type VisualState } from './states'
type Viewport = 'desktop' | 'mobile'
type Theme = 'light' | 'dark'
interface Screen {
name: string
board: string
group: string
states: string[]
}
const webRoot = resolve(import.meta.dirname, '..')
const repoRoot = resolve(webRoot, '..')
const designRoot = join(repoRoot, 'docs', 'design')
const sourceRoot = join(designRoot, 'source')
const outRoot = join(repoRoot, 'tmp', 'visual')
const distRoot = join(outRoot, 'dist')
const configFile = join(webRoot, 'vite.config.ts')
const frames: Record<Viewport, { width: number; height: number }> = {
desktop: { width: 1440, height: 900 },
mobile: { width: 390, height: 844 },
}
const contentTypes: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript',
'.css': 'text/css',
'.svg': 'image/svg+xml',
'.json': 'application/json',
}
const settleMs = 400
const diffThreshold = 48
function slug(text: string) {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
function parseArgs(argv: string[]) {
const flags = new Map<string, string>()
const positional: string[] = []
for (const arg of argv) {
const flag = /^--(\w+)=(.+)$/.exec(arg)
if (flag) {
flags.set(flag[1]!, flag[2]!)
} else {
positional.push(arg)
}
}
const [screen, ...stateWords] = positional
if (!screen) {
throw new Error(
'Usage: task visual:check -- <screen> [state] [--viewport=desktop|mobile] [--theme=light|dark]',
)
}
const viewports = (flags.get('viewport')?.split(',') ?? ['desktop', 'mobile']) as Viewport[]
const themes = (flags.get('theme')?.split(',') ?? ['light', 'dark']) as Theme[]
return { screen, state: stateWords.join(' ') || undefined, viewports, themes }
}
async function readScreen(name: string): Promise<Screen> {
const markdown = await readFile(join(designRoot, 'screens', `${name}.md`), 'utf8')
const reference = /\*\*Visual reference:\*\* `source\/([^`]+)`, group "([^"]+)"/.exec(markdown)
if (!reference) {
throw new Error(`docs/design/screens/${name}.md has no visual reference line`)
}
const states = [...markdown.matchAll(/^### (.+)$/gm)].map((match) => match[1]!.trim())
return { name, board: reference[1]!, group: reference[2]!, states }
}
function pickStates(screen: Screen, wanted?: string) {
if (!wanted) {
return screen.states
}
const match = screen.states.find(
(state) => state.toLowerCase() === wanted.toLowerCase() || slug(state) === slug(wanted),
)
if (!match) {
throw new Error(`Unknown state "${wanted}". Known: ${screen.states.join(', ')}`)
}
return [match]
}
async function serveDesign() {
const server = createServer(async (request, response) => {
const pathname = decodeURIComponent(
new URL(request.url ?? '/', 'http://localhost').pathname,
)
const file = join(sourceRoot, pathname)
if (relative(sourceRoot, file).startsWith('..')) {
response.writeHead(403).end()
return
}
try {
const body = await readFile(file)
response
.writeHead(200, {
'Content-Type': contentTypes[extname(file)] ?? 'application/octet-stream',
})
.end(body)
} catch {
response.writeHead(404).end()
}
})
await new Promise<void>((done) => server.listen(0, '127.0.0.1', done))
const { port } = server.address() as AddressInfo
return { origin: `http://127.0.0.1:${port}`, close: () => server.close() }
}
async function serveApp() {
await build({
configFile,
root: webRoot,
logLevel: 'warn',
build: { outDir: distRoot, emptyOutDir: true },
})
const server = await preview({
configFile,
root: webRoot,
logLevel: 'warn',
build: { outDir: distRoot },
preview: { port: 4180, strictPort: false, open: false },
})
const origin = server.resolvedUrls?.local[0]?.replace(/\/$/, '')
if (!origin) {
throw new Error('Vite preview did not report a local URL')
}
return { origin, close: () => server.close() }
}
const iconHosts = new Set(['api.iconify.design', 'api.simplesvg.com', 'api.unisvg.com'])
async function serveIconsLocally(context: BrowserContext) {
await context.route(
(url) => iconHosts.has(url.hostname),
async (route) => {
const url = new URL(route.request().url())
const svg = /^\/lucide\/([a-z0-9-]+)\.svg$/.exec(url.pathname)
if (svg) {
const data = getIconData(lucide, svg[1]!)
if (!data) {
await route.fulfill({ status: 404, body: '' })
return
}
const { attributes, body } = iconToSVG(data)
await route.fulfill({
status: 200,
contentType: 'image/svg+xml',
body: iconToHTML(replaceIDs(body), attributes),
})
return
}
if (url.pathname === '/lucide.json') {
const names = url.searchParams.get('icons')?.split(',') ?? []
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(
getIcons(lucide, names) ?? { prefix: 'lucide', icons: {} },
),
})
return
}
await route.fulfill({ status: 404, body: '' })
},
)
}
async function openBoard(browser: Browser, origin: string, screen: Screen, theme: Theme) {
const context = await browser.newContext({ viewport: { width: 1600, height: 1000 } })
await serveIconsLocally(context)
const page = await context.newPage()
await page.goto(`${origin}/${encodeURIComponent(screen.board)}`)
await page.locator(`section[data-screen-label="${screen.group}"]`).waitFor()
await page.getByRole('radio', { name: theme === 'dark' ? 'Dark' : 'Light' }).click()
await page.evaluate(() => document.fonts.ready)
return page
}
async function captureDesign(
board: Page,
screen: Screen,
state: string,
viewport: Viewport,
path: string,
) {
const found = await board.evaluate(
({ group, state, width, height }) => {
document
.querySelectorAll('[data-visual-check]')
.forEach((element) => element.removeAttribute('data-visual-check'))
const section = [...document.querySelectorAll('section[data-screen-label]')].find(
(element) => element.getAttribute('data-screen-label') === group,
)
const title = section
? [...section.querySelectorAll('span')].find(
(span) => span.textContent?.trim() === state,
)
: undefined
const block = title?.closest('div')?.parentElement
const target = block
? [...block.querySelectorAll<HTMLElement>('div')].find(
(div) => div.style.width === `${width}px`,
)
: undefined
if (!target) {
return false
}
const container = target.parentElement!
container.style.border = 'none'
container.style.boxSizing = 'content-box'
container.style.width = `${width}px`
container.style.height = `${height}px`
target.style.zoom = '1'
target.dataset.visualCheck = 'frame'
return true
},
{ group: screen.group, state, ...frames[viewport] },
)
if (!found) {
throw new Error(`State "${state}" not found in group "${screen.group}" of ${screen.board}`)
}
const frame = board.locator('[data-visual-check="frame"]')
await frame.scrollIntoViewIfNeeded()
await frame.evaluate(async (root) => {
const urls = new Set<string>()
for (const element of [root, ...root.querySelectorAll('*')]) {
const mask = getComputedStyle(element).maskImage
for (const match of mask.matchAll(/url\("([^"]+)"\)/g)) {
urls.add(match[1]!)
}
}
await Promise.all(
[...urls].map(
(url) =>
new Promise<void>((done) => {
const image = new Image()
image.onload = () => done()
image.onerror = () => done()
image.src = url
}),
),
)
})
await board.waitForTimeout(settleMs)
await frame.screenshot({ path, animations: 'disabled' })
}
function fulfilment(mock: ApiMock) {
const text = typeof mock.body === 'string'
return {
status: mock.status,
headers: mock.headers,
contentType: text ? 'text/plain; charset=utf-8' : 'application/json',
body:
mock.body === undefined ? '' : text ? (mock.body as string) : JSON.stringify(mock.body),
}
}
async function captureApp(
browser: Browser,
origin: string,
setup: VisualState,
viewport: Viewport,
theme: Theme,
path: string,
) {
const { width, height } = frames[viewport]
const context = await browser.newContext({
viewport: { width, height },
colorScheme: theme,
reducedMotion: 'reduce',
})
await serveIconsLocally(context)
const unmocked = new Set<string>()
await context.route('**/v1/**', async (route) => {
const request = route.request()
const key = `${request.method()} ${new URL(request.url()).pathname}`
const mock = setup.api?.[key]
if (!mock) {
unmocked.add(key)
await route.fulfill({ status: 404, body: '' })
return
}
if (mock.hang) {
return
}
await route.fulfill(fulfilment(mock))
})
const page = await context.newPage()
await page.goto(`${origin}${setup.path}`)
await page.waitForLoadState('networkidle')
await setup.steps?.(page)
await page.evaluate(() => document.fonts.ready)
await page.waitForTimeout(settleMs)
await page.screenshot({ path, animations: 'disabled' })
await context.close()
return [...unmocked]
}
async function diff(browser: Browser, designPath: string, appPath: string, diffPath: string) {
const [design, app] = await Promise.all([readFile(designPath), readFile(appPath)])
const page = await browser.newPage()
const result = await page.evaluate(
async ({ design, app, threshold }) => {
const load = (src: string) =>
new Promise<HTMLImageElement>((done, fail) => {
const image = new Image()
image.onload = () => done(image)
image.onerror = fail
image.src = src
})
const [a, b] = await Promise.all([load(design), load(app)])
const width = Math.max(a.width, b.width)
const height = Math.max(a.height, b.height)
const pixels = (image: HTMLImageElement) => {
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const context = canvas.getContext('2d')!
context.drawImage(image, 0, 0)
return context.getImageData(0, 0, width, height).data
}
const left = pixels(a)
const right = pixels(b)
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const context = canvas.getContext('2d')!
const output = context.createImageData(width, height)
let changed = 0
for (let i = 0; i < left.length; i += 4) {
const delta =
Math.abs(left[i]! - right[i]!) +
Math.abs(left[i + 1]! - right[i + 1]!) +
Math.abs(left[i + 2]! - right[i + 2]!)
if (delta > threshold) {
changed++
output.data.set([230, 0, 60, 255], i)
} else {
const gray = (left[i]! + left[i + 1]! + left[i + 2]!) / 3
const faded = 255 - (255 - gray) * 0.2
output.data.set([faded, faded, faded, 255], i)
}
}
context.putImageData(output, 0, 0)
return {
png: canvas.toDataURL('image/png'),
ratio: changed / (width * height),
sizes: `${a.width}x${a.height} vs ${b.width}x${b.height}`,
}
},
{
design: `data:image/png;base64,${design.toString('base64')}`,
app: `data:image/png;base64,${app.toString('base64')}`,
threshold: diffThreshold,
},
)
await page.close()
await writeFile(diffPath, Buffer.from(result.png.split(',')[1]!, 'base64'))
return result
}
async function main() {
const args = parseArgs(process.argv.slice(2))
const screen = await readScreen(args.screen)
const states = pickStates(screen, args.state)
const setups = visualStates[screen.name] ?? {}
const design = await serveDesign()
const app = await serveApp()
const browser = await chromium.launch()
const lines: string[] = []
try {
for (const viewport of args.viewports) {
for (const theme of args.themes) {
const board = await openBoard(browser, design.origin, screen, theme)
for (const state of states) {
const directory = join(outRoot, screen.name, slug(state))
await mkdir(directory, { recursive: true })
const suffix = `${viewport}-${theme}.png`
const designPath = join(directory, `design-${suffix}`)
await captureDesign(board, screen, state, viewport, designPath)
const setup = setups[state]
if (!setup) {
lines.push(
`${state} · ${viewport} · ${theme}: design only, no setup in web/visual/states.ts → ${relative(repoRoot, designPath)}`,
)
continue
}
const appPath = join(directory, `app-${suffix}`)
const diffPath = join(directory, `diff-${suffix}`)
const unmocked = await captureApp(
browser,
app.origin,
setup,
viewport,
theme,
appPath,
)
const result = await diff(browser, designPath, appPath, diffPath)
const warnings = [
...(result.sizes.split(' vs ')[0] !== result.sizes.split(' vs ')[1]
? [`size mismatch ${result.sizes}`]
: []),
...unmocked.map((key) => `unmocked ${key}`),
]
lines.push(
`${state} · ${viewport} · ${theme}: ${(result.ratio * 100).toFixed(1)}% pixels differ → ${relative(repoRoot, directory)}${warnings.length ? ` (${warnings.join('; ')})` : ''}`,
)
}
await board.context().close()
}
}
} finally {
await browser.close()
await app.close()
design.close()
}
const report = [
`# Visual check: ${screen.name}`,
`Board: ${screen.board}, group "${screen.group}"`,
'',
...lines.map((line) => `- ${line}`),
].join('\n')
await writeFile(join(outRoot, screen.name, 'report.md'), `${report}\n`)
console.log(report)
}
await main()
+77
View File
@@ -0,0 +1,77 @@
import type { Page } from 'playwright'
export interface ApiMock {
status: number
body?: unknown
headers?: Record<string, string>
hang?: boolean
}
export interface VisualState {
path: string
api?: Record<string, ApiMock>
steps?: (page: Page) => Promise<void>
}
const registrationClosed: ApiMock = { status: 200, body: { open: false } }
const registrationOpen: ApiMock = { status: 200, body: { open: true } }
async function fillLogin(page: Page, username: string, password: string) {
await page.getByPlaceholder('Your username').fill(username)
await page.locator('input[autocomplete="current-password"]').fill(password)
}
function submitLogin(username: string, password: string) {
return async (page: Page) => {
await fillLogin(page, username, password)
await page.getByRole('button', { name: 'Log in', exact: true }).click()
}
}
function loginWith(response: ApiMock, path = '/login'): Omit<VisualState, 'steps'> {
return {
path,
api: {
'GET /v1/auth/registration': registrationClosed,
'POST /v1/auth/login': response,
},
}
}
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 } },
'Validation errors': {
...loginWith({ status: 500 }),
steps: submitLogin('ma', ''),
},
Submitting: {
...loginWith({ status: 200, hang: true }),
steps: submitLogin('marta', 'limewash2026'),
},
'Wrong credentials (403)': {
...loginWith({ status: 403, body: 'forbidden' }),
steps: submitLogin('marta', 'limewash2026'),
},
'Too many attempts (429)': {
...loginWith({ status: 429, headers: { 'Retry-After': '42' } }),
steps: submitLogin('marta', 'limewash'),
},
'Session expired': {
...loginWith({ status: 500 }, '/login?redirect=/article/12/edit&reason=expired'),
steps: async (page) => {
await page.getByPlaceholder('Your username').fill('marta')
await page.locator('input[autocomplete="current-password"]').focus()
},
},
'Password changed': {
path: '/login?reason=password-changed',
api: { 'GET /v1/auth/registration': registrationClosed },
},
'Server or network error': {
...loginWith({ status: 500 }),
steps: submitLogin('marta', 'limewash2026'),
},
},
}