@micha.bigler/ui-core-micha 2.25.0 → 2.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/WORK_ORDERS.md CHANGED
@@ -41,4 +41,6 @@ prefixed ID; never reuse a bare flat number across workstreams.
41
41
  | MSG-6f | `titleOf` never calls the host resolver for a broadcast; the read ticks claim a delivery that does not exist | (A) `ConversationList.jsx` `titleOf()` gates the host callback on `conversation.kind === 'managed'`, so jg-ferien MSG-10's correct broadcast branch in `resolveManagedConversationLabel` is **unreachable from the list** — the row still falls through to `MessagingList.UNTITLED`. (B) dcm MSG-7 removed `delivered_count` from `read-status`, but `ReadTicks.jsx` still renders `t('MessagingReadTicks.DELIVERED', { count: renderedStatus.delivered_count \|\| 0 })`, so `undefined \|\| 0` produces the identical "Zugestellt an 0" label as before — a claimed delivery with no data source behind it. (C) a viewer already sitting in an open thread when a new message lands is recorded as "not read" until they navigate away and back — the mount-effect only re-marks once per open. | 2026-08-03 | done | 61e13b0 | Envelope: [work-orders/MSG-6f.md](work-orders/MSG-6f.md); all three scopes released together per operator's critical-path directive (one release instead of two, since A and C never depended on dcm) — this also stands as the operator confirmation the envelope asked for before implementing B's vocabulary change. **A:** kind gate removed entirely from `titleOf()`; a host resolver returning `null` already means "no label", so gating duplicated a decision that belongs to the host. **B:** `DELIVERED` retired; dcm MSG-9 (published 2.40.0) added `read_count`/`recipient_count` to `read-status`, gated the same way as `recipient_detail` (non-direct, `read_receipt_detail`-eligible). `ReadTicks.jsx` now renders: counts present → a read ratio (`READ_RATIO`, e.g. "18/40"), no counts + `direct` → two-state tick (`SENT`/`ALL_READ`), no counts + non-direct → nothing (was a permanently-grey "always delivered" tick before). **C:** `Thread.jsx` now also subscribes to `MESSAGING_ENVELOPE` directly (independent of `MessagingProvider`'s own subscription — the registry supports multiple handlers per envelope) and re-marks read on a live `message` frame for the open conversation, skipped when the tab is hidden. **Reviewed independently: `reviewer` + `ui_reviewer`, both clean after one fix round.** Fix round 1 closed a **P1** (`reviewer`, self-caught during the fix, not shipped): the ratio was briefly recomputed client-side from `recipientDetail.filter(r => r.last_read_at).length` to make it "live-update" from `read_state` frames — this silently contradicts the WO's explicit instruction and is wrong, because `recipientDetail`'s `last_read_at` is never thresholded against the specific message's `created_at` the way dcm's own aggregate is (`last_read_at__gte=message.created_at`, dcm `services.py`); in a real group with history it would read as close to N/N regardless of who actually saw *this* message. Reverted to trusting dcm's `read_count`/`recipient_count` verbatim, with the trade-off documented in `ReadTicks.jsx`: the ratio no longer live-updates from a `read_state` frame, only on the next read-status fetch (unlike the DM tick and the per-person popover, both viewer/timestamp-relative facts that safely reuse `cache.receipts`). Also fixed a P3 (`ui_reviewer`): the ratio's `color="text.secondary"` diverged from the sibling meta row's `inherit` convention, changed to `inherit`. **Known, disclosed, non-blocking follow-up (`ui_reviewer` P2, not fixed here):** the DM tick's fixed `grey.500`/`primary.main` colour pair is not fully dark-mode contrast-neutral — verified by hand (WCAG-style luminance/contrast math against both themes' actual palette values, not just accepting the reviewer's claim): light-mode state separation ≈1.71:1, dark-mode ≈1.53:1 (worse, since `primary.main` lightens substantially in dark mode while `grey.500` is a fixed, non-mode-aware value). No single grey threads both themes well — the two themes need opposite-direction adjustments relative to their own inverted primary-blue lightness — and the icon shape (`Done` vs `DoneAll`) remains the primary, non-colour signal per the component's own design principle, so this is tracked rather than guessed at further without live visual verification. Test-infrastructure fix alongside C, not a behavioural change: `Thread.jsx`'s new `subscribe()` call broke 3 existing test files' single-captured-handler mock pattern (`frameHandler = handler`, overwritten per call) — converted to Set/Map-based multi-handler dispatch in `messagingChunk6.test.jsx`, `messagingMsg6.test.jsx`, `messagingThreadReceipts.test.jsx`. Full suite 234/234 (was 222), `tsc` clean. Published as npm 2.22.0 (confirmed live on the registry). jg-ferien pin bump to `dcm 2.40.0` / `ucm 2.22.0` done separately (MSG-14).
