fix: changed frontend workflow and fixed login reference matching for real

This commit is contained in:
fsylvain committed 2026-09-25 22:31:44 +02:00
1 parent f1e22f7073
commit a5873f8886
12 files changed
+503 -46

No files matched your search

+1 -6
View File
@@ -17,12 +17,7 @@ withDefaults(
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)"
/>
<rect width="16" height="16" fill="#111111" />
<path
fill="#ffffff"
fill-rule="evenodd"
+25 -14
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, useSlots, watch } from 'vue'
import AppLogo from '@/components/AppLogo.vue'
import { isInAppNavigation } from '@/composables/useAuthShellNavigation'
const props = defineProps<{
title: string
@@ -22,7 +23,9 @@ onMounted(() => {
updateBelowSm()
mediaQuery.addEventListener('change', updateBelowSm)
}
titleEl.value?.focus()
if (isInAppNavigation()) {
titleEl.value?.focus()
}
})
onUnmounted(() => {
@@ -30,20 +33,30 @@ onUnmounted(() => {
})
const controlSize = computed(() => (belowSm.value ? 'xl' : undefined))
const controlClass = computed(() => (belowSm.value ? '!h-11 !text-base' : '!h-10 !text-sm'))
const mainClass = computed(() => [
'flex min-h-screen flex-col items-center pt-[12vh]',
belowSm.value ? 'bg-default px-5' : 'bg-muted px-0',
'flex min-h-screen flex-col items-center',
belowSm.value ? 'bg-default' : 'bg-muted pt-[12vh]',
])
const cardClass = computed(() => (belowSm.value ? 'w-full' : 'w-full max-w-[400px]'))
const cardUi = computed(() => ({
root: belowSm.value ? 'ring-0' : '',
container: belowSm.value ? 'p-5' : '',
container: belowSm.value ? 'pt-2 px-5 pb-0' : 'p-10',
header: 'mb-6',
title: 'text-xl font-semibold leading-7',
}))
const footerClass = computed(() => [
'mt-4 flex w-full items-center justify-between',
belowSm.value ? 'pt-0 px-5 pb-6' : 'p-0',
cardClass.value,
])
const colorModeSelectUi = { trailing: 'hidden' }
watch(
() => props.title,
(title) => {
@@ -62,12 +75,6 @@ watch(
<template>
<main :class="mainClass">
<UAlert v-if="slots.notice" :class="['mb-4', cardClass]">
<template #description>
<slot name="notice" />
</template>
</UAlert>
<UPageCard
variant="outline"
:description="description"
@@ -80,15 +87,19 @@ watch(
</template>
<template #title>
<h1 ref="titleEl" tabindex="-1">{{ title }}</h1>
<h1 ref="titleEl" tabindex="-1" class="outline-none">{{ title }}</h1>
</template>
<slot :control-size="controlSize" />
<div v-if="slots.notice" class="mb-4">
<slot name="notice" />
</div>
<slot :control-size="controlSize" :control-class="controlClass" />
</UPageCard>
<footer :class="['mt-4 flex w-full items-center justify-between', cardClass]">
<footer :class="footerClass">
<slot name="footer" />
<UColorModeSelect size="sm" />
<UColorModeSelect size="sm" class="ms-auto" :ui="colorModeSelectUi" />
</footer>
</main>
</template>
+2 -3
View File
@@ -30,13 +30,12 @@ describe('AppLogo renders the brand mark as inline SVG, and theme-switches', ()
expect(svg.attributes('height')).toBe('48')
})
it('has no stroke by default, and a dark-mode 1px chalk-600 edge on the black square', () => {
it('has no edge stroke, in light mode or in dark mode', () => {
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()
expect(square.classes().some((c) => c.includes('stroke'))).toBe(false)
})
})
+15 -5
View File
@@ -1,7 +1,12 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import AuthShell from '../AuthShell.vue'
import { resetAuthShellNavigation } from '@/composables/useAuthShellNavigation'
beforeEach(() => {
resetAuthShellNavigation()
})
function mountShell(props: Record<string, unknown>, slots: Record<string, unknown> = {}) {
return mount(AuthShell, {
@@ -25,7 +30,7 @@ describe('AuthShell renders its title as the page heading and sets the document
})
describe('AuthShell renders its notice and footer slots', () => {
it('renders the notice slot in an alert above the title and the footer slot outside the card', () => {
it('renders the notice slot as-is inside the card, after the title, and the footer slot outside the card', () => {
const wrapper = mountShell(
{ title: 'Log in' },
{
@@ -34,14 +39,19 @@ describe('AuthShell renders its notice and footer slots', () => {
},
)
const alertDescription = wrapper.find('[data-slot="description"]')
expect(alertDescription.text()).toBe('Your session expired.')
const notice = wrapper.find('span')
expect(notice.text()).toBe('Your session expired.')
expect(wrapper.findAll('[data-slot="root"][data-orientation]').length).toBe(1)
const card = wrapper.find('[data-testid="auth-card"]')
expect(card.element.contains(notice.element)).toBe(true)
const html = wrapper.html()
expect(html.indexOf('Your session expired.')).toBeLessThan(html.indexOf('<h1'))
expect(html.indexOf('<h1')).toBeLessThan(html.indexOf('Your session expired.'))
const footerLink = wrapper.find('a[href="/register/reset/request"]')
expect(footerLink.exists()).toBe(true)
expect(footerLink.text()).toBe('Forgot password?')
expect(card.element.contains(footerLink.element)).toBe(false)
})
})
@@ -1,10 +1,11 @@
import { describe, it, expect, afterEach } from 'vitest'
import { describe, it, expect, afterEach, beforeEach } 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'
import { resetAuthShellNavigation } from '@/composables/useAuthShellNavigation'
function stubMatchMedia(belowSm: boolean) {
window.matchMedia = ((query: string) => ({
@@ -43,6 +44,10 @@ async function mountLogin() {
return wrapper
}
beforeEach(() => {
resetAuthShellNavigation()
})
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
@@ -133,7 +138,7 @@ describe('The page sits on the muted background from sm up, and the card keeps i
})
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 () => {
it('uses the default background, an unbordered full-width card with the board padding split, and xl controls', async () => {
stubMatchMedia(true)
const wrapper = mount(AuthShell, {
props: { title: 'Log in', description: 'Welcome back.' },
@@ -147,6 +152,7 @@ describe('Below sm, AuthShell drops the muted background, the card border and th
const main = wrapper.find('main')
expect(main.classes()).toContain('bg-default')
expect(main.classes()).not.toContain('bg-muted')
expect(main.classes()).not.toContain('pt-[12vh]')
const card = wrapper.find('[data-testid="auth-card"]')
expect(card.classes()).not.toContain('max-w-[400px]')
@@ -156,7 +162,9 @@ describe('Below sm, AuthShell drops the muted background, the card border and th
expect(button.attributes('data-size')).toBe('xl')
const container = wrapper.find('[data-slot="container"]')
expect(container.classes()).toContain('p-5')
expect(container.classes()).toContain('pt-2')
expect(container.classes()).toContain('px-5')
expect(container.classes()).toContain('pb-0')
})
})
@@ -0,0 +1,243 @@
import { describe, it, expect, afterEach, beforeEach } 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'
import { resetAuthShellNavigation } from '@/composables/useAuthShellNavigation'
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(path = '/login') {
setActivePinia(createPinia())
global.fetch = (() => mockResponse(200, { open: false })) as unknown as typeof fetch
await router.push(path)
const wrapper = mount(LoginView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
currentWrapper = wrapper
await flushPromises()
return wrapper
}
beforeEach(() => {
resetAuthShellNavigation()
})
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
await router.push('/login')
resetAuthShellNavigation()
})
describe('Card padding and control heights at sm and up', () => {
it('pads the card 40px on every side and sizes controls at 40px, 14px text', async () => {
stubMatchMedia(false)
const wrapper = await mountLogin()
const container = wrapper.find('[data-slot="container"]')
expect(container.classes()).toContain('p-10')
expect(container.classes()).not.toContain('pt-2')
const usernameInput = wrapper.find('input[placeholder="Your username"]')
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
const submitButton = wrapper.find('button[type="submit"]')
for (const el of [usernameInput, passwordInput, submitButton]) {
expect(el.classes()).toContain('!h-10')
expect(el.classes()).toContain('!text-sm')
}
const title = wrapper.find('[data-slot="title"]')
expect(title.classes()).toContain('text-xl')
expect(title.classes()).toContain('font-semibold')
expect(title.classes()).toContain('leading-7')
})
})
describe('Card padding and control heights below sm', () => {
it('splits card/footer padding and sizes controls at 44px, 16px text', async () => {
stubMatchMedia(true)
const wrapper = await mountLogin()
const container = wrapper.find('[data-slot="container"]')
expect(container.classes()).toContain('pt-2')
expect(container.classes()).toContain('px-5')
expect(container.classes()).toContain('pb-0')
const footer = wrapper.find('footer')
expect(footer.classes()).toContain('pt-0')
expect(footer.classes()).toContain('px-5')
expect(footer.classes()).toContain('pb-6')
const usernameInput = wrapper.find('input[placeholder="Your username"]')
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
const submitButton = wrapper.find('button[type="submit"]')
for (const el of [usernameInput, passwordInput, submitButton]) {
expect(el.classes()).toContain('!h-11')
expect(el.classes()).toContain('!text-base')
}
})
})
describe('"Forgot password?" is underlined and muted', () => {
it('uses the muted toned color, underline and 3px offset, no other emphasis', async () => {
const wrapper = await mountLogin()
const link = wrapper.find('a[href="/register/reset/request"]')
expect(link.classes()).toContain('text-toned')
expect(link.classes()).toContain('underline')
expect(link.classes()).toContain('underline-offset-[3px]')
expect(link.classes()).not.toContain('text-primary')
expect(link.classes()).not.toContain('font-bold')
expect(link.classes()).not.toContain('font-semibold')
})
})
describe('A field error shows the circle-alert icon with no extra focus halo', () => {
it('precedes both field errors with the error-colored icon, and keeps a single error ring when focused', async () => {
const wrapper = await mountLogin()
await wrapper.find('input[placeholder="Your username"]').setValue('ma')
await wrapper.find('form').trigger('submit')
await flushPromises()
const fieldRoots = wrapper.findAll('[data-slot="root"][data-orientation]')
const usernameError = fieldRoots[0]!.find('[data-slot="error"]')
const passwordError = fieldRoots[1]!.find('[data-slot="error"]')
for (const error of [usernameError, passwordError]) {
const icon = error.find('svg')
expect(icon.exists()).toBe(true)
expect(icon.html()).toContain('M12 8v4m0 4h.01')
expect(icon.classes()).toContain('text-error')
}
const usernameInput = wrapper.find('input[placeholder="Your username"]')
await usernameInput.trigger('focus')
expect(usernameInput.classes()).toContain('ring-error')
expect(usernameInput.classes()).toContain('focus-visible:ring-error')
expect(usernameInput.classes()).not.toContain('ring-primary')
expect(usernameInput.classes()).not.toContain('focus-visible:ring-primary')
})
})
describe('The theme select is right-aligned in the footer with no chevron', () => {
it('renders as the last child, showing only the mode icon and label', async () => {
const wrapper = await mountLogin()
const footer = wrapper.find('footer')
const select = footer.find('button[aria-haspopup="listbox"]')
expect(select.exists()).toBe(true)
const children = Array.from(footer.element.children)
expect(children[children.length - 1]).toBe(select.element)
expect(select.text()).toContain('System')
const trailing = select.find('[data-slot="trailing"]')
expect(trailing.classes()).toContain('hidden')
})
})
describe('The alert renders inside the card, under the description', () => {
it.each([
{
path: '/login?redirect=/article/12/edit&reason=expired',
state: 'Session expired',
text: 'Your session expired',
},
{
path: '/login?reason=password-changed',
state: 'Password changed',
text: 'Password changed',
},
])(
'places the $state alert inside the card, after the title/description, before Username',
async ({ path, text }) => {
const wrapper = await mountLogin(path)
const card = wrapper.find('[data-testid="auth-card"]')
const alerts = wrapper.findAll('[role="alert"], [role="status"]')
expect(alerts.length).toBe(1)
const alert = alerts[0]!
expect(card.element.contains(alert.element)).toBe(true)
expect(alert.text()).toContain(text)
const rootsInCard = card.findAll('[data-slot="root"][data-orientation]')
expect(rootsInCard.some((root) => root.classes().includes('bg-primary'))).toBe(false)
const usernameInput = wrapper.find('input[placeholder="Your username"]')
const html = card.html()
expect(html.indexOf('Welcome back')).toBeGreaterThan(-1)
expect(html.indexOf('Welcome back')).toBeLessThan(html.indexOf(text))
expect(html.indexOf(text)).toBeLessThan(html.indexOf('Username'))
expect(card.element.contains(usernameInput.element)).toBe(true)
expect(alert.classes().some((c) => /^bg-(info|success)\/10$/.test(c))).toBe(true)
},
)
})
describe('The h1 is not focused on first load', () => {
it('leaves focus on the document body after AuthShell mounts', () => {
const wrapper = mount(AuthShell, {
props: { title: 'Log in', description: 'Welcome back.' },
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
const heading = wrapper.find('h1')
expect(heading.element).not.toBe(document.activeElement)
wrapper.unmount()
})
})
describe('Focus moves to the h1 only on an in-app navigation between public screens', () => {
it('does not focus the first AuthShell mount, but focuses the second', () => {
const first = mount(AuthShell, {
props: { title: 'Log in', description: 'Welcome back.' },
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
expect(first.find('h1').element).not.toBe(document.activeElement)
first.unmount()
const second = mount(AuthShell, {
props: { title: 'Forgot password?', description: 'Reset it.' },
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
expect(second.find('h1').element).toBe(document.activeElement)
expect(second.find('h1').classes()).toContain('outline-none')
second.unmount()
})
})
@@ -0,0 +1,11 @@
let hasEnteredBefore = false
export function isInAppNavigation(): boolean {
const wasEntered = hasEnteredBefore
hasEnteredBefore = true
return wasEntered
}
export function resetAuthShellNavigation(): void {
hasEnteredBefore = false
}
+20 -2
View File
@@ -211,7 +211,7 @@ onMounted(async () => {
/>
</template>
<template #default="{ controlSize }">
<template #default="{ controlSize, controlClass }">
<UForm :state="{ username, password }" class="flex flex-col gap-4" @submit="onSubmit">
<UFormField
label="Username"
@@ -219,6 +219,12 @@ onMounted(async () => {
:error="usernameError || undefined"
:size="controlSize"
>
<template v-if="usernameError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon name="i-lucide-circle-alert" class="text-error shrink-0" />
{{ error }}
</span>
</template>
<UInput
v-model="username"
placeholder="Your username"
@@ -228,6 +234,7 @@ onMounted(async () => {
color="primary"
:highlight="!!usernameError"
:size="controlSize"
:ui="{ base: controlClass }"
class="w-full"
/>
</UFormField>
@@ -239,10 +246,19 @@ onMounted(async () => {
:size="controlSize"
>
<template #hint>
<RouterLink to="/register/reset/request" class="text-primary text-sm">
<RouterLink
to="/register/reset/request"
class="text-toned text-sm underline underline-offset-[3px]"
>
Forgot password?
</RouterLink>
</template>
<template v-if="passwordError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon name="i-lucide-circle-alert" class="text-error shrink-0" />
{{ error }}
</span>
</template>
<UInput
v-model="password"
:type="passwordShown ? 'text' : 'password'"
@@ -252,6 +268,7 @@ onMounted(async () => {
color="primary"
:highlight="!!passwordError"
:size="controlSize"
:ui="{ base: controlClass }"
class="w-full"
>
<template #trailing>
@@ -273,6 +290,7 @@ onMounted(async () => {
type="submit"
block
:size="controlSize"
:ui="{ base: controlClass }"
:loading="submitting"
:disabled="submitDisabled"
>