diff --git a/features/auth_shell_login_conformance.feature b/features/auth_shell_login_conformance.feature index 53da1bb..3253e97 100644 --- a/features/auth_shell_login_conformance.feature +++ b/features/auth_shell_login_conformance.feature @@ -16,7 +16,7 @@ Feature: AuthShell and log-in screen conform to the design spec 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 , so it can theme-switch + docs/design/source/brand/favicon.svg as inline SVG, not an 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 @@ -42,9 +42,9 @@ Feature: AuthShell and log-in screen conform to the design spec an 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 + And the black square has no edge stroke in light mode or in dark mode, per + docs/design/source/AuthCard.dc.html's renderVals(), which computes the logo's edge as the + literal, non-theme-branched value "transparent" 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 diff --git a/features/auth_shell_login_conformance.qa.md b/features/auth_shell_login_conformance.qa.md index 7b91093..d266656 100644 --- a/features/auth_shell_login_conformance.qa.md +++ b/features/auth_shell_login_conformance.qa.md @@ -6,13 +6,14 @@ No `/v1` calls are relevant to these assertions; mount AuthShell and LoginView d 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 `` (not ``) 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 ``; 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. +1. **AppLogo renders inline SVG with no edge in either mode** — mount AuthShell with title + "Log in"; assert an inline `` (not ``) 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 ``; 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 the outer square still has no + stroke, per `docs/design/source/AuthCard.dc.html`'s literal, non-theme-branched + `edge: 'transparent'`. 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 @@ -58,8 +59,8 @@ Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Log in", p - [ ] 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. + drawing exactly (white R on a black square), desktop and mobile; no edge stroke is visible + around the square in either light or dark mode. - [ ] 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. diff --git a/features/login_card_conformance.feature b/features/login_card_conformance.feature new file mode 100644 index 0000000..2443500 --- /dev/null +++ b/features/login_card_conformance.feature @@ -0,0 +1,102 @@ +Feature: Log-in card sizing, alert placement and focus behavior match the Auth board + + As the maintainer of RenewCMS + I want AuthShell's card padding, control heights, "Forgot password?" styling, field-error + presentation, theme select and h1 focus timing built exactly to the AuthCard board + (docs/design/source/AuthCard.dc.html) instead of Nuxt UI's untouched defaults + So that /login (and every other AuthShell-based public screen) matches the design board pixel + for pixel, at desktop and mobile, in light and dark + + This complements features/auth_shell_login_conformance.feature, which covers AppLogo, the + UPageCard title/description wiring, the muted-background breakpoint and the neutral/error input + edge. This feature covers what that one doesn't: exact card/control measurements, the "Forgot + password?" link's own styling, the field-error icon and halo behavior, the theme select's + chevron, where the alert sits relative to the card, and when the h1 receives focus. + + Background: + Given AuthShell is the layout for every public route, per docs/design/routes.md + And the card's padding, control heights and title size come from + docs/design/source/AuthCard.dc.html's renderVals(): pad "40px" and control height "40px" + from the sm breakpoint up; pad "8px 20px 0" (with a separate footer padding of + "0 20px 24px") and control height "44px" below sm; the title is always "font:600 20px/28px" + And the Password UFormField's "Forgot password?" link is styled color var(--ui-text-toned) + (muted), text-decoration underline, underline-offset 3px, per AuthCard.dc.html's f.link span + And a field error renders with the i-lucide-circle-alert icon in var(--ui-error), and the + field's edge/shadow is computed error-first, so an errored field that is also focused shows + only the single error-colored inset ring, never a focus ring stacked under or around it + And AuthShell's notice slot (the alert) renders inside the card's own layout, after the + title/description block and before the form — not as a sibling element above the card + And the footer's UColorModeSelect renders with no chevron/dropdown-arrow icon, as the last + (right-hand) child of the footer's space-between row, per AuthCard.dc.html's theme span + And on first paint, AuthShell's h1 (tabindex="-1") does not receive focus; focus moves to it + only when the title changes while AuthShell stays mounted (an in-app navigation between + public screens, e.g. /login to /register/reset/request), per components.md's AuthShell a11y + line ("On route change, focus moves to the h1") + And the h1 never shows a visible focus outline, since it is only ever focused programmatically + and is not part of the visible keyboard-tab order + + Scenario: Card padding and control heights at sm and up + Given the user is on /login at a viewport of sm or wider + When AuthShell and the login form render + Then the card's own padding is 40px on every side + And the Username input, the Password input and the submit button are each 40px tall + And input text renders at 14px + + Scenario: Card padding and control heights below sm + Given the user is on /login at a viewport below sm + When AuthShell and the login form render + Then the card's own padding is 8px top, 20px left and right, 0 bottom + And the footer (outside the card) has its own padding of 0 top, 20px left and right, 24px bottom + And the Username input, the Password input and the submit button are each 44px tall + And input text renders at 16px + + Scenario: "Forgot password?" is underlined and muted + Given the user is on /login + When the Password field renders + Then the "Forgot password?" link uses the muted text color (--ui-text-toned) + And the link is underlined with a 3px underline offset + And the link carries no other emphasis (not bold, not the primary color) + + Scenario: A field error shows the circle-alert icon with no extra focus halo + Given the user is on /login + When the user submits with Username "ma" and Password empty (the Validation errors state) + Then the Username field's error message is preceded by the i-lucide-circle-alert icon in the + error color + And the Password field's error message is preceded by the same icon in the same color + When the user tabs into the still-errored Username field so it is both errored and focused + Then the field shows exactly one inset ring, in the error color + And no second, differently colored focus ring is layered under or around it + + Scenario: The theme select is right-aligned in the footer with no chevron + Given the user is on /login + When AuthShell's footer renders + Then the UColorModeSelect is the last (right-most) element in the footer's row + And it renders only its mode icon and label ("System", "Light" or "Dark") + And it renders no trailing chevron or dropdown-arrow icon + + Scenario Outline: The alert renders inside the card, under the description + Given the user is redirected to + When /login renders with its alert + Then the alert is a child of the same card element that contains the title and description, + not a sibling positioned above the card + And the alert sits after the title/description block and before the Username field + And the alert's background uses UAlert's soft variant, which is dark-tinted automatically in + dark mode + + Examples: + | path | state | + | /login?redirect=/article/12/edit&reason=expired | Session expired | + | /login?reason=password-changed | Password changed | + + Scenario: The h1 is not focused on first load + Given the user navigates directly to /login (a fresh page load) + When AuthShell mounts and the h1 renders + Then the h1 does not receive focus + And focus remains on the document body (or wherever the browser places it on load) + + Scenario: Focus moves to the h1 only on an in-app navigation between public screens + Given the user is on /login with AuthShell already mounted + When the user follows the "Forgot password?" link to /register/reset/request without a full + page reload + Then AuthShell's h1 receives focus once ResetRequestPage's title renders + And the h1 shows no visible outline when it receives this focus diff --git a/features/login_card_conformance.qa.md b/features/login_card_conformance.qa.md new file mode 100644 index 0000000..1d1c24f --- /dev/null +++ b/features/login_card_conformance.qa.md @@ -0,0 +1,59 @@ +# QA: Log-in card sizing, alert placement and focus behavior match the Auth board + +## Automated (Vitest + Vue Test Utils, `task test:web`) + +Mount AuthShell/LoginView directly; mock `useApi` (`GET /v1/auth/registration`, +`POST /v1/auth/login`) only as scaffolding, per features/login_screen.qa.md's setup. + +1. **Card padding and control heights at sm+** — mount at a `>= 640px` viewport; assert the + card's own padding is 40px on every side (not Nuxt UI's untouched default); assert Username, + Password and the submit button each render at a computed height of 40px; assert input font-size + is 14px. +2. **Card padding and control heights below sm** — mount at a `< 640px` viewport; assert the + card's padding is 8px top / 20px sides / 0 bottom; assert the footer's own padding is 0 top / + 20px sides / 24px bottom; assert Username, Password and the submit button are each 44px tall; + assert input font-size is 16px. +3. **"Forgot password?" styling** — assert the link's computed color equals `--ui-text-toned`, + `text-decoration-line: underline`, `text-underline-offset: 3px`, and that it carries no bold + weight or primary-color class. +4. **Field error icon and no stacked halo** — trigger the Validation errors state; assert each + field's error text is preceded by an `i-lucide-circle-alert` icon in the error color; then + focus the still-errored Username input and assert only one ring class (the error ring) is + present — the neutral/primary focus ring class is absent, not merely visually covered. +5. **Theme select has no chevron, is footer-last** — assert `UColorModeSelect` is the last child + of the footer's flex row; assert no chevron-down icon element renders inside it. +6. **Alert renders inside the card** — mount at `/login?reason=expired` and separately at + `/login?reason=password-changed`; assert the alert element's closest UPageCard/card ancestor is + the same element that contains the h1 (i.e., the alert is NOT a sibling before the card); + assert its DOM position is after the title/description block and before the Username field. +7. **h1 not focused on first load** — mount LoginView fresh (simulating first paint); assert + `document.activeElement` is not the h1. +8. **h1 focus on in-app navigation only** — mount AuthShell at /login, then update the `title` + prop the way an in-app route change to /register/reset/request would (per the existing `watch` + on `props.title`); assert the h1 now has focus. + +## Visual (manual verification — `task visual:check -- log-in`, desktop and mobile, light and dark) + +Reference: `docs/design/source/AuthCard.dc.html` via `RenewCMS Screens Auth.dc.html`, group +"Log in", all 9 states. + +- [ ] Card padding is 40px on every side at desktop, and 8px/20px/0 (card) + 0/20px/24px + (footer) below sm, across all 9 states. +- [ ] Username, Password and the submit button are 40px tall at desktop and 44px below sm. +- [ ] "Forgot password?" is muted and underlined in every state that shows it. +- [ ] In the Validation errors state, both fields show the circle-alert icon and exactly one + ring around the field (no double ring), light and dark. +- [ ] The theme select shows icon + label only, no chevron, right-aligned in the footer. +- [ ] In Session expired and Password changed, the alert sits inside the card, under the + description, above the Username field, and is visibly dark-tinted (not a light-mode + background) in dark mode. +- [ ] Keyboard/focus: on a fresh /login load, nothing is auto-focused to the h1 (initial focus + stays with the browser/body); following "Forgot password?" to the reset-request screen moves + focus to that screen's h1, with no visible outline ring at any point. + +## Manual verification + +- **AppLogo edge in dark mode** — resolved by the amended + features/auth_shell_login_conformance.feature: AppLogo shows no edge stroke in either light or + dark mode, per AuthCard.dc.html's literal `edge: 'transparent'`. No check needed here; see that + feature's own QA file for the AppLogo assertions. diff --git a/web/src/components/AppLogo.vue b/web/src/components/AppLogo.vue index 6404452..c93e08c 100644 --- a/web/src/components/AppLogo.vue +++ b/web/src/components/AppLogo.vue @@ -17,12 +17,7 @@ withDefaults( shape-rendering="crispEdges" xmlns="http://www.w3.org/2000/svg" > - + 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( diff --git a/web/src/components/__tests__/AppLogo.spec.ts b/web/src/components/__tests__/AppLogo.spec.ts index 243d0ba..8bd4e43 100644 --- a/web/src/components/__tests__/AppLogo.spec.ts +++ b/web/src/components/__tests__/AppLogo.spec.ts @@ -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) }) }) diff --git a/web/src/components/__tests__/AuthShell.spec.ts b/web/src/components/__tests__/AuthShell.spec.ts index 6c28f23..94ef5c9 100644 --- a/web/src/components/__tests__/AuthShell.spec.ts +++ b/web/src/components/__tests__/AuthShell.spec.ts @@ -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, slots: Record = {}) { 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(' ({ @@ -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') }) }) diff --git a/web/src/components/__tests__/loginCardConformance.spec.ts b/web/src/components/__tests__/loginCardConformance.spec.ts new file mode 100644 index 0000000..5e1e70e --- /dev/null +++ b/web/src/components/__tests__/loginCardConformance.spec.ts @@ -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> | 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() + }) +}) diff --git a/web/src/composables/useAuthShellNavigation.ts b/web/src/composables/useAuthShellNavigation.ts new file mode 100644 index 0000000..ba3d392 --- /dev/null +++ b/web/src/composables/useAuthShellNavigation.ts @@ -0,0 +1,11 @@ +let hasEnteredBefore = false + +export function isInAppNavigation(): boolean { + const wasEntered = hasEnteredBefore + hasEnteredBefore = true + return wasEntered +} + +export function resetAuthShellNavigation(): void { + hasEnteredBefore = false +} diff --git a/web/src/views/LoginView.vue b/web/src/views/LoginView.vue index 8ce8435..904051a 100644 --- a/web/src/views/LoginView.vue +++ b/web/src/views/LoginView.vue @@ -211,7 +211,7 @@ onMounted(async () => { /> -