42
42
  | MSG-6g | Poll UI: tap-to-vote with proportional result bars, no separate vote button | `PollCard.jsx` required select-then-click-"Abstimmen" two-step voting, with results as a bare `(n)` count suffix. Operator request: WhatsApp-style tap-to-vote with proportional bars. | 2026-08-03 | done | ef66eb5 | Envelope: [work-orders/MSG-6g.md](work-orders/MSG-6g.md). UX improvement, blocked nothing, blocked by nothing — except a real blocker found mid-implementation: the WO's own assumption that dcm's vote endpoint treats `option_ids` as a complete authoritative set per call did not hold for multi-select (`vote_poll` only ever added votes, never retracted one for an option missing from a later call — untoggling was silently a no-op). Reported per the WO's own "stop and report" contingency; operator confirmed fixing it directly in dcm rather than working around it client-side — see dcm `work-orders/MSG-11.md`, published as dcm 2.40.1. **Implemented directly by the Orchestrator** (Codex unavailable, out of credits). Single-choice now renders a real `RadioGroup` (native "different option moves the vote, same option is a no-op" semantics, matching `AuthFactorRequirementCard.jsx`'s existing radio convention); multi-select renders `Checkbox`. Each option shows a `LinearProgress` bar + count/percentage (in the accessible name, not width alone); count/percent never `NaN` at zero votes. Every tap is optimistic (`MessagingProvider.castPollVote`, patterned on `toggleReaction`'s immediate-patch/reconcile-or-revert shape) with a per-poll "latest tap wins" token so rapid re-taps race deterministically instead of a stale response clobbering a newer one; a rejected vote visibly reverts. **Independent `reviewer` + `ui_reviewer` (both mandatory per the WO), 5 findings total, all fixed** — see `work-orders/MSG-6g.md`'s as-built record for full detail: `reviewer` R1 (**P1**, real bug) — the optimistic count projection was built from the stale caller-supplied `poll` prop instead of the freshly cache-read `previousPoll`, computing a wrong transient vote count in the shared cache on a second rapid tap; fixed, and verified non-vacuous by reverting the fix and confirming the new regression test fails with the exact predicted wrong number before restoring it. R2 (P2) — no test covered non-zero-count rapid-tap math (zero-vote fixtures elsewhere masked R1 via a `Math.max(0, ...)` clamp); closed with a dedicated regression test. R3 (P3) — a possible `undefined` pushed into an option's `voters` array when `user?.id` is momentarily unset; guarded. `ui_reviewer` U1 (P2) — no visual distinction between an in-flight optimistic vote and a confirmed one; fixed by reusing `ReactionBar.jsx`'s `pending`-Set dimming convention, deliberately *not* disabling the control (the WO requires rapid re-taps to stay possible, not be blocked). U2 (P2) — bare ungrouped `Radio` controls diverged from this repo's own convention and lost native arrow-key/group semantics; fixed with `RadioGroup`, which also let a manual `Box onClick`+`stopPropagation` whole-row-tap hack be removed in favour of `FormControlLabel`'s native label-click — that same change surfaced and fixed an unreported bug (`RadioGroup`'s own `onChange` only ever returns a DOM string via `event.target.value`, silently coercing a non-string option id) caught by an existing numeric-id test fixture. Removed now-orphaned `MessagingPoll.VOTE`/`SELECT_OPTION` i18n keys; added `MessagingPoll.OPTION_RESULT`. Scoped gate: full suite 234 → 241 (7 new tests), `tsc` clean. Published as npm 2.23.0. jg-ferien pin bump is a separate, subsequent step. |
43
43
  | MSG-6h | Attachment gallery (thumbnail/lightbox/right-click download), reaction bar overlap | `AttachmentList.jsx` renders each attachment as a bare download button (36px thumbnail if loaded, plus a text label that always fell back to the raw UUID `id` since dcm never returned a filename). `MessageBubble.jsx`s `ReactionBar` uses a fixed `mt: -0.5` overlap trick against the bubble that now reads as visually broken. | 2026-08-03 | done | 5d6ba6f | Envelope: [work-orders/MSG-6h.md](work-orders/MSG-6h.md). Operator report from a staging screenshot, seen for the first time end-to-end now that `django-core-micha` MSG-10 fixed attachment uploads (previously unconditionally rejected). Companion to dcm `work-orders/MSG-12.md` (published 2.40.2, exposes the real filename this WO consumes). **Implemented directly by the Orchestrator** (Codex unavailable). `AttachmentList.jsx` rewritten: each attachment is a small 64×64 tile (image thumbnail via the existing `getAttachmentThumbnail`, or a file-icon+filename for non-images); click on an image opens a lightbox (full image via `getAttachment`, cached per-attachment so a re-open doesn't re-fetch); right-click opens a context menu with Download, `stopPropagation`'d so it never also opens `MessageBubble`'s own bubble-level menu. `ReactionBar`'s fixed `mt: -0.5` overlap trick (`MessageBubble.jsx:139`) replaced with a small positive `mt: 0.5` gap — the old trick pulled the chip into whatever sat immediately above it in flow, which used to always be plain text but now can be MSG-6g's poll bars or this WO's own bordered attachment tiles. **Independent `reviewer` + `ui_reviewer` (both mandatory per the WO), 9 findings total, all fixed or explicitly resolved/disclosed** — see `work-orders/MSG-6h.md`'s as-built record for full detail. Headline: `reviewer` R1 and `ui_reviewer` U1 independently found the **same real regression** from two angles — the redesign made an image's only download path a mouse right-click (the lightbox had no download control), unreachable by keyboard or touch (no long-press). One fix closed both: the lightbox now carries its own Download button, reachable by click, Tab+Enter, or touch. `reviewer` R2 (P2) — the new lightbox fetch had no loading/error state; added a `loading`/`ready`/`error` status with a spinner and a visible error alert, guarded against a stale in-flight fetch being superseded by a newer open. R3 (P3) — lightbox `Dialog` had no accessible name; added. `ui_reviewer` U2 (P2, resolved not separately fixed) — no visual affordance for right-click discoverability; the R1/U1 fix already makes every attachment fully reachable via primary click, so right-click stays a shortcut, not a required hidden path — a redundant hover-icon on top of that was judged scope creep past the finding's actual concern. U3 (P2) — truncated filenames got a `title` tooltip. U4 (P3) — French `PREVIEW` translation fixed for verb-first parallelism with its siblings. U5 (P3) — lightbox buttons gained the codebase's established `boxShadow` convention for floating icons. U6 (P3, disclosed not fixed) — a residual silent-no-op edge case matching a pre-existing pattern elsewhere in the same component; R2's loading/error state already covers the actually-reachable failure path. **Scope B (reaction spacing) explicitly NOT live-verified** against the operator's original staging screenshot — no staging credentials this round; both reviewers judged the fix low-risk by static reading only. Full suite 241 → 249 (8 new tests), `tsc` clean. Published as npm 2.24.0.
44
- | MSG-6i | Attachment tile 120px, poll spacing "luftiger", poll % denominator = respondents not sum(votes), wider Composer emoji picker | `AttachmentList.jsx` tiles are 64px. `PollCard.jsx` reads cramped per an operator screenshot. Poll percentages currently divide by `sum(vote_count)`, which for multi-select can exceed respondent count. Composer reuses `ReactionBar`s 5-emoji `QUICK_EMOJIS` for free-form text composition. | 2026-08-03 | done | (pending push) | Envelope + Part B as-built: [work-orders/MSG-6i.md](work-orders/MSG-6i.md). Operator requirements round after MSG-6h shipped. Scope C deliberately reverses an explicit MSG-6g non-goal ("denominator = votes cast, not participant count") -- operator-confirmed correction, not an oversight. No backend change needed for scope C (union of existing `voters` arrays, client-side). **Implemented directly by the Orchestrator** (Codex unavailable). Also fixed a companion bug found live while verifying jg-ferien's MSG-18: `Thread.jsx`'s root Stack/scroll Box had no flex participation and a hardcoded `maxHeight:560`, so it never shrank to fit a short mobile viewport regardless of host sizing — added `flex:1, minHeight:0` to both (kept `maxHeight:560` as a no-op fallback outside a flex context). **Independent `reviewer` + `ui_reviewer`, run in parallel, 7 findings total, 5 fixed + 2 disclosed-not-fixed** (full detail in the WO's own record): headline finding, confirmed independently by both reviewers — scope D's emoji grid broke `Menu`'s built-in arrow-key roving focus (IconButtons nested under an intervening `role="group"` Box aren't direct children of `MenuList`, so keyboard nav went dead); fixed by switching `Menu`→`Popover` (a grid of buttons was never really a linear menu). `reviewer` also found the optimistic vote projection could desync `vote_count` from `voters` when `userId` is momentarily unknown (the new respondent-count denominator reads only `voters`) — fixed with a sentinel voter id so both always move together; and that the `Thread.jsx` companion fix had zero regression coverage — added a `getComputedStyle`-based test. `ui_reviewer` P3s: the emoji grid's `aria-label` duplicated the trigger button's own label (fixed with a dedicated `EMOJI_PICKER` key); a hardcoded `fontSize` on the emoji buttons (no theme token exists for this, left as-is, disclosed). Full suite (single-forked, an unrelated worker-pool timeout under the default parallel pool made this necessary) 249 → 256 (7 new), `tsc` clean. Publishing as npm 2.25.0. |
44
+ | MSG-6i | Attachment tile 120px, poll spacing "luftiger", poll % denominator = respondents not sum(votes), wider Composer emoji picker | `AttachmentList.jsx` tiles are 64px. `PollCard.jsx` reads cramped per an operator screenshot. Poll percentages currently divide by `sum(vote_count)`, which for multi-select can exceed respondent count. Composer reuses `ReactionBar`s 5-emoji `QUICK_EMOJIS` for free-form text composition. | 2026-08-03 | done | a6ef578 | Envelope + Part B as-built: [work-orders/MSG-6i.md](work-orders/MSG-6i.md). Operator requirements round after MSG-6h shipped. Scope C deliberately reverses an explicit MSG-6g non-goal ("denominator = votes cast, not participant count") -- operator-confirmed correction, not an oversight. No backend change needed for scope C (union of existing `voters` arrays, client-side). **Implemented directly by the Orchestrator** (Codex unavailable). Also fixed a companion bug found live while verifying jg-ferien's MSG-18: `Thread.jsx`'s root Stack/scroll Box had no flex participation and a hardcoded `maxHeight:560`, so it never shrank to fit a short mobile viewport regardless of host sizing — added `flex:1, minHeight:0` to both (kept `maxHeight:560` as a no-op fallback outside a flex context). **Independent `reviewer` + `ui_reviewer`, run in parallel, 7 findings total, 5 fixed + 2 disclosed-not-fixed** (full detail in the WO's own record): headline finding, confirmed independently by both reviewers — scope D's emoji grid broke `Menu`'s built-in arrow-key roving focus (IconButtons nested under an intervening `role="group"` Box aren't direct children of `MenuList`, so keyboard nav went dead); fixed by switching `Menu`→`Popover` (a grid of buttons was never really a linear menu). `reviewer` also found the optimistic vote projection could desync `vote_count` from `voters` when `userId` is momentarily unknown (the new respondent-count denominator reads only `voters`) — fixed with a sentinel voter id so both always move together; and that the `Thread.jsx` companion fix had zero regression coverage — added a `getComputedStyle`-based test. `ui_reviewer` P3s: the emoji grid's `aria-label` duplicated the trigger button's own label (fixed with a dedicated `EMOJI_PICKER` key); a hardcoded `fontSize` on the emoji buttons (no theme token exists for this, left as-is, disclosed). Full suite (single-forked, an unrelated worker-pool timeout under the default parallel pool made this necessary) 249 → 256 (7 new), `tsc` clean. Publishing as npm 2.25.0. |
45
+ | NOTIF-14 | Push-preview toggle in NotificationSettings | Companion to `django-core-micha` MSG-13 (landed `39135e9`), which added a per-user `NotificationPreference.push_preview_opt_in` backend field (default on) that hides sender/text from a push body when off, already exposed on the `notifications/preferences/` endpoint — but nothing in ucm reads or writes it yet, so there is no UI path to reach it. | 2026-08-04 | done | 72e6697 | Envelope: [work-orders/NOTIF-14.md](work-orders/NOTIF-14.md). **Codex → Claude fallback**: Codex exited immediately on "workspace out of credits" (twice) before writing any code, so this session implemented directly — author-flip rule applied, independent `reviewer` ran before commit. Adds a third `Switch` to `NotificationSettings.jsx`, mirroring the existing `email_opt_in` toggle shape exactly (not the more involved device-subscription push toggle). **Load-bearing subtlety from the envelope, confirmed correct by `reviewer`:** `push_preview_opt_in` defaults `True` on the backend (opposite of `push_opt_in`'s `False` default) — checked as `!== false`, not `Boolean(...)`, with a non-vacuous test (field absent from the mocked API response) proving it. UX call from the Expertenchat, implemented as specified: the toggle is disabled unless `push_opt_in` is already on. Two new i18n keys across all four languages (de/fr/en/sw). While writing the new tests, surfaced and fixed two latent, pre-existing gaps in `tests/NotificationSettings.test.jsx` that had simply never been exercised before (the file had only ever rendered the component twice total): no `afterEach(cleanup)` between tests (added, matches the convention already used in ~33 other test files in this repo), and the file's mocked `useTranslation` returned a fresh `t` function reference on every call, unlike real react-i18next (which memoizes it) — this re-triggered the component's `useEffect(..., [t])` on every re-render in tests and clobbered a toggle's own state update with a stale refetch; fixed by hoisting `t` to a module-level stable reference in the test file only, production code untouched. `reviewer` independently confirmed both are legitimate test-infra fixes, not a production defect. Gate: `tests/NotificationSettings.test.jsx` 2→8 passed; full suite (measured baseline via `git stash -u`) 256 passed/0 failed → after 262 passed/0 failed, 0 regressions; `tsc -p tsconfig.build.json --noEmit` clean. Tier 2 (ui-core-micha is shared-core, forces Tier 2 regardless of size per AGENTS.md) — `sec_reviewer` not needed (the content-in-push security question was already ruled in dcm MSG-13; this WO only wires an already-shipped, already-reviewed backend preference into the settings UI). |
46
+ | SHELL-1 | A shared user menu: avatar, profile, logout | Measured 2026-08-04: **seven render sites across two apps** for two adjacent actions — spesix has an account link (`:184`) plus a separate logout (`:224`), and the same pair again at `:269`/`:307` for mobile; survey_app has account nav (`:67`), logout desktop (`:152`) and logout mobile (`:90`); jg-ferien has an avatar linking to `/account` and **no header logout at all** (it sits at the bottom of `/more`). The duplication is worse *within* each app than between them. ucm exports nothing header-shaped today and has no avatar component, though it has owned `logout` in `auth/AuthContext.jsx:132` all along. | 2026-08-04 | planned | | Envelope: [work-orders/SHELL-1.md](work-orders/SHELL-1.md). New `SHELL-*` prefix for shared application-shell surfaces, distinct from `MSG-*`/`NOTIF-*`. **Design settled with the operator against an interactive prototype — the boundary is not to be renegotiated:** ucm owns the avatar, the menu, Profil (`/account`, verified identical in all four apps) and Abmelden; the host passes only account-related entries, already permission-filtered and already translated; **the context/structure picker never enters the menu.** That last point is measured, not assumed — spesix's `activeSchoolId` is consumed by **13 files** (Approvals/Budgets/Claims/AppLayout) and jg's `selectedEventId` by **18**; it re-scopes the entire application's data, so it stays a permanently visible control of its own. Operator 2026-08-04: org/structure pickers are app-specific and already solved where they live; a second route to them here would be the very duplication this WO removes. **Expected integration for spesix and survey_app is `<UserMenu resolveLink={navigate} />` and nothing else** — only jg passes an entry. Two traps carried over from elsewhere in this estate: an earlier prototype had Benachrichtigungen as a ucm built-in, but **only jg** embeds `NotificationSettings` and `?tab=notifications` exists only there, so three apps would have got a menu entry pointing at a route they lack — verify every built-in against every app; and **labels arrive as finished strings, never i18n keys**, which is exactly why `django-core-micha` MSG-13 is currently broken. Stated cost: Profil goes from one tap to two in every app. hram is **unverified** (no header file under `components/`) — do not claim four apps benefit until someone looks. Scope E folds in MSG-13's preview `Switch`, which is not deliverable from dcm; ship A-D without it if the dcm preference has not landed. Host header rewires are four separate small WOs, one per app, including removing jg's now-redundant local `UserAvatar`. |
@@ -10,6 +10,8 @@ export const notificationsTranslations = {
10
10
  'NotificationSettings.EMAIL_HINT': { de: 'Erhalte wichtige Informationen per E-Mail.', fr: 'Recevez les informations importantes par e-mail.', en: 'Receive important information by email.', sw: 'Pokea taarifa muhimu kwa barua pepe.' },
11
11
  'NotificationSettings.PUSH_LABEL': { de: 'Push-Benachrichtigungen', fr: 'Notifications push', en: 'Push notifications', sw: 'Arifa za push' },
12
12
  'NotificationSettings.PUSH_HINT': { de: 'Aktiviere Benachrichtigungen für dieses Gerät.', fr: 'Activez les notifications pour cet appareil.', en: 'Enable notifications for this device.', sw: 'Washa arifa kwa kifaa hiki.' },
13
+ 'NotificationSettings.PUSH_PREVIEW_LABEL': { de: 'Vorschau in Push-Benachrichtigungen', fr: 'Aperçu dans les notifications push', en: 'Preview in push notifications', sw: 'Muhtasari katika arifa za push' },
14
+ 'NotificationSettings.PUSH_PREVIEW_HINT': { de: 'Zeigt Absender und Nachrichtentext in der Push-Benachrichtigung. Bei Deaktivierung erhältst du weiterhin Benachrichtigungen, aber ohne Inhalt.', fr: 'Affiche l’expéditeur et le texte du message dans la notification push. Si désactivé, vous recevez toujours les notifications, mais sans contenu.', en: 'Shows the sender and message text in the push notification. When off, you still get notified, just without the content.', sw: 'Inaonyesha mtumaji na maandishi ya ujumbe kwenye arifa ya push. Ikizimwa, bado utaarifiwa, lakini bila maudhui.' },
13
15
  'NotificationSettings.PUSH_ENABLE': { de: 'Push aktivieren', fr: 'Activer les notifications push', en: 'Enable push', sw: 'Washa push' },
14
16
  'NotificationSettings.PUSH_DISABLE': { de: 'Push auf diesem Gerät deaktivieren', fr: 'Désactiver les notifications push sur cet appareil', en: 'Disable push on this device', sw: 'Zima push kwenye kifaa hiki' },
15
17
  'NotificationSettings.PUSH_DENIED': { de: 'Die Browserberechtigung für Push-Benachrichtigungen wurde verweigert.', fr: 'L’autorisation du navigateur pour les notifications push a été refusée.', en: 'Browser permission for push notifications was denied.', sw: 'Ruhusa ya kivinjari kwa arifa za push imekataliwa.' },
@@ -31,6 +31,7 @@ export function NotificationSettings() {
31
31
  const [loading, setLoading] = useState(true);
32
32
  const [savingEmail, setSavingEmail] = useState(false);
33
33
  const [savingPush, setSavingPush] = useState(false);
34
+ const [savingPreview, setSavingPreview] = useState(false);
34
35
  const [pushSubscribed, setPushSubscribed] = useState(false);
35
36
  const [error, setError] = useState('');
36
37
  const [conflict, setConflict] = useState('');
@@ -118,6 +119,21 @@ export function NotificationSettings() {
118
119
  setSavingPush(false);
119
120
  }
120
121
  };
122
+ const handlePreviewToggle = async (event) => {
123
+ const push_preview_opt_in = event.target.checked;
124
+ setSavingPreview(true);
125
+ setError('');
126
+ try {
127
+ const updated = await patchNotificationPreferences({ push_preview_opt_in });
128
+ setPreferences(updated);
129
+ }
130
+ catch (_a) {
131
+ setError(t('NotificationSettings.SAVE_ERROR'));
132
+ }
133
+ finally {
134
+ setSavingPreview(false);
135
+ }
136
+ };
121
137
  const handleDisablePush = async () => {
122
138
  setSavingPush(true);
123
139
  setError('');
@@ -142,6 +158,6 @@ export function NotificationSettings() {
142
158
  if (loading) {
143
159
  return _jsx(Box, { sx: { display: 'flex', justifyContent: 'center', py: 4 }, children: _jsx(CircularProgress, {}) });
144
160
  }
145
- return (_jsxs(Box, { sx: { maxWidth: 520 }, children: [_jsxs(Typography, { variant: "h6", gutterBottom: true, sx: { display: 'flex', alignItems: 'center', gap: 1 }, children: [_jsx(NotificationsIcon, {}), t('NotificationSettings.TITLE')] }), _jsx(Typography, { variant: "body2", color: "text.secondary", gutterBottom: true, children: t('NotificationSettings.SUBTITLE') }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, onClose: () => setError(''), children: error }), conflict && _jsx(Alert, { severity: "warning", sx: { mb: 2 }, onClose: () => setConflict(''), children: conflict }), _jsx(Box, { sx: { py: 2 }, children: _jsx(FormControlLabel, { control: _jsx(Switch, { checked: Boolean(preferences === null || preferences === void 0 ? void 0 : preferences.email_opt_in), onChange: handleEmailToggle, disabled: savingEmail }), label: _jsxs(Box, { children: [_jsx(Typography, { variant: "body1", children: t('NotificationSettings.EMAIL_LABEL') }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: t('NotificationSettings.EMAIL_HINT') })] }), labelPlacement: "end", sx: { alignItems: 'flex-start', ml: 0 } }) }), _jsx(Divider, {}), _jsxs(Box, { sx: { py: 2 }, children: [iosNeedsInstall && _jsx(Alert, { severity: "info", sx: { mb: 1.5 }, children: t('NotificationSettings.IOS_HINT') }), !pushSupported && !iosNeedsInstall && _jsx(Alert, { severity: "warning", sx: { mb: 1.5 }, children: t('NotificationSettings.PUSH_NOT_SUPPORTED') }), _jsx(FormControlLabel, { control: (_jsx(Switch, { checked: pushSubscribed, onChange: (event) => (event.target.checked ? handleEnablePush() : handleDisablePush()), disabled: savingPush || !pushSupported || iosNeedsInstall })), label: _jsxs(Box, { children: [_jsx(Typography, { variant: "body1", children: t('NotificationSettings.PUSH_LABEL') }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: t('NotificationSettings.PUSH_HINT') })] }), labelPlacement: "end", sx: { alignItems: 'flex-start', ml: 0 } })] })] }));
161
+ return (_jsxs(Box, { sx: { maxWidth: 520 }, children: [_jsxs(Typography, { variant: "h6", gutterBottom: true, sx: { display: 'flex', alignItems: 'center', gap: 1 }, children: [_jsx(NotificationsIcon, {}), t('NotificationSettings.TITLE')] }), _jsx(Typography, { variant: "body2", color: "text.secondary", gutterBottom: true, children: t('NotificationSettings.SUBTITLE') }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, onClose: () => setError(''), children: error }), conflict && _jsx(Alert, { severity: "warning", sx: { mb: 2 }, onClose: () => setConflict(''), children: conflict }), _jsx(Box, { sx: { py: 2 }, children: _jsx(FormControlLabel, { control: _jsx(Switch, { checked: Boolean(preferences === null || preferences === void 0 ? void 0 : preferences.email_opt_in), onChange: handleEmailToggle, disabled: savingEmail }), label: _jsxs(Box, { children: [_jsx(Typography, { variant: "body1", children: t('NotificationSettings.EMAIL_LABEL') }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: t('NotificationSettings.EMAIL_HINT') })] }), labelPlacement: "end", sx: { alignItems: 'flex-start', ml: 0 } }) }), _jsx(Divider, {}), _jsxs(Box, { sx: { py: 2 }, children: [iosNeedsInstall && _jsx(Alert, { severity: "info", sx: { mb: 1.5 }, children: t('NotificationSettings.IOS_HINT') }), !pushSupported && !iosNeedsInstall && _jsx(Alert, { severity: "warning", sx: { mb: 1.5 }, children: t('NotificationSettings.PUSH_NOT_SUPPORTED') }), _jsx(FormControlLabel, { control: (_jsx(Switch, { checked: pushSubscribed, onChange: (event) => (event.target.checked ? handleEnablePush() : handleDisablePush()), disabled: savingPush || !pushSupported || iosNeedsInstall })), label: _jsxs(Box, { children: [_jsx(Typography, { variant: "body1", children: t('NotificationSettings.PUSH_LABEL') }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: t('NotificationSettings.PUSH_HINT') })] }), labelPlacement: "end", sx: { alignItems: 'flex-start', ml: 0 } }), _jsx(FormControlLabel, { control: (_jsx(Switch, { checked: (preferences === null || preferences === void 0 ? void 0 : preferences.push_preview_opt_in) !== false, onChange: handlePreviewToggle, disabled: savingPreview || !(preferences === null || preferences === void 0 ? void 0 : preferences.push_opt_in) })), label: _jsxs(Box, { children: [_jsx(Typography, { variant: "body1", children: t('NotificationSettings.PUSH_PREVIEW_LABEL') }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: t('NotificationSettings.PUSH_PREVIEW_HINT') })] }), labelPlacement: "end", sx: { alignItems: 'flex-start', ml: 0, mt: 1.5 } })] })] }));
146
162
  }
