feat: add page shell, preferences and translations

Base layout with top bar, role-based menu, profile menu and phone layout; theme, language and German form of address kept in cookies; German formal, German informal and English text; Fluent avatar bundled in the image by a Node build stage; placeholder pages for the Publisher and Admin groups.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3t4WL7bd9bdHHNjEsxzqC
This commit is contained in:
2026-10-17 21:05:40 +02:00
co-authored by Claude Sonnet 5.5
parent 3c69c2a156
commit 0dc079a8ac
34 changed files with 2132 additions and 24 deletions
@@ -0,0 +1,104 @@
---
title: 'Page shell, preferences and translations'
type: 'feature'
ticket: '6'
created: '2026-10-17'
status: 'built'
baseline_revision: '3c69c2a'
route: 'full'
route_source: 'auto'
risk: 'medium'
review: 'quick'
review_source: 'pinned'
context:
- '{project-root}/_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md'
- '{project-root}/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/DESIGN.md'
- '{project-root}/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/EXPERIENCE.md'
---
<frozen-after-approval reason="human-owned intent — do not modify unless human renegotiates">
## Intent
**Problem:** After sign-in the user sees an unstyled English page. Every later epic needs one shared layout, the three per-device preferences and German/English text before it can add a page.
**Approach:** One base layout (top bar, left menu with role-based groups, profile menu, skip link) in plain server-rendered HTML styled with the design tokens, with the Fluent web components used for the interactive controls and bundled into the image. Theme, language and German form of address are stored in cookies and read by the server to render the page. All existing text moves to a German formal, German informal and English catalogue.
## Boundaries & Constraints
**Always:** AD-15 and AD-16 apply. No inline script, inline style or inline event handler (CSP stays `script-src 'self'`); no network request for fonts, icons or scripts at runtime. The theme is an attribute on `<html>`; `system` is resolved by CSS (`prefers-color-scheme`) only. Preferences are never sent to the server except as cookies. Every user-visible string goes through translation. The menu is declared in one place with the role each entry needs; a hidden entry is also refused by its view (decorators from 1.5). Touch targets are at least 44 px; the break between wide and narrow layout is 720 px. The page works for navigation and sign-out without JavaScript.
**Never:** No dashboard content (account card, announcements): that is the account epic. No Admin colour or background settings: the accent colour is a CSS variable with the default `#0F6CBD`, set by a later entry. No server-stored preferences. No Node.js or npm in the runtime image. No CDN.
## I/O & Edge-Case Matrix
| Scenario | Input / State | Expected Output / Behavior | Error Handling |
|----------|--------------|---------------------------|----------------|
| Shell after sign-in | Signed-in user | Top bar with portal name and avatar (initials), left menu, content; their display name is shown in the profile menu | n/a |
| Menu by role | User / Publisher / Admin | User: Startseite. Publisher adds group "Veröffentlichen". Admin adds group "Administration". A Publisher who is not Admin sees no Administration group | A direct address to a hidden page shows "Dazu fehlt Ihnen die Berechtigung." (403) |
| First visit | No preference cookies | Theme System, language from `Accept-Language` (de or en, else en), German form formal | n/a |
| Choose preference | Profile menu | Cookie set, page re-renders with the new value; with JavaScript it applies at once without reloading | An unknown value is ignored, the cookie is not set |
| Informal form | Language English, form "du" | Form has no effect on English and the control is hidden | n/a |
| Informal German | Language German, form "du" | Strings use "du" forms; a string with no informal entry falls back to the formal one | n/a |
| Unsafe return address | `next` outside the site | Ignored; the user goes to `/` | n/a |
| Phone width | 360 px | Menu behind a menu button, one column, no horizontal scroll | n/a |
| Signed-out and error pages | Not signed in | Same layout without menu and avatar; preferences still readable from the cookies | n/a |
</frozen-after-approval>
## Code Map
- `portal/templates/base.html` -- new shared layout; `accounts/base.html` becomes a thin extension of it. All 1.5 pages move onto it.
- `portal/shell/` -- new app: `menu.py` (declaration of entries: label, URL name, required role, group), `preferences.py` (read and validate the three cookies, cookie names and lifetime), `middleware.py` (activates the language, sets `request.preferences`), `context.py` (context processor: menu filtered by role, preferences), `views.py` (`POST /preferences`), `urls.py`.
- `portal/frontend/` -- new: `package.json`, `package-lock.json`, an entry file that imports only the Fluent components used, and a build script.
- `portal/static/portal/` -- `shell.css` (tokens from DESIGN.md, light and dark, phone layout), `shell.js` (menu button, profile menu, apply preference at once; no inline code).
- `portal/locale/{de,de_DU,en}/LC_MESSAGES/django.po` -- catalogues; `de_DU` holds only the informal differences and falls back to `de`.
- `Dockerfile` -- a `frontend` build stage on `node:*-slim` runs `npm ci` and the bundle; the result is copied into `portal/static/vendor/` before `collectstatic`. Message catalogues are compiled in the builder stage.
- `portal/config/settings.py` -- add the app, middleware and context processor, `LANGUAGES` with the informal variant.
- `portal/config/middleware.py` -- CSP gains `style-src 'self'` and `img-src 'self' data:` only if the Fluent bundle needs them (decided at build; no `unsafe-inline`).
- Placeholder pages for the Publisher and Admin groups (one view each, decorated with `publisher_required` / `admin_required`, text "Diese Seite folgt.") so role visibility and the direct-address refusal can be checked now; later epics replace them.
- `portal/tests/` -- `test_preferences.py`, `test_shell.py`, `test_translations.py`.
## Tasks & Acceptance
**Execution:**
- [ ] `portal/frontend/` and Dockerfile stage -- build the Fluent bundle into the image, pinned by lockfile -- AD-16
- [ ] `portal/shell/preferences.py`, `middleware.py` -- cookies in, validated preferences out, language activated per request -- AD-15
- [ ] `portal/templates/base.html`, `shell.css`, `shell.js` -- layout, themes, profile menu, phone menu, skip link
- [ ] `portal/shell/menu.py`, `context.py` -- role-filtered menu and active-item marker
- [ ] `portal/locale/` -- German formal, German informal and English text for every existing string; compile step in the Docker build
- [ ] Move the 1.5 pages onto the shell; placeholder Publisher and Admin pages
- [ ] Tests for every matrix row
**Acceptance Criteria:**
- Given a signed-in user, when the home page loads, then the top bar, menu and their display name are in the page and no script, style or event-handler attribute is inline.
- Given a User, a Publisher and an Admin, when the menu renders, then it shows Startseite, plus "Veröffentlichen" only for Publishers and "Administration" only for Admins, and a direct request to a hidden page returns 403.
- Given cookies theme=dark, lang=de, form=du, when a page renders, then `<html>` carries the dark theme and the page text uses the informal form; with lang=en it is English and ignores the form.
- Given no cookies and `Accept-Language: de-DE`, when a page renders, then it is German formal and the theme is System.
- Given an invalid preference value, when it is posted, then no cookie changes.
- Given every `{% trans %}` and `gettext` string in the code, when the catalogue test runs, then each has a German entry and no entry is fuzzy or empty.
- Given a viewport of 360 px, when the page loads, then the menu is behind a button and there is no horizontal scrolling (checked by hand at the done step; the CSS rule is checked by test).
## Design Notes
Preferences are cookies named `pm_theme`, `pm_lang`, `pm_form`, `SameSite=Lax`, `Secure`, one year, not `HttpOnly` so `shell.js` can apply a change without a reload; they hold no personal data. The informal German register is a language code `de-du` whose catalogue holds only differing strings; Django's gettext lookup falls back to `de`, which falls back to `msgid`. If that fallback does not behave as expected in the build, the plan changes to a template filter, not to duplicated catalogues.
`msgfmt` and `xgettext` (GNU gettext) are not available on the Windows development machine, so strings are added to the catalogues by hand and a test (using `polib`) keeps them complete; the compile step runs in the Docker builder stage, which installs `gettext`. Message extraction with `makemessages` can be run in a container later when the string count grows.
Fluent web components render inside shadow DOM; if a component needs inline styles that the CSP refuses, the control falls back to plain HTML and CSS rather than loosening the policy.
## Verification
**Commands:**
- `uv run pytest` -- expected: all tests pass, including the new ones
- `uv run ruff check portal manage.py docker` -- expected: no findings
- `uv run python manage.py makemigrations --check --dry-run` -- expected: no changes detected
- `docker compose up --build` on the Linux host -- expected: image builds with the front-end stage; pages load with no CSP violations in the browser console
**Manual:** sign in as `ptest-user1`, `ptest-publisher`, `ptest-admin`; check the menu groups, change the three preferences and reload, narrow the window to phone width, and open `/` in a private window to see the defaults.
## Implementation Notes
- Built inline; `pytest` 247 passed, 6 skipped (live LDAP), `ruff check` clean, `makemigrations --check` no changes.
- Judgement calls: the Fluent bundle contains only the avatar (the one control the shell needs that works with a fallback); buttons and menus are native HTML so the shell works without JavaScript. The profile menu is a `<details>` element. Theme applies at once; language and form reload the page because the server renders the text. `LocaleMiddleware` is replaced by `PreferencesMiddleware`. The `de_DU` catalogue fell back to `de` as hoped (tested). No `locale/en` catalogue: the message ids are English. Placeholder pages live at `/publish/announcements` and `/administration/{appearance,settings,audit,status}`.
- Not verified: the Docker build (Node stage, `compilemessages`) and the visual result in a browser, including the Fluent avatar tokens and the phone layout, since neither runs on the Windows machine.