Rebuild the masthead on the component layer: an avatar-only account button, a + menu, and a main navigation made for a phone #282

Closed
opened 2026-09-19 09:53:17 +00:00 by tiagoagueda · 1 comment
Owner

The masthead is the one element on every page, and it is the oldest thing in the interface: a flex row that has had a control added to it each time one was needed — the wordmark, seven links, a <details> that hides them under Menu below 768 pixels, a search box, an account disclosure with a name in it. #262 and #263 since gave the project a component layer (Basecoat structure, Postulo paint, composed with django-cotton), and the header has had none of it. This is the rebuild.

Four things, in the order they were asked for:

  1. The profile button is the avatar, and nothing else.
  2. The profile menu says who you are — Signed in as …, or the name on its own — since the trigger no longer does.
  3. A + menu beside it, to start a listing, a document, a company or an event from anywhere.
  4. The main navigation rethought: fluid, readable, customisable, and built for a phone rather than folded onto one.

What the masthead is today

src/postulo/templates/base.html:94-199, one <header class="sticky top-0 z-30"> holding a single flex flex-wrap row:

  • Wordmark (:105-113) — logo and instance name; takes over as the dashboard link, with the active style and its own accessible name, when Dashboard is hidden (#23).
  • The navigation, written twice — partials/nav_links.html rendered into a <details class="md:hidden"> panel (:129-138) and again into a <nav class="hidden md:flex"> (:139-141). Items come from core/navigation.py:ITEMS, which is seven of them; the comment at :116 records that six already measured 635 pixels and made every page in Postulo scroll sideways at 320 — an AA Reflow failure, fixed by the disclosure (#113).
  • Search (:145-152) — class="hidden md:block". There is no search on a phone at all, which is worth stating plainly because it is invisible in the markup's intent.
  • Account menu (:158-192) — a <details class="popover" data-menu> whose <summary> is {% avatar user %} + {{ user.display_name }} (hidden sm:inline) + a chevron. Inside: Your details, Settings, Server settings for staff, a separator, the theme switch as a menu row (#197), and Sign out.

Deliberately not role="menu": the comment at :153 records the reasoning — this is navigation, so it is a disclosure where Tab moves between rows; the people table's row actions are genuine actions and get the menu role (#262). Whatever replaces it keeps that distinction.

1. The avatar as the button

{% avatar user %} is core/templatetags/postulo.py:206. It draws the picture, or an initials tile coloured by a stable hash of the display name.

It is decorative on purpose — alt="" on the picture, aria-hidden="true" on the tile — and the docstring gives the reason: "it always stands beside the person's name." Taking the name out of the trigger makes that sentence false. Two things follow, and neither is optional:

  • The <summary> already carries aria-label="Account menu, {{ name }}" (:160), so the control stays named. That label becomes the only name, so it is now load-bearing, and the tag's docstring should stop claiming a name is always beside it.
  • The target shrinks. The avatar is size-7 — 28 pixels. On its own that clears WCAG 2.2 AA's 24 (tests/e2e/test_target_size.py, measured not read) with four pixels to spare, and is nowhere near the 44 that is comfortable for a thumb. It needs padding around it, not a bigger avatar.
  • The chevron: with the name gone, decide whether it stays. It is the only thing saying "this opens something".

2. The name inside the menu

A header row at the top of the panel — avatar, display name, and the email underneath it if it earns the space. Signed in as … reads as a security statement and is the right phrasing on a multi-person instance (#268 is the issue for those); the name alone is quieter. Either is fine; it should be decided once and written down, not left to the template.

Not a menuitem — it is a label, not a destination, unless it becomes the Your details link with the name as its text.

3. The + menu

Four things were asked for. Three are a link to a page that exists; one is not, and it is the reason this section needs a decision before any markup:

Asked for Route today
A listing listings:create — and jobs:capture_create, which is how a listing usually starts (paste a URL)
A document Three routes: documents:cv_create, documents:letter_create, documents:upload_create
A company jobs:company_create
An event applications:event_create takes an application's pk. An event belongs to an application; there is no route that makes one from nowhere.
  • The event is the real question. Either the menu offers Reminder instead (applications:reminder_create is global and is the thing somebody actually wants from a masthead), or an event gains a standalone create flow whose first field picks the application. The second is a feature, not a menu entry, and would be better as its own issue than smuggled into this one. Recommendation: ship Reminder, and open the standalone-event question separately. #238 (deadlines and reminders) is adjacent.
  • A document is three things. A submenu is a poor fit for a disclosure that must work with no script. Three rows — CV, Cover letter, Upload a file — under a group heading is simpler and reads better; it also matches what the Documents section actually contains.
  • An application is the obvious fifth (applications:create) and was not in the list. Worth deciding rather than omitting silently.
  • Use <c-dropdown-menu> (templates/cotton/dropdown_menu.html), which is exactly this: <details>, Basecoat's popover box, role="menu" with menuitem rows, and the arrow-key handling app.js:911-950 already adds. These are actions, so the menu role is right here — unlike the account disclosure beside it.
  • It needs an icon. static/icons/ has plus.

4. The main navigation

The present design is one row, doubled, with a breakpoint switching which copy is in the layout. It works and it is dull, and the three adjectives asked for point at different things:

  • Fluid / readable. Seven items at 635 pixels means the row is one label away from wrapping in English and already wraps in German, Greek and Portuguese — the header's own comment notes its height is not a constant for exactly this reason, which is why app.js:757 measures it into --header-height for everything that has to clear it. Options worth weighing: icons beside labels so the row survives translation; an overflow More menu once the row runs out; grouping seven items into four (Search / Pipeline / Documents / Records). Whatever is chosen, --header-height must keep working — the sticky sidebars, the skip link and scroll-padding-top all read it.
  • Customisable. This already half-exists and should be built on, not replaced: hidden_nav_items on the profile, navigation.HIDEABLE, and the switches in AppearanceForm (accounts/forms.py:424). What is missing is order — a person can hide Calendar but cannot put Applications first. If reordering goes in, the stored shape changes from a list of hidden keys to something that also carries position, and navigation.py's docstring explains why hidden-not-shown was chosen (a new item in a later release must appear for everybody); the same care applies to whatever replaces it. Note that #281 proposes an Accessibility settings section, which may move where these switches live.
  • Mobile. Menu opening a panel of seven stacked links is functional and is not a phone navigation. A bottom bar of four with the rest behind More is what a phone user expects; a drawer is the other answer. This is the part to prototype rather than specify, and it is the reason this issue is a revamp and not a tidy-up.

What the component layer actually offers, and its two hard limits

basecoat-css@1.0.2 ships 39 component stylesheets. Three are imported (assets/css/app.css:24-26): button, popover, dropdown-menu. Unimported and relevant here: avatar, sidebar, drawer, command, dialog, tooltip, button-group, item, badge.

Before anyone reaches for them:

  • Basecoat's JavaScript is not vendored, and that is a rule, not an omission. cotton/dropdown_menu.html states it: Basecoat's own dropdown is a <button> plus a panel its script reveals, "and a menu somebody cannot open with scripts off is not one this project ships" — hence <details>/<summary>, with app.js adding only what the platform lacks. drawer.js, sidebar.js and command.js are all script-first. Their stylesheets may still be usable; their behaviour has to be rebuilt on a disclosure, or left out.
  • tests/test_stylesheet.py refuses any Basecoat import whose class names app.css also defines. The bundle would bring a .card and an .alert under names this project owns, and the cascade — not the author — would decide which won. Any new import is a deliberate, tested decision.

A command palette (command.css) is tempting for "fluid", and would answer the missing phone search besides. It is also the single most script-dependent thing in the list. If it appears, it appears on top of a search box that works without it, never instead of one.

Boundary with #73

#73 (Readable and usable on a phone, 0.6.0) owns the page bodies: tables, the board, touch targets, form inputmode, the dashboard, and adding a phone viewport to the browser suite. It takes the navigation as given — "the navigation collapses" is listed there as an existing fact.

This issue owns the masthead: the account control, the + menu, and what the main navigation is. The two meet at the phone viewport #73 adds to the suite, which is what would hold this work in place afterwards. Neither blocks the other; doing this one first means #73's phone pass is not fighting a header it also wants to change.

What will constrain the work

Four browser tests already fence this area, and a revamp has to keep them green rather than edit them into agreement:

  • tests/e2e/test_reflow.py::test_the_navigation_becomes_a_menu_and_still_works — 320 pixels, and the reason the disclosure exists at all.
  • tests/e2e/test_target_size.py — 24×24 measured, with the spacing exception; the avatar-only trigger meets it by four pixels.
  • tests/e2e/test_sticky_header.py — the header is still there after scrolling and an anchor clears it.
  • tests/e2e/test_keyboard_and_focus.py, test_people_menu.py — Escape, arrow keys, and focus not being lost when a menu closes (#227).

Also: the browser suite reads the live tree, so no template or stylesheet edit while it runs; npm run build:css whenever the templates gain classes, since the compiled CSS is committed and CI checks it; and three catalogues (fr-fr, pt-pt, pt-br) filled draft for every new string, the other 36 at the release sweep.

Suggested order

The four parts are independent and the first three are small. If this is split, 1 + 2 + 3 land together as one commit (they are one control and the button beside it) and 4 is its own issue once its shape has been prototyped — it is the only part where the right answer is not already visible from the code.

The masthead is the one element on every page, and it is the oldest thing in the interface: a flex row that has had a control added to it each time one was needed — the wordmark, seven links, a `<details>` that hides them under *Menu* below 768 pixels, a search box, an account disclosure with a name in it. #262 and #263 since gave the project a component layer (Basecoat structure, Postulo paint, composed with `django-cotton`), and the header has had none of it. This is the rebuild. Four things, in the order they were asked for: 1. **The profile button is the avatar, and nothing else.** 2. **The profile menu says who you are** — *Signed in as …*, or the name on its own — since the trigger no longer does. 3. **A `+` menu beside it**, to start a listing, a document, a company or an event from anywhere. 4. **The main navigation rethought**: fluid, readable, customisable, and built for a phone rather than folded onto one. ## What the masthead is today `src/postulo/templates/base.html:94-199`, one `<header class="sticky top-0 z-30">` holding a single `flex flex-wrap` row: - **Wordmark** (`:105-113`) — logo and instance name; takes over as the dashboard link, with the active style and its own accessible name, when *Dashboard* is hidden (#23). - **The navigation, written twice** — `partials/nav_links.html` rendered into a `<details class="md:hidden">` panel (`:129-138`) and again into a `<nav class="hidden md:flex">` (`:139-141`). Items come from `core/navigation.py:ITEMS`, which is **seven** of them; the comment at `:116` records that six already measured 635 pixels and made every page in Postulo scroll sideways at 320 — an AA Reflow failure, fixed by the disclosure (#113). - **Search** (`:145-152`) — `class="hidden md:block"`. **There is no search on a phone at all**, which is worth stating plainly because it is invisible in the markup's intent. - **Account menu** (`:158-192`) — a `<details class="popover" data-menu>` whose `<summary>` is `{% avatar user %}` + `{{ user.display_name }}` (`hidden sm:inline`) + a chevron. Inside: *Your details*, *Settings*, *Server settings* for staff, a separator, the theme switch as a menu row (#197), and *Sign out*. Deliberately **not** `role="menu"`: the comment at `:153` records the reasoning — this is navigation, so it is a disclosure where Tab moves between rows; the people table's row actions are genuine actions and get the menu role (#262). Whatever replaces it keeps that distinction. ## 1. The avatar as the button `{% avatar user %}` is `core/templatetags/postulo.py:206`. It draws the picture, or an initials tile coloured by a stable hash of the display name. **It is decorative on purpose** — `alt=""` on the picture, `aria-hidden="true"` on the tile — and the docstring gives the reason: *"it always stands beside the person's name."* Taking the name out of the trigger makes that sentence false. Two things follow, and neither is optional: - The `<summary>` already carries `aria-label="Account menu, {{ name }}"` (`:160`), so the control stays named. That label becomes the *only* name, so it is now load-bearing, and the tag's docstring should stop claiming a name is always beside it. - **The target shrinks.** The avatar is `size-7` — 28 pixels. On its own that clears WCAG 2.2 AA's 24 (`tests/e2e/test_target_size.py`, measured not read) with four pixels to spare, and is nowhere near the 44 that is comfortable for a thumb. It needs padding around it, not a bigger avatar. - The chevron: with the name gone, decide whether it stays. It is the only thing saying "this opens something". ## 2. The name inside the menu A header row at the top of the panel — avatar, display name, and the email underneath it if it earns the space. *Signed in as …* reads as a security statement and is the right phrasing on a multi-person instance (#268 is the issue for those); the name alone is quieter. Either is fine; it should be decided once and written down, not left to the template. Not a `menuitem` — it is a label, not a destination, unless it becomes the *Your details* link with the name as its text. ## 3. The `+` menu Four things were asked for. Three are a link to a page that exists; **one is not**, and it is the reason this section needs a decision before any markup: | Asked for | Route today | |---|---| | A listing | `listings:create` — and `jobs:capture_create`, which is how a listing usually starts (paste a URL) | | A document | **Three routes**: `documents:cv_create`, `documents:letter_create`, `documents:upload_create` | | A company | `jobs:company_create` | | **An event** | **`applications:event_create` takes an application's pk.** An event belongs to an application; there is no route that makes one from nowhere. | - **The event is the real question.** Either the menu offers *Reminder* instead (`applications:reminder_create` is global and is the thing somebody actually wants from a masthead), or an event gains a standalone create flow whose first field picks the application. The second is a feature, not a menu entry, and would be better as its own issue than smuggled into this one. Recommendation: ship *Reminder*, and open the standalone-event question separately. #238 (deadlines and reminders) is adjacent. - **A document is three things.** A submenu is a poor fit for a disclosure that must work with no script. Three rows — *CV*, *Cover letter*, *Upload a file* — under a group heading is simpler and reads better; it also matches what the Documents section actually contains. - **An application** is the obvious fifth (`applications:create`) and was not in the list. Worth deciding rather than omitting silently. - Use `<c-dropdown-menu>` (`templates/cotton/dropdown_menu.html`), which is exactly this: `<details>`, Basecoat's popover box, `role="menu"` with `menuitem` rows, and the arrow-key handling `app.js:911-950` already adds. These *are* actions, so the menu role is right here — unlike the account disclosure beside it. - It needs an icon. `static/icons/` has `plus`. ## 4. The main navigation The present design is one row, doubled, with a breakpoint switching which copy is in the layout. It works and it is dull, and the three adjectives asked for point at different things: - **Fluid / readable.** Seven items at 635 pixels means the row is one label away from wrapping in English and already wraps in German, Greek and Portuguese — the header's own comment notes its height is not a constant for exactly this reason, which is why `app.js:757` measures it into `--header-height` for everything that has to clear it. Options worth weighing: icons beside labels so the row survives translation; an overflow *More* menu once the row runs out; grouping seven items into four (Search / Pipeline / Documents / Records). Whatever is chosen, **`--header-height` must keep working** — the sticky sidebars, the skip link and `scroll-padding-top` all read it. - **Customisable.** This already half-exists and should be built on, not replaced: `hidden_nav_items` on the profile, `navigation.HIDEABLE`, and the switches in `AppearanceForm` (`accounts/forms.py:424`). What is missing is **order** — a person can hide *Calendar* but cannot put *Applications* first. If reordering goes in, the stored shape changes from a list of hidden keys to something that also carries position, and `navigation.py`'s docstring explains why hidden-not-shown was chosen (a new item in a later release must appear for everybody); the same care applies to whatever replaces it. Note that #281 proposes an *Accessibility* settings section, which may move where these switches live. - **Mobile.** *Menu* opening a panel of seven stacked links is functional and is not a phone navigation. A bottom bar of four with the rest behind *More* is what a phone user expects; a drawer is the other answer. **This is the part to prototype rather than specify**, and it is the reason this issue is a revamp and not a tidy-up. ## What the component layer actually offers, and its two hard limits `basecoat-css@1.0.2` ships 39 component stylesheets. **Three** are imported (`assets/css/app.css:24-26`): `button`, `popover`, `dropdown-menu`. Unimported and relevant here: `avatar`, `sidebar`, `drawer`, `command`, `dialog`, `tooltip`, `button-group`, `item`, `badge`. Before anyone reaches for them: - **Basecoat's JavaScript is not vendored, and that is a rule, not an omission.** `cotton/dropdown_menu.html` states it: Basecoat's own dropdown is a `<button>` plus a panel its script reveals, *"and a menu somebody cannot open with scripts off is not one this project ships"* — hence `<details>`/`<summary>`, with `app.js` adding only what the platform lacks. `drawer.js`, `sidebar.js` and `command.js` are all script-first. Their **stylesheets** may still be usable; their behaviour has to be rebuilt on a disclosure, or left out. - **`tests/test_stylesheet.py` refuses any Basecoat import whose class names `app.css` also defines.** The bundle would bring a `.card` and an `.alert` under names this project owns, and the cascade — not the author — would decide which won. Any new import is a deliberate, tested decision. A command palette (`command.css`) is tempting for "fluid", and would answer the missing phone search besides. It is also the single most script-dependent thing in the list. If it appears, it appears *on top of* a search box that works without it, never instead of one. ## Boundary with #73 **#73** (*Readable and usable on a phone*, 0.6.0) owns the page bodies: tables, the board, touch targets, form `inputmode`, the dashboard, and adding a phone viewport to the browser suite. It takes the navigation as given — *"the navigation collapses"* is listed there as an existing fact. **This issue owns the masthead**: the account control, the `+` menu, and what the main navigation *is*. The two meet at the phone viewport #73 adds to the suite, which is what would hold this work in place afterwards. Neither blocks the other; doing this one first means #73's phone pass is not fighting a header it also wants to change. ## What will constrain the work Four browser tests already fence this area, and a revamp has to keep them green rather than edit them into agreement: - `tests/e2e/test_reflow.py::test_the_navigation_becomes_a_menu_and_still_works` — 320 pixels, and the reason the disclosure exists at all. - `tests/e2e/test_target_size.py` — 24×24 measured, with the spacing exception; the avatar-only trigger meets it by four pixels. - `tests/e2e/test_sticky_header.py` — the header is still there after scrolling and an anchor clears it. - `tests/e2e/test_keyboard_and_focus.py`, `test_people_menu.py` — Escape, arrow keys, and focus not being lost when a menu closes (#227). Also: the browser suite reads the live tree, so no template or stylesheet edit while it runs; `npm run build:css` whenever the templates gain classes, since the compiled CSS is committed and CI checks it; and three catalogues (`fr-fr`, `pt-pt`, `pt-br`) filled `draft` for every new string, the other 36 at the release sweep. ## Suggested order The four parts are independent and the first three are small. If this is split, **1 + 2 + 3 land together** as one commit (they are one control and the button beside it) and **4 is its own issue** once its shape has been prototyped — it is the only part where the right answer is not already visible from the code.
tiagoagueda added this to the 0.5.0 milestone 2026-09-19 09:53:17 +00:00
Author
Owner

Parts 1–3 are in (01d799c80); part 4 is spun out as #299, per the suggested order —
it is the one part whose right answer is not visible from the code, and
test_reflow.py::test_the_navigation_becomes_a_menu_and_still_works pins the current
row-plus-disclosure that this issue says to keep green rather than edit into agreement.

1. The profile button is the avatar and nothing else. The <summary> drops the name
span and the chevron's job is noted in the template; the aria-label ("Account menu, …")
is now the control's only name, so it stays load-bearing. The target grows from 28 to a
44-pixel box with p-2 around the unchanged size-7 avatar — padding, not a bigger
avatar — which clears the 24 measured in test_target_size.py with room for a thumb.

2. The name inside the menu. A header row at the top of the panel: avatar, display
name, and the email under it, truncating inside the popover's 224px. The decision the
issue asked for is made once and written down in the template: the name on its own, not
Signed in as … (quieter; the security-statement form is not needed on a
single-person instance). It is a label, not a destination — Your details below remains
the link.

3. A + menu beside it. <c-dropdown-menu> with a plus trigger: Listing (the
capture, which is how a listing usually starts), Application, Company, Reminder,
then a Documents group of CV / Cover letter / Upload a file. Reminder is in for
event (an event has no route that makes one from nowhere); Application is the
obvious fifth, included rather than omitted silently. The group is a role="group"
label, not a heading: a heading in the masthead would land in the document before every
page's <h1>, and the pages are held to one-h1-first. All seven destination routes
already existed; the one new string (Listing, singular) is filled draft in
fr-fr / pt-pt / pt-br.

Part 4 — the main navigation rethought for a phone — is #299 now, per the suggested
order: it is the one part whose right answer is not visible from the code, and
test_reflow.py::test_the_navigation_becomes_a_menu_and_still_works pins the current
row-plus-disclosure, so that work rewrites that fence with intent. The standalone-event
question is parked there as well.

Kept green without editing them: the reflow, target-size, sticky-header,
keyboard-and-focus and people-menu browser tests. tests/test_header.py was updated to
hold the new shape (the + menu's routes, and the name being in the menu rather than the
trigger).

Parts 1–3 are in (`01d799c80`); part 4 is spun out as #299, per the suggested order — it is the one part whose right answer is not visible from the code, and `test_reflow.py::test_the_navigation_becomes_a_menu_and_still_works` pins the current row-plus-disclosure that this issue says to keep green rather than edit into agreement. **1. The profile button is the avatar and nothing else.** The `<summary>` drops the name span and the chevron's job is noted in the template; the `aria-label` ("Account menu, …") is now the control's only name, so it stays load-bearing. The target grows from 28 to a 44-pixel box with `p-2` around the unchanged `size-7` avatar — padding, not a bigger avatar — which clears the 24 measured in `test_target_size.py` with room for a thumb. **2. The name inside the menu.** A header row at the top of the panel: avatar, display name, and the email under it, truncating inside the popover's 224px. The decision the issue asked for is made once and written down in the template: the name on its own, not *Signed in as …* (quieter; the security-statement form is not needed on a single-person instance). It is a label, not a destination — *Your details* below remains the link. **3. A + menu beside it.** `<c-dropdown-menu>` with a `plus` trigger: *Listing* (the capture, which is how a listing usually starts), *Application*, *Company*, *Reminder*, then a *Documents* group of *CV* / *Cover letter* / *Upload a file*. *Reminder* is in for *event* (an event has no route that makes one from nowhere); *Application* is the obvious fifth, included rather than omitted silently. The group is a `role="group"` label, not a heading: a heading in the masthead would land in the document before every page's `<h1>`, and the pages are held to one-h1-first. All seven destination routes already existed; the one new string (*Listing*, singular) is filled `draft` in fr-fr / pt-pt / pt-br. **Part 4** — the main navigation rethought for a phone — is #299 now, per the suggested order: it is the one part whose right answer is not visible from the code, and `test_reflow.py::test_the_navigation_becomes_a_menu_and_still_works` pins the current row-plus-disclosure, so that work rewrites that fence with intent. The standalone-event question is parked there as well. Kept green without editing them: the reflow, target-size, sticky-header, keyboard-and-focus and people-menu browser tests. `tests/test_header.py` was updated to hold the new shape (the + menu's routes, and the name being in the menu rather than the trigger).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Postulo/postulo#282
No description provided.