147
163
  export default NotificationSettings;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.25.0",
3
+ "version": "2.25.1",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "repository": {
@@ -10,6 +10,8 @@ export const notificationsTranslations = {
10
10
  'NotificationSettings.EMAIL_HINT': { de: 'Erhalte wichtige Informationen per E-Mail.', fr: 'Recevez les informations importantes par e-mail.', en: 'Receive important information by email.', sw: 'Pokea taarifa muhimu kwa barua pepe.' },
11
11
  'NotificationSettings.PUSH_LABEL': { de: 'Push-Benachrichtigungen', fr: 'Notifications push', en: 'Push notifications', sw: 'Arifa za push' },
12
12
  'NotificationSettings.PUSH_HINT': { de: 'Aktiviere Benachrichtigungen für dieses Gerät.', fr: 'Activez les notifications pour cet appareil.', en: 'Enable notifications for this device.', sw: 'Washa arifa kwa kifaa hiki.' },
13
+ 'NotificationSettings.PUSH_PREVIEW_LABEL': { de: 'Vorschau in Push-Benachrichtigungen', fr: 'Aperçu dans les notifications push', en: 'Preview in push notifications', sw: 'Muhtasari katika arifa za push' },
14
+ 'NotificationSettings.PUSH_PREVIEW_HINT': { de: 'Zeigt Absender und Nachrichtentext in der Push-Benachrichtigung. Bei Deaktivierung erhältst du weiterhin Benachrichtigungen, aber ohne Inhalt.', fr: 'Affiche l’expéditeur et le texte du message dans la notification push. Si désactivé, vous recevez toujours les notifications, mais sans contenu.', en: 'Shows the sender and message text in the push notification. When off, you still get notified, just without the content.', sw: 'Inaonyesha mtumaji na maandishi ya ujumbe kwenye arifa ya push. Ikizimwa, bado utaarifiwa, lakini bila maudhui.' },
13
15
  'NotificationSettings.PUSH_ENABLE': { de: 'Push aktivieren', fr: 'Activer les notifications push', en: 'Enable push', sw: 'Washa push' },
14
16
  'NotificationSettings.PUSH_DISABLE': { de: 'Push auf diesem Gerät deaktivieren', fr: 'Désactiver les notifications push sur cet appareil', en: 'Disable push on this device', sw: 'Zima push kwenye kifaa hiki' },
15
17
  'NotificationSettings.PUSH_DENIED': { de: 'Die Browserberechtigung für Push-Benachrichtigungen wurde verweigert.', fr: 'L’autorisation du navigateur pour les notifications push a été refusée.', en: 'Browser permission for push notifications was denied.', sw: 'Ruhusa ya kivinjari kwa arifa za push imekataliwa.' },
@@ -38,6 +38,7 @@ export function NotificationSettings() {
38
38
  const [loading, setLoading] = useState(true);
39
39
  const [savingEmail, setSavingEmail] = useState(false);
40
40
  const [savingPush, setSavingPush] = useState(false);
41
+ const [savingPreview, setSavingPreview] = useState(false);
41
42
  const [pushSubscribed, setPushSubscribed] = useState(false);
42
43
  const [error, setError] = useState('');
43
44
  const [conflict, setConflict] = useState('');
@@ -123,6 +124,20 @@ export function NotificationSettings() {
123
124
  }
124
125
  };
125
126
 
127
+ const handlePreviewToggle = async (event) => {
128
+ const push_preview_opt_in = event.target.checked;
129
+ setSavingPreview(true);
130
+ setError('');
131
+ try {
132
+ const updated = await patchNotificationPreferences({ push_preview_opt_in });
133
+ setPreferences(updated);
134
+ } catch {
135
+ setError(t('NotificationSettings.SAVE_ERROR'));
136
+ } finally {
137
+ setSavingPreview(false);
138
+ }
139
+ };
140
+
126
141
  const handleDisablePush = async () => {
127
142
  setSavingPush(true);
128
143
  setError('');
@@ -185,6 +200,18 @@ export function NotificationSettings() {
185
200
  labelPlacement="end"
186
201
  sx={{ alignItems: 'flex-start', ml: 0 }}
187
202
  />
203
+ <FormControlLabel
204
+ control={(
205
+ <Switch
206
+ checked={preferences?.push_preview_opt_in !== false}
207
+ onChange={handlePreviewToggle}
208
+ disabled={savingPreview || !preferences?.push_opt_in}
209
+ />
210
+ )}
211
+ label={<Box><Typography variant="body1">{t('NotificationSettings.PUSH_PREVIEW_LABEL')}</Typography><Typography variant="caption" color="text.secondary">{t('NotificationSettings.PUSH_PREVIEW_HINT')}</Typography></Box>}
212
+ labelPlacement="end"
213
+ sx={{ alignItems: 'flex-start', ml: 0, mt: 1.5 }}
214
+ />
188
215
  </Box>
189
216
  </Box>
190
217
  );
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment jsdom
2
2
  import React from 'react';
3
- import { beforeEach, describe, expect, it, vi } from 'vitest';
4
- import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
5
5
 
6
6
  const notificationsApi = vi.hoisted(() => ({
7
7
  getNotificationPreferences: vi.fn(),
@@ -13,7 +13,12 @@ const notificationsApi = vi.hoisted(() => ({
13
13
  }));
14
14
 
15
15
  vi.mock('../src/notifications/api', () => notificationsApi);
16
- vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key) => key }) }));
16
+ // A stable `t` reference, matching real react-i18next (which memoizes it) -- an unstable
17
+ // mock `t` would re-trigger the component's `useEffect(..., [t])` on every render and
18
+ // re-fetch preferences, clobbering any state update made in between (e.g. a toggle's
19
+ // optimistic-from-response update racing against a stale refetch).
20
+ const stableT = (key) => key;
21
+ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: stableT }) }));
17
22
 
