From 897c056358abff581eb02bb975438415e530567e Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Fri, 31 Jul 2026 19:01:56 +0200 Subject: [PATCH 1/3] docs(plan): Add the header username plan Authenticated users see Dashboard, Courses, and Log out, but nothing tells them which account they are acting as. The plan reuses the dashboard greeting pattern (sec:authentication) to show the escaped, CSS-truncated username in the header, with a visually hidden "Signed in as" prefix for screen readers. Refs #126 --- docs/plans/2026-07-28-header-username.md | 32 ++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 docs/plans/2026-07-28-header-username.md diff --git a/docs/plans/2026-07-28-header-username.md b/docs/plans/2026-07-28-header-username.md new file mode 100644 index 0000000..45f1c04 --- /dev/null +++ b/docs/plans/2026-07-28-header-username.md @@ -0,0 +1,32 @@ +# Header Username Implementation Plan + +**Goal:** Show the signed-in username in the site header (issue #126). Authenticated users currently see Dashboard, Courses, and Log out, but nothing tells them which account they are acting as; the username only appears in the dashboard greeting. This matters when switching between the student, instructor, and admin roles. + +**Approach:** Reuse the existing display pattern from the dashboard greeting (`sec:authentication="name"`, Thymeleaf Spring Security extras already imported by the layout fragment). The username is user-typed data, so it is rendered escaped (inherent to the attribute processor) and truncated with CSS so it can never distort or spoof the header chrome. No ADR: this is a presentation change, no architectural decision involved. + +**Out of scope:** a profile page or menu behind the username, and any change to what identifies a user (the `username` column stays the display name). + +--- + +## Version Control (GitButler) + +- Commit with `but commit feat/header-username -m ""` from the main repository. +- **NEVER push.** The user reviews in GitButler and pushes manually. + +--- + +## Tasks + +- [ ] `docs(plan): Add the header username plan` (this document) +- [ ] `feat(frontend): Show the signed-in username in the header` (closes #126) + - `fragments/layout.html`, authenticated `ul.nav__list`, before the Log out control: + `` + (the `.visually-hidden` utility already exists in `base.css`; screen readers get the sentence, sighted users just see the name) + - `base.css`: `.nav__user` as non-interactive chrome (`color: var(--color-text-muted)`, `font-size: var(--font-size-sm)`); `.nav__user-name` with `max-width: 12rem`, `overflow: hidden`, `text-overflow: ellipsis`, `white-space: nowrap`, `display: inline-block` so a 50-character username (the database cap) truncates instead of wrapping the header + - MockMvc assertions: an authenticated page renders "Signed in as" plus the principal's username in the header; an anonymous page contains no `nav__user` + +## Verification + +- Full test suite and Checkstyle green. +- Browser pane, both themes: username visible next to Log out, muted; axe (WCAG 2.1 A/AA) reports no new violations; a 50-character username truncates with an ellipsis and causes no horizontal scroll. +- Demo data cleaned up afterwards; nothing pushed. From 68071785c2568632e270b25c8c9e6a9749d78780 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Fri, 31 Jul 2026 19:05:58 +0200 Subject: [PATCH 2/3] feat(frontend): Show the signed-in username in the header Nothing in the page chrome said which account the user was acting as: the header showed Dashboard, Courses, and Log out, and the username only appeared in the dashboard greeting, which matters when switching between student, instructor, and admin accounts. The authenticated nav now carries the username before the Log out control, reusing the dashboard's sec:authentication pattern, so it is escaped by the attribute processor. A visually hidden prefix makes screen readers announce "Signed in as [name]" while sighted users just see the muted name. The CSS truncates at 12rem with an ellipsis so a 50 character username (the schema cap) cannot distort the header. MockMvc asserts the item renders when authenticated and is absent for anonymous visitors. Fixes #126 --- src/main/resources/static/css/base.css | 18 ++++++++++++++++++ .../resources/templates/fragments/layout.html | 7 +++++++ .../learndev/auth/AuthFlowTest.java | 19 +++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/src/main/resources/static/css/base.css b/src/main/resources/static/css/base.css index f7986ce..e04b1fb 100644 --- a/src/main/resources/static/css/base.css +++ b/src/main/resources/static/css/base.css @@ -211,6 +211,24 @@ main:focus { box-shadow: inset 0 -2px 0 var(--color-primary); } +/* The signed-in username: non-interactive chrome, deliberately muted so + it does not compete with the nav links. */ +.nav__user { + color: var(--color-text-muted); + font-size: var(--font-size-sm); +} + +/* Usernames are user-typed (50 chars max in the schema): truncate rather + than let a long one wrap or stretch the header. */ +.nav__user-name { + display: inline-block; + max-width: 12rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: bottom; +} + /* ---------- Layout ---------- */ .site-main { diff --git a/src/main/resources/templates/fragments/layout.html b/src/main/resources/templates/fragments/layout.html index eaeecad..ae3e739 100644 --- a/src/main/resources/templates/fragments/layout.html +++ b/src/main/resources/templates/fragments/layout.html @@ -62,6 +62,13 @@
  • Admin
  • + +
  • diff --git a/src/test/java/com/ericbouchut/learndev/auth/AuthFlowTest.java b/src/test/java/com/ericbouchut/learndev/auth/AuthFlowTest.java index 8e98e0a..c16ba92 100644 --- a/src/test/java/com/ericbouchut/learndev/auth/AuthFlowTest.java +++ b/src/test/java/com/ericbouchut/learndev/auth/AuthFlowTest.java @@ -8,8 +8,10 @@ import org.springframework.test.web.servlet.MockMvc; import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -89,4 +91,21 @@ void register_form_renders_and_shows_field_errors() throws Exception { .andExpect(content().string(containsString("aria-invalid=\"true\""))) .andExpect(content().string(containsString("id=\"username-error\""))); } + + /** + * The header identifies the signed-in account on every page: sighted + * users see the username, screen readers hear "Signed in as [name]" + * (issue #126). Anonymous visitors get no such item. + */ + @Test + void header_shows_the_signed_in_username() throws Exception { + mvc.perform(get("/").with(user("carol-header").roles("STUDENT"))) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Signed in as"))) + .andExpect(content().string(containsString("carol-header"))); + + mvc.perform(get("/")) + .andExpect(status().isOk()) + .andExpect(content().string(not(containsString("nav__user")))); + } } From 56697223ee9e16d311b7dcb6efa7dedb15f68dda Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Fri, 31 Jul 2026 23:05:32 +0200 Subject: [PATCH 3/3] fix(frontend): Separate the account group from the nav and wrap on mobile Review feedback on the first header username implementation (issue reopened): the username read as just another nav item, and the header broke on mobile, where the Log out button was pushed off-screen once logged in. Root cause: the nav list never wrapped (flex default), and the 12rem username item made the authenticated row overflow. The username and the Log out form move out of the nav into a site-header__account group behind a border divider, so assistive tech browsing the Main landmark only meets real links and the pair wraps as one unit on narrow viewports. The nav list now wraps (this also hardens the anonymous header), and the username width caps at 40vw under the 46rem breakpoint on top of the 12rem desktop cap. The plan document records the amendment, and the MockMvc assertions follow the new markup. Refs #126 --- docs/plans/2026-07-28-header-username.md | 7 ++++++ src/main/resources/static/css/base.css | 23 +++++++++++++---- .../resources/templates/fragments/layout.html | 25 ++++++++++--------- .../learndev/auth/AuthFlowTest.java | 8 +++--- 4 files changed, 43 insertions(+), 20 deletions(-) diff --git a/docs/plans/2026-07-28-header-username.md b/docs/plans/2026-07-28-header-username.md index 45f1c04..357b55f 100644 --- a/docs/plans/2026-07-28-header-username.md +++ b/docs/plans/2026-07-28-header-username.md @@ -24,6 +24,13 @@ (the `.visually-hidden` utility already exists in `base.css`; screen readers get the sentence, sighted users just see the name) - `base.css`: `.nav__user` as non-interactive chrome (`color: var(--color-text-muted)`, `font-size: var(--font-size-sm)`); `.nav__user-name` with `max-width: 12rem`, `overflow: hidden`, `text-overflow: ellipsis`, `white-space: nowrap`, `display: inline-block` so a 50-character username (the database cap) truncates instead of wrapping the header - MockMvc assertions: an authenticated page renders "Signed in as" plus the principal's username in the header; an anonymous page contains no `nav__user` +- [ ] `fix(frontend): Separate the account group from the nav and wrap on mobile` + (review feedback, issue #126 reopened: the username read as just another + nav item, and `.nav__list` never wrapped, so the 12rem username pushed + the Log out button off-screen on narrow viewports; the username and Log + out move out of the nav into `div.site-header__account` behind a border + divider, `.nav__list` gains `flex-wrap: wrap`, and the username width + caps at `40vw` under the 46rem breakpoint) ## Verification diff --git a/src/main/resources/static/css/base.css b/src/main/resources/static/css/base.css index e04b1fb..38ad515 100644 --- a/src/main/resources/static/css/base.css +++ b/src/main/resources/static/css/base.css @@ -187,6 +187,9 @@ main:focus { .nav__list { list-style: none; display: flex; + /* Without wrap, a crowded row pushes trailing items (the Log out + button, when it lived here) off-screen on narrow viewports. */ + flex-wrap: wrap; gap: var(--space-3); margin: 0; padding: 0; @@ -211,22 +214,32 @@ main:focus { box-shadow: inset 0 -2px 0 var(--color-primary); } -/* The signed-in username: non-interactive chrome, deliberately muted so - it does not compete with the nav links. */ -.nav__user { +/* The account group: status and exit, deliberately outside the nav. The + divider separates "where you can go" from "who you are". */ +.site-header__account { + display: flex; + align-items: center; + gap: var(--space-3); + padding-left: var(--space-3); + border-left: 1px solid var(--color-border); color: var(--color-text-muted); font-size: var(--font-size-sm); } /* Usernames are user-typed (50 chars max in the schema): truncate rather than let a long one wrap or stretch the header. */ -.nav__user-name { +.site-header__account-name { display: inline-block; max-width: 12rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - vertical-align: bottom; +} + +@media (max-width: 46rem) { + .site-header__account-name { + max-width: 40vw; + } } /* ---------- Layout ---------- */ diff --git a/src/main/resources/templates/fragments/layout.html b/src/main/resources/templates/fragments/layout.html index ae3e739..055e4f0 100644 --- a/src/main/resources/templates/fragments/layout.html +++ b/src/main/resources/templates/fragments/layout.html @@ -62,20 +62,21 @@
  • Admin
  • - - -
  • - - -
  • -
  • + + diff --git a/src/test/java/com/ericbouchut/learndev/auth/AuthFlowTest.java b/src/test/java/com/ericbouchut/learndev/auth/AuthFlowTest.java index c16ba92..0a5da4b 100644 --- a/src/test/java/com/ericbouchut/learndev/auth/AuthFlowTest.java +++ b/src/test/java/com/ericbouchut/learndev/auth/AuthFlowTest.java @@ -94,18 +94,20 @@ void register_form_renders_and_shows_field_errors() throws Exception { /** * The header identifies the signed-in account on every page: sighted - * users see the username, screen readers hear "Signed in as [name]" - * (issue #126). Anonymous visitors get no such item. + * users see the username in the account group next to Log out, screen + * readers hear "Signed in as [name]" (issue #126). Anonymous visitors + * get no account group at all. */ @Test void header_shows_the_signed_in_username() throws Exception { mvc.perform(get("/").with(user("carol-header").roles("STUDENT"))) .andExpect(status().isOk()) + .andExpect(content().string(containsString("site-header__account"))) .andExpect(content().string(containsString("Signed in as"))) .andExpect(content().string(containsString("carol-header"))); mvc.perform(get("/")) .andExpect(status().isOk()) - .andExpect(content().string(not(containsString("nav__user")))); + .andExpect(content().string(not(containsString("site-header__account")))); } }