Feature: Profile page, display name and the account menu As a verified RenewCMS user in the shared workspace I want /profile to let me set an optional display name, see my read-only account details and email myself a password reset link, and I want the sidebar account chip to open a menu that leads there So that people are named by a human name across the back-office, while logging in still uses the username, and I always have a way to reach Profile, switch theme and log out Background: Given /profile is a verified route (meta.auth "verified", meta.title "Profile") rendered as ProfilePage inside DashboardShell, per docs/design/routes.md and docs/design/screens/profile.md And the endpoints already exist and this slice wires the SPA to them with no Go change: GET /v1/auth/me (returns id, username, email, is_verified, display_name), PATCH /v1/user/me (features/user_display_name.feature) and POST /v1/auth/password-reset (features/request_password_reset.feature) And useApi's getMe and the Session type gain email and displayName; a missing or null display_name in a response is read as ""; the session store gains an action that replaces the session's displayName from the display_name of a PATCH /v1/user/me response And the PageHeader shows breadcrumb "Profile" (mobile title "Profile"), the page title "Profile" and the subtitle "Your account on this RenewCMS workspace."; none of the five navigation items (Home, Articles, Users, Integration, Settings) has aria-current="page" on /profile And the page is at most 720 px wide with three ProfileCards in this order: Display name, Account, Password; cards are separated by 20 px, have 20 px padding (16 px on mobile) and inputs are 40 px high (44 px on mobile, 16 px text) And the Display name card has the title "Display name", the text "Shown instead of your username across the back-office and in the emails RenewCMS sends. Leave it empty to use your username.", one input labelled "Display name (optional)" with placeholder "e.g. Marta Alves", and a help row under the input: the field error on the left (icon circle-alert, 12 px, error color) and the counter "n / 60" on the right in the mono style; there is no maxlength, so typing past 60 is possible and shows the error And the Display name card's footer strip (top border, --ui-bg-muted, 12 px status text on the left, Save button on the right, 32 px high on desktop and 44 px on mobile) shows "Unsaved changes" in the warning color while the draft is dirty and no save is in flight; otherwise it shows "You appear as “{display name}”." when a display name is saved, or "You appear as “{username}”, your username." when none is And the draft is the trimmed field value (String.prototype.trim); it is dirty when it differs from the session's displayName; it is valid when its length counted in Unicode code points (Array.from(value).length, the same count as the API's utf8.RuneCountInString, not bytes, not UTF-16 units and not grapheme clusters) is 60 or fewer; the counter shows that count and turns red (error color) above 60 And Save is enabled (full opacity) while the draft is dirty and valid, or while a save is in flight; otherwise it is disabled at 40 % opacity; pressing Enter in the field activates Save only when Save is enabled, and does nothing otherwise And activating Save sends PATCH /v1/user/me with JSON {"display_name": ""}; while it is in flight the field is disabled and dimmed, the button reads "Saving…" with a loader-circle icon, and a second submit is ignored And a 200 response replaces session.displayName with the response's display_name at once (the server's value, not the typed one), sets the field to that value, and raises a toast: color success, icon i-lucide-circle-check, id "display-name-saved", title "Display name saved", description "You now appear as {display name} across RenewCMS and in emails." And the display name is shown wherever a person is named, read from the session: the sidebar chip and the account menu (name and initials) and the Home greeting "{greeting}, {name}", where name is the display name when not empty and the username otherwise; the Users list reads display_name from its own GET /v1/user/ items and is not changed by this slice And any non-2xx or failed PATCH keeps the typed value and the dirty state; a 401 and a 403 are handled entirely by useApi's global handling (features/api_client_global_error_handling.feature) And the Account card has the title "Account", the text "Set when your account was created. You log in with your username, even after adding a display name.", and two disabled inputs with a lock icon at the right: "Username" (the session's username) and "Email" (the session's email), in two columns on desktop (minmax(0,1fr) minmax(0,1.4fr)) and stacked on mobile And the Password card has the title "Password", the text "To change it, we'll email a reset link to {email}. The link works once, within 1 hour.", a disabled input labelled "Password" showing "••••••••••••" with a lock icon, and, beside it (stacked on mobile), a secondary button "Send reset link" with the mail icon; the note under them exists only once a request has been made And activating the button sends POST /v1/auth/password-reset with JSON {"email": ""}, with no confirmation step; while in flight the button reads "Sending…" with a loader-circle icon and is disabled; the note is role="status", 16 px icon, 600-weight title and the text on a soft background And leaving /profile while the draft is dirty (a route change, including Log out) opens a ConfirmDialog, except when the redirect is caused by a 401 handled by useApi (authRedirectPending); leaving while the draft is not dirty, or after a successful save, opens nothing And the UserMenu chip in the sidebar footer no longer logs out on click: it opens the account menu; the chip is 40 px high with a 26 px sand (#d4c5b0) initials avatar with ink text, the name (display name, else username) with truncation, and a chevrons-up-down icon; it has aria-haspopup="menu", aria-expanded reflecting the open state, and title "Account" And while the menu is open, or while the current route is /profile, the chip takes --rc-sand with #111111 text And on desktop (lg and up) the menu is a 224 px popover above the chip aligned to its left edge with --rc-shadow-pop; below lg the account row in the navigation drawer closes the drawer and opens the menu as a bottom sheet with a grab handle over an --rc-overlay scrim; Escape or a click outside closes either, and focus returns to the chip; arrow keys move between items And the menu content, top to bottom, is: a header (the name in semibold, and the email below it in muted 12 px on desktop; on mobile a 36 px initials avatar, the name at 15 px semibold and the email at 14 px muted), a "Profile" item (icon circle-user, highlighted with --ui-bg-elevated while the route is /profile), a separator, the three checkbox items "System", "Light" and "Dark" bound to useColorMode().preference with the current one checked (kept from the shipped menu), a separator, and "Log out" (icon log-out); items are 32 px on desktop and 48 px at 15 px text on mobile And activating "Profile" navigates to /profile and closes the menu (when already on /profile it only closes); activating a theme item sets the preference and, in the desktop popover, keeps the menu open (the mobile sheet closes); activating "Log out" behaves as today: POST /v1/auth/logout in a try/finally, then session.clear() and a navigation to /login And avatar initials are the uppercased first Unicode character of each of the first two words of the display name; a display name with a single word, and a username, give their first two characters, uppercased And in the collapsed sidebar rail the chip shows only the avatar, keeps its accessible name "{name} — account menu" and opens the same menu And this slice does not touch the invitation or accept-invite work, does not change the Users page, and does not add a way to change the username, email or password inside the page Scenario Outline: Route guards for /profile Given the session is When the user navigates to /profile Then Examples: | session | outcome | | resolved, verified | ProfilePage renders | | resolved, unverified | the app redirects to /register/pending | | resolved with no session (401 from /auth/me) | the app redirects to /login?redirect=/profile&reason=expired | Scenario: Default — the usual first visit with no display name Given the session is verified with id 1, username "marta", email "marta@renew.example" and no display name When the user navigates to /profile Then the breadcrumb reads "Profile" (mobile title "Profile") And the Display name field is empty, shows the placeholder "e.g. Marta Alves", and the counter reads "0 / 60" And the footer reads "You appear as “marta”, your username." And Save is disabled And the Username input shows "marta" and the Email input shows "marta@renew.example", both disabled And the Password card text reads "To change it, we'll email a reset link to marta@renew.example. The link works once, within 1 hour." And the button reads "Send reset link", no note is shown, and no request other than the session's was sent Scenario: Loading — opened by URL before /auth/me has returned Given GET /v1/auth/me has not yet resolved When the user opens /profile directly by URL Then the breadcrumb reads "Profile", and the page title and subtitle are shown And three skeleton cards replace the real cards (one with a title bar and one input, one with a title bar and two inputs, one with a title bar and one input), 16 px apart, in an aria-busy="true" container And AppBoot is not shown When GET /v1/auth/me then resolves 200 for the verified user "marta" Then the skeleton cards are replaced by the Default state Scenario: Opened from the menu, the cached user renders at once Given the user is on /home with a resolved session for "marta" When the user activates "Profile" in the account menu Then the Default state renders with no skeleton, and GET /v1/auth/me is not called again Scenario: Editing — typing turns Save on Given the user is on /profile in the Default state When the user types "Marta Alves" into Display name Then the counter reads "11 / 60" and the footer reads "Unsaved changes" in the warning color And Save is enabled And no request has been sent Scenario Outline: The counter counts Unicode characters after trimming Given the user is on /profile in the Default state When the user types into Display name Then the counter reads " / 60" And the counter is and Save is Examples: | value | count | color | save | | "Marta Alves" | 11 | muted | enabled | | " Marta Alves " | 11 | muted | enabled | | 60 repetitions of "a" | 60 | muted | enabled | | 61 repetitions of "a" | 61 | red | disabled | | 60 repetitions of "😀" | 60 | muted | enabled | | 61 repetitions of "😀" | 61 | red | disabled | | "Cafe" followed by U+0301 | 5 | muted | enabled | | 5 spaces, 60 "a", 5 spaces | 60 | muted | enabled | Scenario: Too long — over 60 characters shows a field error and keeps Save off Given the user is on /profile in the Default state When the user types "Marta Alves, Head of Renovations, Brussels and Wallonia office" Then the counter reads "62 / 60" in the error color And the help row shows the error "Use 60 characters or fewer." with the circle-alert icon And the field has the error border And the footer reads "Unsaved changes" in the warning color And Save is disabled When the user presses Enter in the field Then PATCH /v1/user/me is never sent When the user shortens the value to "Marta Alves" Then the error disappears, the counter reads "11 / 60" and Save is enabled Scenario Outline: Dirty is measured on the trimmed draft against the saved name Given the session's display name is "" When the user sets Display name to , read literally as described in the column Then Save is And the footer reads "