18
23
  import { NotificationSettings } from '../src/notifications/NotificationSettings';
19
24
 
@@ -32,6 +37,11 @@ function installPushEnvironment(subscription) {
32
37
  return registration;
33
38
  }
34
39
 
40
+ afterEach(() => {
41
+ cleanup();
42
+ vi.unstubAllGlobals();
43
+ });
44
+
35
45
  describe('NotificationSettings push toggle', () => {
36
46
  beforeEach(() => {
37
47
  vi.clearAllMocks();
@@ -80,3 +90,82 @@ describe('NotificationSettings push toggle', () => {
80
90
  expect(notificationsApi.patchNotificationPreferences).not.toHaveBeenCalled();
81
91
  });
82
92
  });
93
+
94
+ describe('NotificationSettings push preview toggle', () => {
95
+ beforeEach(() => {
96
+ vi.clearAllMocks();
97
+ notificationsApi.getVapidPublicKey.mockResolvedValue('AQID');
98
+ });
99
+
100
+ it('renders checked when the preference is explicitly on', async () => {
101
+ notificationsApi.getNotificationPreferences.mockResolvedValue({
102
+ email_opt_in: false, push_opt_in: true, push_preview_opt_in: true,
103
+ });
104
+
105
+ render(<NotificationSettings />);
106
+
107
+ const toggle = await screen.findByRole('switch', { name: /PUSH_PREVIEW_LABEL/ });
108
+ expect(toggle.checked).toBe(true);
109
+ });
110
+
111
+ it('renders checked when the preference is absent entirely (default-on, not Boolean(undefined))', async () => {
112
+ // Regression test for NOTIF-14: a naive `Boolean(preferences?.push_preview_opt_in)`
113
+ // reads a missing field as false, misrepresenting the backend's true default. Must
114
+ // fail against that implementation.
115
+ notificationsApi.getNotificationPreferences.mockResolvedValue({ email_opt_in: false, push_opt_in: true });
116
+
117
+ render(<NotificationSettings />);
118
+
119
+ const toggle = await screen.findByRole('switch', { name: /PUSH_PREVIEW_LABEL/ });
120
+ expect(toggle.checked).toBe(true);
121
+ });
122
+
123
+ it('renders unchecked when the preference is explicitly off', async () => {
124
+ notificationsApi.getNotificationPreferences.mockResolvedValue({
125
+ email_opt_in: false, push_opt_in: true, push_preview_opt_in: false,
126
+ });
127
+
128
+ render(<NotificationSettings />);
129
+
130
+ const toggle = await screen.findByRole('switch', { name: /PUSH_PREVIEW_LABEL/ });
131
+ expect(toggle.checked).toBe(false);
132
+ });
133
+
134
+ it('toggling calls patchNotificationPreferences with push_preview_opt_in and re-renders from the response', async () => {
135
+ notificationsApi.getNotificationPreferences.mockResolvedValue({
136
+ email_opt_in: false, push_opt_in: true, push_preview_opt_in: true,
137
+ });
138
+ notificationsApi.patchNotificationPreferences.mockResolvedValue({
139
+ email_opt_in: false, push_opt_in: true, push_preview_opt_in: false,
140
+ });
141
+
142
+ render(<NotificationSettings />);
143
+ const toggle = await screen.findByRole('switch', { name: /PUSH_PREVIEW_LABEL/ });
144
+ fireEvent.click(toggle);
145
+
146
+ await waitFor(() => expect(notificationsApi.patchNotificationPreferences).toHaveBeenCalledWith({ push_preview_opt_in: false }));
147
+ await waitFor(() => expect(toggle.checked).toBe(false));
148
+ });
149
+
150
+ it('is disabled when push notifications are not opted in for this account', async () => {
151
+ notificationsApi.getNotificationPreferences.mockResolvedValue({
152
+ email_opt_in: false, push_opt_in: false, push_preview_opt_in: true,
153
+ });
154
+
155
+ render(<NotificationSettings />);
156
+
157
+ const toggle = await screen.findByRole('switch', { name: /PUSH_PREVIEW_LABEL/ });
158
+ expect(toggle.disabled).toBe(true);
159
+ });
160
+
161
+ it('is enabled when push notifications are opted in for this account', async () => {
162
+ notificationsApi.getNotificationPreferences.mockResolvedValue({
163
+ email_opt_in: false, push_opt_in: true, push_preview_opt_in: true,
164
+ });
165
+
166
+ render(<NotificationSettings />);
167
+
168
+ const toggle = await screen.findByRole('switch', { name: /PUSH_PREVIEW_LABEL/ });
169
+ expect(toggle.disabled).toBe(false);
170
+ });
171
+ });
@@ -0,0 +1,151 @@
1
+ # WORK ORDER NOTIF-14 (ui-core-micha) — push-preview toggle in NotificationSettings
2
+
3
+ **EXECUTION DIRECTIVE.** If you are the implementer reading this as your own spec, this section is
4
+ not addressed to you — it tells the Orchestrator how to invoke you; you ARE that invocation, do not
5
+ shell out to `codex exec`. Orchestrator: implement through `codex exec` in the background, invoked
6
+ **directly via Bash** (never the `debugger`/`*_coder` Agent wrappers) with **both** flags
7
+ `--skip-git-repo-check` and `--dangerously-bypass-approvals-and-sandbox`, prompt passed as a
8
+ positional argument from this file. Fall back to direct Claude implementation only on Codex quota /
9
+ rate-limit / non-zero exit.
10
+
11
+ Feature-level WO (Expertenchat envelope). Companion to `django-core-micha`'s `work-orders/MSG-13.md`
12
+ (landed `39135e9` on dcm `main`), whose Scope E explicitly said: "The `Switch` in `ui-core-micha`'s
13
+ `NotificationSettings.jsx` is a companion change and is NOT deliverable from this repo — flag it in
14
+ the completion note so a `ui-core-micha` WO gets written." This is that WO.
15
+
16
+ ## TIER
17
+ Tier 2 — this repo IS a shared-core surface (`ui-core-micha`), which per AGENTS.md forces Tier 2
18
+ regardless of change size. Independent `reviewer` mandatory. No `sec_reviewer` needed: the security
19
+ question (should message content go in a push at all) was already ruled by the operator in dcm's
20
+ MSG-13; this WO only wires an existing, already-shipped backend preference into the existing
21
+ settings UI — no new exposure, no new endpoint, no new data crossing a trust boundary.
22
+
23
+ ## GOAL
24
+ dcm's MSG-13 added a per-user account-level preference, `NotificationPreference.push_preview_opt_in`
25
+ (default `True`), already exposed read/write on the existing `notifications/preferences/` endpoint
26
+ (`NotificationPreferenceSerializer` fields: `email_opt_in`, `push_opt_in`, `push_preview_opt_in`).
27
+ When off, dcm's `PushDispatcher` sends a content-free push body (no sender name, no message text) —
28
+ the notification still arrives, but its preview is hidden. Nothing in ucm reads or writes this field
29
+ yet, so a user has no way to turn it off (or back on) — a per-user preference sitting dead on the
30
+ backend with no UI path to reach it.
31
+
32
+ ## EXPECTED OUTCOME
33
+ - `NotificationSettings.jsx` gains a third toggle, alongside the existing email/push `Switch`es,
34
+ bound to `push_preview_opt_in` via the same generic `patchNotificationPreferences()` call already
35
+ used for the other two (no new API function needed — see Context Package).
36
+ - The toggle reflects the loaded preference correctly, **including the default-on case**: a fresh
37
+ account (or a preferences payload predating this field) has never set `push_preview_opt_in`
38
+ explicitly, and this MUST still render as checked/on. `Boolean(preferences?.push_preview_opt_in)`
39
+ — the pattern the existing two switches use — is **wrong** here, because `Boolean(undefined) ===
40
+ false`; that would silently show "preview off" for a user whose real, backend-defaulted state is
41
+ "preview on" (the exact class of default-mismatch bug MSG-13 itself was about, one level up).
42
+ The correct check is `preferences?.push_preview_opt_in !== false`.
43
+ - Toggling calls `patchNotificationPreferences({ push_preview_opt_in: <bool> })` and updates local
44
+ state from the response, mirroring `handleEmailToggle`'s existing shape exactly (loading flag,
45
+ error handling, no confirmation dialog).
46
+ - **UX placement decision (Expertenchat call, confirm or override at Approval Gate #1):** render the
47
+ preview toggle directly under the push toggle (same `Box`/`Divider` block, not a new section), and
48
+ **disable it when push notifications are not actually enabled for this account**
49
+ (`!preferences?.push_opt_in`) — a preview setting is meaningless when no push is being sent at all,
50
+ and leaving it enabled-but-inert invites a user to "fix" a push they never receive by toggling a
51
+ setting that does nothing. Gate on the account-level `push_opt_in` (matches dcm's own
52
+ `is_channel_enabled` gate for the `push` channel), not on this device's `pushSubscribed` — a user
53
+ with push enabled on another device but not this one should still be able to manage their preview
54
+ preference here.
55
+
56
+ ## CONTEXT PACKAGE — verified current state
57
+
58
+ **`src/notifications/NotificationSettings.jsx`** (194 lines) already renders two independent
59
+ preference switches against the same preferences object:
60
+ - `handleEmailToggle` (`:81-93`) — the exact shape to mirror: `setSavingEmail(true)` →
61
+ `patchNotificationPreferences({ email_opt_in })` → `setPreferences(updated)` → catch sets
62
+ `error` → `finally` resets the saving flag.
63
+ - The push switch (`:176-187`) is more involved (subscribe/unsubscribe device flow) — **do not
64
+ mirror that one**, the preview toggle is a plain preference flip like email, not a device
65
+ subscription.
66
+ - `preferences` state (`:37`) is the raw object returned by `getNotificationPreferences()`
67
+ (`GET /api/notifications/preferences/`) and updated in place by
68
+ `patchNotificationPreferences()` (`PATCH`, same URL, generic passthrough — accepts any subset of
69
+ serializer fields, **no new API function needed**, see `src/notifications/api.js:6-13`).
70
+ - Render block to extend is `:162-188` (the email `Box`, a `Divider`, the push `Box`). Add a third
71
+ `Box` with a `FormControlLabel`+`Switch`, same structural pattern as the email one (`:163-168`) —
72
+ `checked`, `onChange`, `disabled`, then a label with a primary `Typography` + a `caption`
73
+ `Typography` hint underneath.
74
+
75
+ **`src/notifications/api.js`** — `patchNotificationPreferences(patch)` (`:11-14`) is a bare
76
+ passthrough `PATCH`; no change needed here.
77
+
78
+ **i18n**: `src/i18n/notificationsTranslations.ts` — existing `NotificationSettings.*` keys (`:5-19`)
79
+ cover `EMAIL_LABEL`/`EMAIL_HINT`/`PUSH_LABEL`/`PUSH_HINT` etc. across four languages (`de`, `fr`,
80
+ `en`, `sw`). Add two new keys, e.g. `NotificationSettings.PUSH_PREVIEW_LABEL` and
81
+ `NotificationSettings.PUSH_PREVIEW_HINT`, in the same four-language shape. The hint text should say
82
+ what changes when off (no sender name or message text in the push body — the message itself still
83
+ arrives) without needing to explain the underlying push-encryption reasoning from dcm's MSG-13 (that
84
+ belongs to dcm, not this UI copy).
85
+
86
+ **Tests**: `tests/NotificationSettings.test.jsx` already mocks `../src/notifications/api` in full
87
+ (`:6-13`) and stubs `react-i18next` to return raw keys (`:16`) — extend the existing
88
+ `getNotificationPreferences`/`patchNotificationPreferences` mocks, do not add a new mock module.
89
+
90
+ ## NON-GOALS / DO NOT TOUCH
91
+ - Do not touch the push subscribe/unsubscribe device flow (`handleEnablePush`/`handleDisablePush`,
92
+ `:95-144`) or anything in `getPushSupport`/`getIosInstallState`.
93
+ - Do not touch `django-core-micha` — the backend half (field, migration, serializer, dispatch gating)
94
+ already shipped in dcm MSG-13 (`39135e9` on `main`). This WO is UI-only.
95
+ - Do not add a confirmation dialog or any new component beyond the one additional `Switch` block —
96
+ match the existing two switches' bare, no-confirmation UX exactly.
97
+ - Do not change what dcm's push body renders when the preference is off (already correct and
98
+ reviewed in dcm MSG-13) — this WO is only "can the user reach the setting", not the setting's effect.
99
+
100
+ ## RISKS
101
+ - The `Boolean(...)` vs `!== false` default-mismatch trap above is the main risk in this WO — call it
102
+ out explicitly in the PR/commit and in the required test, since it is easy to copy-paste the
103
+ existing `Boolean(preferences?.push_opt_in)` pattern without noticing the two fields have opposite
104
+ defaults (`push_opt_in` defaults `False` on the backend, `push_preview_opt_in` defaults `True`).
105
+ - Low blast radius otherwise: one existing, already-authenticated, already-scoped-to-`request.user`
106
+ endpoint; no new permission surface; additive UI only.
107
+
108
+ ## REQUIRED TESTS TO WRITE
109
+ Narrow, mirroring `tests/NotificationSettings.test.jsx`'s existing style and mocks.
110
+
111
+ 1. The preview switch renders **checked** when `getNotificationPreferences()` resolves with
112
+ `push_preview_opt_in: true`, **and also** when it resolves with the field **absent entirely**
113
+ (the default-on, non-vacuity case — must fail if the implementation uses `Boolean(...)` instead
114
+ of the `!== false` check).
115
+ 2. The preview switch renders **unchecked** when `push_preview_opt_in: false`.
116
+ 3. Toggling the switch calls `patchNotificationPreferences({ push_preview_opt_in: <new value> })`
117
+ and re-renders from the mocked response.
118
+ 4. The preview switch is **disabled** when `preferences.push_opt_in` is `false`, and **enabled**
119
+ when it is `true` — regardless of this device's own `pushSubscribed` state (per the UX placement
120
+ decision above).
121
+
122
+ **Non-vacuity:** test 1's absent-field case must fail against a naive `Boolean(preferences?.…)`
123
+ implementation — prove it before considering this WO done.
124
+
125
+ ## TEST SCOPE FOR THE GATE (orchestrator)
126
+ `tests/NotificationSettings.test.jsx` only — no other suite touches this component.
127
+
128
+ ## TARGET REPO
129
+ `C:\Users\biglmi\Documents\webapps\ui-core-micha`. Branch `main` (no `develop` in this repo — verify
130
+ with `git branch -a` before starting in case that has changed). Publish + version bump per the
131
+ repo's release flow if this ships as its own npm release, or bundle with the next ucm release per
132
+ operator direction.
133
+
134
+ ## MINI-HANDOVER (pastable)
135
+
136
+ > Repo: `C:\Users\biglmi\Documents\webapps\ui-core-micha` (branch `main`). Work order:
137
+ > `work-orders/NOTIF-14.md` — read it fully, then follow the `orchestrate-codex` skill.
138
+ >
139
+ > Adds the third `Switch` to `NotificationSettings.jsx` for dcm MSG-13's `push_preview_opt_in`
140
+ > preference (already live on the backend, `django-core-micha` `39135e9`). The one thing that will
141
+ > silently ship wrong if rushed: this field defaults to `True`, opposite of `push_opt_in`'s `False`
142
+ > default — the existing `Boolean(preferences?.field)` pattern used for the other two switches
143
+ > reads a missing/undefined field as `false` and would show "preview off" for every account that
144
+ > never explicitly touched the setting. Use `preferences?.push_preview_opt_in !== false` instead,
145
+ > and make sure the required test proves it (assert checked=true when the field is absent from the
146
+ > mocked API response, not just when it's explicitly `true`).
147
+
148
+ ## PROGRESS CONTRACT
149
+ Emit `PLAN: <steps>` up front, then a single-line `PROGRESS: [<n>/<total>] <action>` before every
150
+ relevant action and `PROGRESS: [<n>/<total>] done` on completion, spaced so no gap exceeds ~2 min,
151
+ stdout unbuffered, and exactly one final `RESULT: DONE|BLOCKED <reason>`.
@@ -0,0 +1,255 @@
1
+ # WORK ORDER SHELL-1 (ui-core-micha) — a shared user menu: avatar, profile, logout
2
+
3
+ **EXECUTION DIRECTIVE.** If you are the implementer reading this as your own spec, this section is not
4
+ addressed to you — it tells the Orchestrator how to invoke you; you ARE that invocation, do not shell
5
+ out to `codex exec`. Orchestrator: implement through `codex exec` in the background, invoked **directly
6
+ via Bash** (never the `debugger`/`*_coder` Agent wrappers) with **both** flags `--skip-git-repo-check`
7
+ and `--dangerously-bypass-approvals-and-sandbox`, prompt passed as a positional argument from this
8
+ file. Fall back to direct Claude implementation only on Codex quota / rate-limit / non-zero exit.
9
+
10
+ Feature-level WO (Expertenchat envelope). New prefix `SHELL-*` for shared application-shell surfaces
11
+ (header chrome, user menu) — distinct from `MSG-*` (messaging) and `NOTIF-*` (notification domain).
12
+ Design settled with the operator 2026-08-04 against an interactive prototype.
13
+
14
+ ## TIER
15
+ Tier 2 — shared-core UI consumed by every app, and it touches the logout path. Independent `reviewer`
16
+ mandatory, `ui_reviewer` mandatory.
17
+
18
+ ## WHY
19
+
20
+ Measured across the estate 2026-08-04, not estimated:
21
+
22
+ | App | Account + logout controls today |
23
+ |---|---|
24
+ | spesix `Header.jsx` | account link `:184` + separate logout `:224` — **and the same pair again** at `:269`/`:307` for mobile |
25
+ | survey_app `Header.jsx` | account nav entry `:67`, logout desktop `:152`, logout mobile `:90` |
26
+ | jg-ferien `HeaderUserActions.jsx` | avatar → `/account` only; **logout exists nowhere in the header** — it sits at the bottom of `/more` |
27
+ | hram | **unknown** — no header file under `components/`, different structure |
28
+
29
+ **Seven render sites across two apps for two adjacent actions**, and the duplication is worse *within*
30
+ each app than between them: desktop and mobile variants render the same logout separately. That is
31
+ where behaviour drifts.
32
+
33
+ This is a **consolidation**, not a bug fix. jg's missing header logout is the most visible symptom, but
34
+ fixing only jg would leave the duplication everywhere else untouched.
35
+
36
+ ## THE BOUNDARY — settled, do not renegotiate
37
+
38
+ | | |
39
+ |---|---|
40
+ | **ucm owns** | the avatar, the menu, **Profil** (`/account`), **Abmelden** |
41
+ | **Host passes** | account-related entries only, already permission-filtered and already translated |
42
+ | **Stays out** | the context/structure picker, and management navigation such as org administration |
43
+
44
+ **The context picker must never enter this menu.** Verified: spesix's `activeSchoolId` is consumed by
45
+ **13 files** (`ApprovalsPage`, `BudgetsPage`, `ClaimsPage`, `ClaimEditPage`, `AppLayout`, …) and jg's
46
+ `selectedEventId` by **18**. Changing it re-scopes the entire application's data. It is a primary
47
+ control that must stay permanently visible next to the content it governs — burying a control that
48
+ determines *which school's budgets you are reading* behind avatar → menu → item is both two taps too
49
+ many and the wrong mental model. The menu answers "who am I"; the picker answers "what am I looking at".
50
+
51
+ Operator, 2026-08-04: org/structure pickers are app-specific and already solved where they live. Adding
52
+ a second route to them here would be exactly the duplication this WO exists to remove.
53
+
54
+ ## SCOPE
55
+
56
+ **A. `UserMenu` — a new exported component.**
57
+
58
+ Trigger is an avatar; clicking opens a menu containing an identity block (name, email), **Profil**,
59
+ any host entries, a separator, and **Abmelden** last.
60
+
61
+ - **Navigation follows the existing `NotificationBell({ resolveLink })` pattern**
62
+ (`notifications/NotificationBell.jsx:19,31`) — the host passes its `navigate`; ucm knows no routes.
63
+ Do not invent a second navigation mechanism.
64
+ - **`/account` is the Profil default.** Verified identical in jg-ferien, spesix, survey_app and hram.
65
+ Overridable, but the default must work with no host configuration.
66
+ - **Abmelden calls ucm's own `logout`** (`auth/AuthContext.jsx:132`) — already there, unexported to any
67
+ UI today. That is the whole gap.
68
+
69
+ **B. ucm owns the avatar.** ucm currently has **no** avatar component (only MUI `Avatar` usage inside
70
+ `ConversationList`); jg has its own local `UserAvatar`. Build ucm's, deriving initials from
71
+ `first_name` / `last_name` / `username`, which `mapUserFromApi` (`auth/AuthContext.jsx:41-56`) already
72
+ carries in every app. Allow a host override for a real image, but the no-config path must render
73
+ something correct.
74
+
75
+ jg's local `UserAvatar` becomes redundant. **Removing it is jg's job, not this WO's** — note it in the
76
+ completion note so the follow-up is written.
77
+
78
+ **C. The host extension point.**
79
+
80
+ ```jsx
81
+ items={[{ id, label, onSelect }]}
82
+ ```
83
+
84
+ Three rules, each load-bearing:
85
+
86
+ 1. **ucm never evaluates permissions.** jg's org entry is gated on `canManageStructure(user)`, a
87
+ jg-local helper. A `requiresPermission` prop would need a permission model shared across four apps —
88
+ there is none, and inventing one here is out of scope. **The host filters, then passes.**
89
+ 2. **Labels arrive as resolved strings, not i18n keys.** `More.NOTIFICATIONS` lives in jg's catalogue;
90
+ ucm cannot translate it. This is the same ownership question `django-core-micha` MSG-13 is currently
91
+ failing — dcm emits keys nobody has a catalogue for. Get it right here from the start: **whoever
92
+ owns the text supplies it finished.**
93
+ 3. **One action mechanism, not two.** `onSelect` only; the host closes over its own `navigate`. Do not
94
+ also accept `href` — two ways to express the same thing is two things to test and two ways to be
95
+ inconsistent.
96
+
97
+ **Ordering belongs to ucm.** Host entries render as one group in a fixed slot; **Abmelden is always
98
+ last**. If hosts could order freely, logout would sit in four different places and the consistency that
99
+ motivates this WO would be gone.
100
+
101
+ **Expected usage:** spesix and survey_app pass **nothing** — `<UserMenu resolveLink={navigate} />` is
102
+ the whole integration. Only jg passes an entry (its notification settings, which no other app has).
103
+
104
+ **D. Icons — decide once, state it.** jg's entries carry a trailing `ChevronRightIcon` and no leading
105
+ icon; ucm's built-ins would want leading icons. A mixed set looks broken. Either every entry may carry
106
+ an optional leading icon (ucm supplies its own), or none do. **This is the implementer's call, but it
107
+ must be stated, not left to emerge.**
108
+
109
+ **E. The preview toggle from `django-core-micha` MSG-13 scope E.** MSG-13 rules that the push shows
110
+ sender + message text, with a per-user opt-out. dcm ships the preference; **the `Switch` belongs in
111
+ `NotificationSettings.jsx` and is not deliverable from dcm.** It is folded in here because it is the
112
+ same corner of ucm and the same release.
113
+
114
+ Depends on dcm publishing the preference field first. **If it has not landed, ship A–D without E and
115
+ say so** — do not block the menu on it.
116
+
117
+ ## CONTEXT PACKAGE — verified current state (Orchestrator, Implementation map)
118
+
119
+ Work from this package; do not explore broadly from scratch — open only the named files to verify.
120
+ If you must dig deeper, delegate to a read-only Explore sub-agent (Haiku).
121
+
122
+ **New file: `src/auth/UserMenu.jsx`.** Sits with `AuthContext.jsx` (identity/auth surface), not
123
+ `notifications/` — this is not a notification. Export it in `src/index.js` next to the existing auth
124
+ block (`export { AuthContext, AuthProvider } from './auth/AuthContext';` at `:4`) — add
125
+ `export { UserMenu } from './auth/UserMenu';` there, not in the notifications block at `:59-65`.
126
+
127
+ **Menu pattern to mirror — `src/notifications/NotificationBell.jsx` (whole file, 85 lines), exactly:**
128
+ - `resolveLink` prop shape (`:19`): `export function NotificationBell({ resolveLink }) { … }`. The
129
+ handler calls `resolveLink(someLinkString)` conditionally (`:31`) — same contract `UserMenu` must
130
+ use: `resolveLink('/account')` for Profil, `resolveLink(item.onSelect...)` is wrong per spec —
131
+ re-read scope C: host items use `onSelect` (a callback), NOT `resolveLink` — `resolveLink` is only
132
+ for ucm's own Profil navigation. Two distinct mechanisms coexist here (ucm's own nav via
133
+ `resolveLink`, host items via their own `onSelect`) and that is correct, not a violation of "one
134
+ action mechanism" (point C.3 bars the host from having *two* ways — `onSelect` is the host's one way).
135
+ - Trigger + `Menu` scaffold (`:36-51`): `IconButton` with `aria-label`, `onClick={(e) =>
136
+ setAnchorEl(e.currentTarget)}`, then MUI `<Menu anchorEl={anchorEl} open={Boolean(anchorEl)}
137
+ onClose={() => setAnchorEl(null)} anchorOrigin={{horizontal:'right',vertical:'bottom'}}
138
+ transformOrigin={{horizontal:'right',vertical:'top'}}>`. **MUI `Menu` handles Escape and
139
+ click-outside natively via its built-in focus trap** (`onClose` fires for both) — no custom
140
+ keyboard/focus-trap code needed; `ui_reviewer`'s RISKS check is about *not breaking* this default,
141
+ not building one. Focus returns to the trigger `IconButton` automatically on close (MUI default);
142
+ verify this in the required test rather than assuming it.
143
+ - Item click closes the menu (`setAnchorEl(null)`) before/alongside the action (`:28-32`) — Abmelden
144
+ and Profil and each host item must all close the menu on click.
145
+
146
+ **Identity data — `src/auth/AuthContext.jsx`:**
147
+ - `mapUserFromApi` (`:41-73`) is where `user.first_name`, `user.last_name`, `user.username`,
148
+ `user.email` land — confirmed present on every `user` object in every app (already relied on by
149
+ `AccountPage`). Avatar initials: prefer `first_name[0]+last_name[0]`, fall back to `username[0]`
150
+ when names are absent (per scope B) — no other field exists to derive from.
151
+ - `logout` (`:132-141`): `const logout = async () => { try { await logoutSession(); } catch (e) {
152
+ console.error(...) } finally { setUser(null); } }`, already exposed on the context value (`:150`).
153
+ - **No `useAuth` hook exists in this repo** — every consumer does `useContext(AuthContext)` directly
154
+ (`AuthContext` exported at `:15`, `createContext(null)`). `UserMenu` must do the same — do not
155
+ invent a `useAuth` hook as part of this WO, that would be scope creep beyond the menu itself.
156
+ - Do not touch any line in this file (NON-GOALS already says so) — `UserMenu` only *reads*
157
+ `user`/`logout` off the context.
158
+
159
+ **Avatar:** no existing ucm `Avatar` component (verified — only bare MUI `<Avatar>` usage inside
160
+ `ConversationList`, nothing reusable). Build it inline in `UserMenu.jsx` or as a small
161
+ `src/auth/UserAvatar.jsx` sibling — implementer's call; MUI's `<Avatar>{initials}</Avatar>` for the
162
+ no-image case, `<Avatar src={overrideImageUrl} />` when a host passes one (scope B: "Allow a host
163
+ override for a real image").
164
+
165
+ **i18n — new file `src/i18n/userMenuTranslations.ts`**, same shape as the existing four
166
+ (`src/i18n/notificationsTranslations.ts` is the closest analog, e.g. its `:1-4`): a flat object,
167
+ keys like `'UserMenu.PROFILE'`/`'UserMenu.LOGOUT'`, each `{ de, fr, en, sw }`. **No existing
168
+ `UserMenu.*` or reusable `Profil`/`Abmelden` key exists yet** — `authTranslations.ts` has
169
+ `Account.TAB_PROFILE` (`:1616-1619`, "Profil"/"Profile") which is close but is the *account page
170
+ tab* label, a different UI surface; do not reuse it silently — either add fresh `UserMenu.*` keys
171
+ (recommended, keeps this component's translations self-contained and independently consumable) or
172
+ explicitly reuse `Account.TAB_PROFILE` if the copy is identical — state which was chosen. Then export
173
+ `userMenuTranslations` from `src/index.js` next to `authTranslations` (`:56`)/`notificationsTranslations`.
174
+ Per scope C.2, host-supplied `items[].label` are already-resolved strings — `UserMenu` must NOT run
175
+ them through `t()`.
176
+
177
+ **Package/test conventions:** flat package, `src/` at repo root (no `frontend/` subdir), React 19 +
178
+ MUI 7 as peer deps (`package.json`), Vitest + `@testing-library/react`. New test file
179
+ `tests/UserMenu.test.jsx` mirroring `tests/NotificationBell.test.jsx`'s shape: `vi.hoisted()` mocks,
180
+ stub `react-i18next`'s `useTranslation` with a **stable** `t` reference (module-level, not recreated
181
+ per render — the NOTIF-14 postmortem in this repo's own `WORK_ORDERS.md` documents exactly this
182
+ class of bug: an unstable mocked `t` retriggers effects and clobbers state in tests), and
183
+ `afterEach(cleanup)`.
184
+
185
+ ## NON-GOALS / DO NOT TOUCH
186
+ - **No context or structure picker**, in any form, including read-only display of the current context.
187
+ ucm cannot know whether the unit is a school, an event, a course or a department.
188
+ - No management navigation (org administration and the like) — account-related entries only.
189
+ - Do not change `AuthContext`'s `logout`, session handling, or `mapUserFromApi`.
190
+ - Do not touch any host app in this WO. The four header rewires are separate small WOs, one per app.
191
+ - Do not change `NotificationBell` or the notification feed.
192
+ - Do not add a permission model, a routing table, or an i18n catalogue to ucm.
193
+
194
+ ## RISKS
195
+ - **Profil goes from one tap to two** in every app, including the three whose avatar is a direct link
196
+ today. On mobile that is felt. It is the price of the consolidation and must be stated in the
197
+ completion note, not discovered by users.
198
+ - **The benefit only materialises per app.** ucm exports nothing header-shaped today, so there is no
199
+ single place to switch over — the component lands, then four hosts adopt it individually.
200
+ - **"Looks universal" is not a check.** An earlier version of the prototype had *Benachrichtigungen* as
201
+ a ucm built-in. In fact **only jg** embeds `NotificationSettings`, and `?tab=notifications` exists
202
+ only there — three apps would have got a menu entry pointing at a route they do not have. Verify
203
+ every built-in against all consuming apps before making it a built-in.
204
+ - **hram is unverified.** Do not claim four apps benefit until someone has looked at hram's shell.
205
+ - A menu is a focus trap if built carelessly: keyboard operation, `Escape`, click-outside and focus
206
+ return to the trigger all have to work. `ui_reviewer` should check this specifically.
207
+
208
+ ## REQUIRED TESTS TO WRITE
209
+ Narrow and behavioural. Do NOT run the full suite.
210
+
211
+ 1. With no `items` and no overrides, the menu renders identity, Profil and Abmelden — the zero-config
212
+ path two of four apps will use.
213
+ 2. Abmelden calls `AuthContext`'s `logout`. Assert the call, not merely that the item rendered.
214
+ 3. Profil navigates via `resolveLink` to `/account` by default, and to an override when given.
215
+ 4. Host `items` render in their slot with their `onSelect` invoked on click — and **Abmelden is still
216
+ the last item** with host entries present. That ordering assertion is the guard against hosts
217
+ drifting apart.
218
+ 5. Keyboard: the trigger opens the menu, `Escape` closes it and returns focus to the trigger.
219
+ 6. The avatar renders correct initials from `first_name`/`last_name`, and falls back to `username` when
220
+ names are absent.
221
+
222
+ **Non-vacuity:** test 4's ordering assertion must fail if host entries are appended after Abmelden;
223
+ test 2 must fail if `logout` is stubbed out.
224
+
225
+ ## TEST SCOPE FOR THE GATE (orchestrator)
226
+ The new component's tests plus whatever covers `AuthContext` and `NotificationSettings` if scope E is
227
+ included. Not the full suite.
228
+
229
+ ## TARGET REPO
230
+ `C:\Users\biglmi\Documents\webapps\ui-core-micha`. Branch `main` (or `develop` if present). Publish per
231
+ the repo's release flow; host pin bumps and header rewires are separate work.
232
+
233
+ ## MINI-HANDOVER (pastable)
234
+
235
+ > Repo: `C:\Users\biglmi\Documents\webapps\ui-core-micha` (branch `main`, or `develop` if present).
236
+ > Work order: `work-orders/SHELL-1.md` — read it fully, then follow the `orchestrate-codex` skill.
237
+ >
238
+ > **Design is settled against an interactive prototype — do not renegotiate the boundary.** ucm owns the
239
+ > avatar, the menu, Profil (`/account`) and Abmelden. The host passes only account-related entries,
240
+ > already permission-filtered and already translated. **The context/structure picker never enters this
241
+ > menu** — spesix's `activeSchoolId` drives 13 files and jg's `selectedEventId` 18; it re-scopes the
242
+ > whole app and must stay a permanently visible control of its own.
243
+ >
244
+ > Two traps, both already paid for elsewhere in this estate: **do not make an entry a built-in without
245
+ > verifying it in every app** (an earlier prototype had Benachrichtigungen as shared — only jg has it),
246
+ > and **labels arrive as finished strings, never i18n keys** (dcm MSG-13 is currently broken for exactly
247
+ > that reason).
248
+ >
249
+ > Expected integration for spesix and survey_app is `<UserMenu resolveLink={navigate} />` and nothing
250
+ > else. Scope E depends on dcm MSG-13's preference landing — if it has not, ship A–D and say so.
251
+
252
+ ## PROGRESS CONTRACT
253
+ Emit `PLAN: <steps>` up front, then a single-line `PROGRESS: [<n>/<total>] <action>` before every
254
+ relevant action and `PROGRESS: [<n>/<total>] done` on completion, spaced so no gap exceeds ~2 min,
255
+ stdout unbuffered, and exactly one final `RESULT: DONE|BLOCKED <reason>`.