# QA: Profile page, display name and the account menu ## Implementation note for coder (informational, not a QA assertion) There is no Go change and no HTTP-level Go section: PATCH /v1/user/me, GET /v1/auth/me and POST /v1/auth/password-reset are already covered (features/user_display_name.qa.md, features/get_current_user.qa.md, features/request_password_reset.qa.md). Amended specs (their Vitest tests must be updated in the same change): dashboard_shell_branding.feature (UserMenu label, initials, groups and the collapsed aria-label), home.feature (greeting name), plus session.spec.ts, useApi.spec.ts (getMe maps email and display_name, absent means ""), UserMenu.spec.ts, DashboardShell.spec.ts and the HomeView spec. The Saved toast uses `useToast().add({ id: 'display-name-saved', color: 'success', icon: 'i-lucide-circle-check', title: 'Display name saved', description: 'You now appear as {name} across RenewCMS and in emails.' })`. The 429 countdown reuses `useRetryAfter`; the 300 s fallback applies when `ApiError.retryAfter` is undefined or 0. ## Automated (Vitest + Vue Test Utils, `task test:web`) Mock `useApi`'s `request` and `getMe` per case; no real backend. Mount ProfilePage inside DashboardShell with a resolved verified session (id 1, username "marta", email "marta@renew.example", displayName "") and a test router at /profile. Use fake timers only for the countdown. No test freezes the system date; the Home greeting cases mock the hour as home.qa.md does and assert the name part only. Run the layout cases at a desktop and a below-lg width (mock `matchMedia`). 1. **Route guards, Examples-driven** - verified renders; unverified redirects to /register/pending; 401 redirects to /login?redirect=/profile&reason=expired. 2. **Default** - breadcrumb "Profile", title, subtitle, empty field with placeholder, "0 / 60", footer "You appear as “marta”, your username.", Save disabled, Username and Email inputs disabled with their values, Password text with the email, "Send reset link", no note; no nav item has aria-current. 3. **Loading** - unresolved GET /v1/auth/me; assert three skeleton cards, aria-busy, no AppBoot; resolve and assert Default. 4. **From the menu** - navigate from /home via "Profile"; assert no skeleton and no second GET /v1/auth/me. 5. **Editing** - type "Marta Alves"; assert "11 / 60", "Unsaved changes", Save enabled, no request. 6. **Counter, Examples-driven** - each table row, including the 60/61 emoji rows and "Cafe" + U+0301 (5); assert count, color class and Save state. 7. **Too long** - the 62-character value; assert "62 / 60", the error with circle-alert, "Unsaved changes", Save disabled, Enter sends nothing; shorten and assert the error clears. 8. **Dirty rules, Examples-driven** - each row. 9. **Enter saves** - assert one PATCH with body `{"display_name":"Marta Alves"}`. 10. **Saving** - unresolved PATCH with a padded value; assert the trimmed body, disabled and dimmed field, "Saving…", and that Enter and a second click send nothing. 11. **Saved** - resolve 200; assert session.displayName, chip "Marta Alves" with "MA", field value, "11 / 60", footer, Save disabled, the toast arguments, and no leave dialog after navigating. 12. **Server value wins** - resolve with a trimmed display_name; assert all consumers use it. 13. **Clearing** - saved "Marta Alves", empty the field, save; assert body `{"display_name":""}`, chip "marta", footer, and the "Display name removed" toast. 14. **Save failed, Examples-driven** - `ApiError(500)` and `NetworkError`; assert the alert copy, the kept value, the "Unsaved changes" footer, Save enabled, the session unchanged and no toast; save again and assert the alert clears during the request and PATCH is sent again. 15. **400, Examples-driven** - `ApiError(400)` with the display-name text gives the field error; with another body gives the Save failed alert. 16. **Leave guard** - dirty draft, navigate to /users; assert ConfirmDialog props (title, text, "Keep editing" as confirm and "Discard changes" as cancel, neutral tone); Keep editing stays; Discard leaves and leaves the session name unchanged. Also assert: clean draft leaves with no dialog; a 401 during PATCH redirects with no dialog (authRedirectPending); Log out while dirty shows the dialog. 17. **Menu, desktop** - click the chip; assert aria-haspopup, aria-expanded, title "Account", the sand class, the header name and email, the item order (Profile, System, Light, Dark, Log out) with the two separators, and Profile not highlighted. 18. **Menu, mobile** - below lg, open the drawer and activate the account row; assert the drawer closed, the sheet with its grab handle, the 36 px avatar header and the same item order. 19. **Closing** - Escape and an outside click; assert closed, aria-expanded false, focus on the chip; ArrowDown and ArrowUp move between items. 20. **Menu items** - Profile navigates and closes; on /profile it is highlighted, closes with no navigation; Log out calls POST /v1/auth/logout, clears the session and goes to /login. 21. **Chip fill** - sand on /profile with the menu closed; not sand on /home. 22. **Theme** - activating "Dark" sets `useColorMode().preference`, closes the menu, and reopening shows "Dark" checked. 23. **Initials, Examples-driven** - each row, including "Élodie Martin" giving "ÉM", a single word giving two letters, and username "m.alves" giving "M." (first two characters). 24. **Collapsed rail** - avatar only, aria-label "Marta Alves — account menu", same menu. 25. **Home greeting, Examples-driven** - no display name gives "marta"; "Marta Alves" gives "Marta Alves"; then save on /profile and navigate to /home and assert the new name. Do not assert the greeting word or the hour. 26. **Reset: sending** - unresolved POST; assert body `{"email":"marta@renew.example"}`, "Sending…", disabled, no note. 27. **Reset: sent and Send again** - 200 gives "Send again" and the mail-check note with the exact copy; "Send again" hides the note during the request, sends the same body, and on 200 shows it again; remounting resets to idle. 28. **Reset: 429, Examples-driven** - reject with `ApiError(429, )`; assert the warning note copy, disabled button reading "Send reset link"; advance fake timers by the table's seconds minus 1 and assert still disabled, then by 1 and assert enabled with the note gone. Rows: 42, 300, undefined (300), 0 (300). 29. **Reset: failed, Examples-driven** - `ApiError(500)` and `NetworkError`; assert the error note copy, enabled button, and that a retry resolving 200 gives the sent note. 30. **Users list** - mount UsersPage with session display name "Marta Alves" and the users_screen Default items where marta and ines carry display names; assert the marta row shows "Marta Alves" then "marta" and the "you" tag, ines shows "Inès Duarte" then "ines", jonas shows alone, and the chip reads "Marta Alves". ## Visual (task visual:check -- profile, and -- home; desktop and mobile, light and dark) Reference: `docs/design/screens/profile.md`, board group "Profile" in `docs/design/source/RenewCMS Screens Back-office.dc.html`, exact values in `docs/design/source/AppFrame.dc.html`. Add each state's setup to `web/visual/states.ts` with the board's own sample data. The gate is hard: every capture must be within 10 %. Base session mock: GET /v1/auth/me 200 with id 1, username "marta", email "marta@renew.example", is_verified true, display_name "" (state-specific overrides below). - [ ] Account menu: /home with `now: boardNow` and the home Default fixtures, session as above (id 1, "marta", "marta@renew.example", display_name ""), menu opened by clicking the chip (on mobile: open the drawer, then the account row). Name "marta", initials "MA", email "marta@renew.example". The board now draws System, Light and Dark, with Light checked in its sample. The `exception` block is removed from web/visual/states.ts, so the default 10 % gate applies. `task visual:check -- profile "Account menu"` must PASS on all four captures (desktop and mobile, light and dark). If the only remaining diff is the board's Profile highlight on Home (the app highlights Profile only on /profile), do NOT re-add an exception and do NOT highlight Profile on Home. Stop and ask the user. - [ ] Manual: item heights measured 32 px (desktop popover) and 48 px (mobile sheet); arrow keys move between items; Escape and outside click close; choosing a theme keeps the menu open. - [ ] Loading: /profile with GET /v1/auth/me hanging; three skeleton cards, 16 px apart. - [ ] Default, no display name: empty field, "0 / 60", the username footer, Save at 40 %. - [ ] Editing: type "Marta Alves"; field focused; "11 / 60"; "Unsaved changes"; Save enabled. - [ ] Too long: type "Marta Alves, Head of Renovations, Brussels and Wallonia office"; "62 / 60" red; error "Use 60 characters or fewer."; Save at 40 %. - [ ] Saving: type "Marta Alves", press Save with PATCH hanging; disabled dimmed field, "Saving…". - [ ] Saved: PATCH 200 with display_name "Marta Alves"; chip "Marta Alves" "MA"; toast "Display name saved" / "You now appear as Marta Alves across RenewCMS and in emails." - [ ] Save failed: PATCH 500 after typing "Marta Alves"; the error alert in the card; "Unsaved changes". - [ ] Reset: sending: session display_name "Marta Alves"; POST /v1/auth/password-reset hanging; "Sending…". - [ ] Reset: sent: same session; POST 200; "Send again" and the "Reset link sent" note. - [ ] Reset: too many requests (429): POST 429 with header `Retry-After: 300`; disabled button at 40 % and the "Too many requests" note. - [ ] Reset: failed: POST 500; the "Couldn't send the link" note. - [ ] Display names in the Users list: /users, session display_name "Marta Alves"; GET /v1/user/ with the five board rows (sam.oliveira@renew.example pending 27 Sep 2026, p.huber@gmail.com expired 3 Aug 2026, ines "Inès Duarte" 18 Jun 2026, jonas 2 Apr 2026, marta "Marta Alves" 12 Mar 2026 with "you"). Clock-sensitive rendering is skipped per repo rules. - [ ] `task visual:check -- home` still PASSes for Default, Loading, Empty and Error. The session fixture has display_name "", so the greeting name is "marta". Extend the fixture with `email` and `display_name` (it must tolerate them being absent). - [ ] Layout from the board frame, not components.md: content column 720 px max; cards separated by 20 px; the footer strip has the status text left and Save right (32 px high on desktop, 44 px on mobile); the account inputs are two columns on desktop and stacked on mobile; the password input and button are a row (button aligned to the bottom edge) on desktop and stacked, full width, on mobile. - [ ] Not covered by screenshots: keyboard and focus order (Tab reaches the field, Save, "Send reset link"; Escape returns focus to the chip; focus returns to the field after a failed save), hover and active states, motion (popover and sheet, skeleton pulse 1.6 s, both stopped under reduced motion). - [ ] Not drawn, no visual state: the leave-page dialog, the "Display name removed" toast, the 400 variants. Vitest only (cases 13, 15, 16). ## Manual verification - **Real round trips against `task dev`.** Vitest only mocks `useApi`. Save a display name and confirm the chip, menu, Home greeting and Users list show it after a reload; clear it and confirm the username returns; confirm login still works only with the username. - **Unicode limit.** Paste 60 and 61 emoji and a combining-mark name; the client counter and the API (`utf8.RuneCountInString`) agree at the boundary. - **Real reset email** (needs real or sandboxed SMTP, `SMTP_HOST` and related vars). "Send reset link" delivers a mail to the account's own address, the link ends on the reset-confirm screen, and a completed reset invalidates the current session (`token_version`). "Send again" invalidates the older link. - **Real 429.** Exceed `AUTH_RATE_LIMIT_MAX_ATTEMPTS` within `AUTH_RATE_LIMIT_WINDOW_SECONDS` and confirm the button re-enables when the real Retry-After elapses. The limiter budget is shared with login. - **API quirk, out of scope.** POST /v1/auth/password-reset returns 200 even if the mail cannot be sent (the handler discards the use-case error), so with SMTP down the page still shows "Reset link sent". Reported, not fixed here. - **Mobile sheet and toasts on a real device.** The drawer-to-sheet handoff, scrim, safe-area padding, and the toast placement (bottom center on mobile). - **Theme persistence.** The choice made from the menu survives a reload.