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
+1183 -57

No files matched your search

+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.')
})
})
+67 -45
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,52 +211,75 @@ onMounted(async () => {
/>
</template>
<form ref="formEl" class="flex flex-col gap-4" @submit.prevent="onSubmit">
<UFormField label="Username" name="username" :error="usernameError">
<UInput
v-model="username"
placeholder="Your username"
autocomplete="username"
:disabled="submitting"
class="w-full"
/>
</UFormField>
<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"
class="w-full"
>
<template #trailing>
<UButton
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
variant="ghost"
color="neutral"
size="sm"
square
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
:aria-pressed="passwordShown"
@click="passwordShown = !passwordShown"
/>
</template>
</UInput>
</UFormField>
<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>
<UButton
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
variant="ghost"
color="neutral"
size="sm"
square
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
:aria-pressed="passwordShown"
@click="passwordShown = !passwordShown"
/>
</template>
</UInput>
</UFormField>
<UButton type="submit" block :loading="submitting" :disabled="submitDisabled">
{{ submitLabel }}
</UButton>
</form>
<UButton
type="submit"
block
:size="controlSize"
:loading="submitting"
:disabled="submitDisabled"
>
{{ submitLabel }}
</UButton>
</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'),
},
},
}