fix: login match design reference
This commit is contained in:
1 parent
001f9f4774
commit
f1e22f7073
16 files changed
+1152
-26
No files matched your search
@@ -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
@@ -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.
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.visual.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
}
|
||||
Reference in new issue
Block a user