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(
-
-
-
-
-
-
- {{ title }}
+ {{ title }}
-
+
+
+
+
+
-
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 () => {
/>
-
+
{
:error="usernameError || undefined"
:size="controlSize"
>
+
+
+
+ {{ error }}
+
+
{
color="primary"
:highlight="!!usernameError"
:size="controlSize"
+ :ui="{ base: controlClass }"
class="w-full"
/>
@@ -239,10 +246,19 @@ onMounted(async () => {
:size="controlSize"
>
-
+
Forgot password?
+
+
+
+ {{ error }}
+
+
{
color="primary"
:highlight="!!passwordError"
:size="controlSize"
+ :ui="{ base: controlClass }"
class="w-full"
>
@@ -273,6 +290,7 @@ onMounted(async () => {
type="submit"
block
:size="controlSize"
+ :ui="{ base: controlClass }"
:loading="submitting"
:disabled="submitDisabled"
>