@micha.bigler/ui-core-micha 2.13.0 → 2.14.0

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
@@ -22,5 +22,5 @@ prefixed ID; never reuse a bare flat number across workstreams.
22
22
  | ONB-1 | Per-app configurable notifications onboarding step | New `browserPush` prop `{nagUntil, showOnce}` on `OnboardingProvider`; parameterizes the `browser_push` descriptor's condition (default changes from implicit "all-channels" to "any-channel", stopping the over-nag); `showOnce` via a persisted `onboarding_seen` set (frozen-at-mount ref, no mid-session flicker) | 2026-07-17 | done | 1ae7c20 | Default behavior change affects cockpit and all consumers on their next ucm bump. jg-ferien companion WO pins the new version with `browserPush={{nagUntil: 'any-channel'}}` explicit (matches new default, but pinned explicitly per the WO). |
23
23
  | PERF-3B1 | Parallel auth bootstrap after CSRF | Starts auth-methods and current-user concurrently once CSRF is available, while preserving error handling and loading semantics. | 2026-07-19 | done | ee89329 | Independent `reviewer` + `sec_reviewer` passes both clean (no findings); one P3 test-coverage gap from the reviewer (missing mirror case: auth-methods rejects, current-user succeeds) closed with an added regression test — 45/45 tests green. Published as 2.10.1 (patch, no interface change) and pinned in jg-ferien alongside its PERF-3A companion WO. |
24
24
  | NOTIF-6 | NotificationsProvider + bell on canonical dcm feed/* API | Single-WS-owner `NotificationsProvider`/`useNotifications` (initial REST seed via `feedApi.js` + one `/ws/notifications/` socket, reconnect w/ backoff) + generic MUI `NotificationBell`; additive exports only | 2026-07-27 | done | 6c63fb9 | Codex-built direct (no nesting), independent `reviewer`: single-WS-owner invariant traced clean (no way to break it — `authenticated`/`replaceNotifications`/`refresh` all referentially stable across unrelated re-renders, re-render test is genuine not a no-op). R1 (P2, real bug): the `notification.status` WS handler patched item flags but never adjusted `unreadCount`, so the badge went stale after a cross-tab/cross-device mark — the test had locked in the stale count as expected behavior. Fixed: delta computed from before/after unread state on the WS patch path, mirroring `mark()`'s own logic; test corrected + a new cross-tab regression test added. 57 passing, build clean. No new npm dependency. Full history + backend contract: dcm's `WORK_ORDERS.md`/`docs/design/notifications-platform.md`. Publishes ucm 2.11.0 |
25
- | NOTIF-13 | Layer-1 transport extraction (pluggable realtime) | Extract the socket out of `NotificationsProvider` into a generic subscribe-by-message-type realtime primitive so a second domain (messaging) can ride the same socket; the catch-all that turns every non-`notification.status` message into a feed entry is replaced by explicit dispatch with unknown types ignored. Additive: `NotificationsProvider` stays the sole mount point, its context value unchanged. Full spec: [work-orders/NOTIF-13.md](work-orders/NOTIF-13.md). | 2026-07-29 | planned | | Tier 2 (cockpit consumes `NotificationsProvider` at 2.12.0 in prod). Phase A, the hinge: unblocks NOTIF-15 (jg bell adoption) and is the substrate for Phase B messaging. Canonical register row: django-core-micha `WORK_ORDERS.md`. Operator decision 2026-07-29: additive only, NO standalone `RealtimeProvider` apps must mount. Scope E (envelope contract + helper + doc) also touches dcm; note that the roadmap wording "dcm multi-stream consumer" overstates it — `push_to_users`/`NotificationConsumer` are already stream-agnostic. |
26
- | NOTIF-12 | Popup channel via the wizard renderer | Make `popup` a real delivery channel: extract the presentational dialog shell out of `OnboardingWizard`, render popup-eligible notifications through it, and record seen/dismissed on `NotificationRecipient` (design D-F7: renderer reuse only, NOT the onboarding progress store); dcm `PopupDispatcher` stops being a logging stub. Full spec: [work-orders/NOTIF-12.md](work-orders/NOTIF-12.md). | 2026-07-29 | planned | | Tier 2 (extracts a renderer cockpit+jg run in production; the roadmap label "uncritical" is about urgency, not blast radius). **HARD GATE: starts only after NOTIF-13 landed + published** — consumes its envelope contract and `subscribe()`, and edits the same ucm files (AGENTS.md overlap rule). **SHIPS WITH ZERO PRODUCERS, deliberately** (operator decision 2026-07-29, made with the evidence): no notification type declares `popup` in `eligible_channels` anywhere in dcm/jg/cockpit/hram/spesix/survey_app, so the channel is wired but inactive until an app opts a type in. WO therefore requires (a) CHANGELOG wording that does not overclaim (NOTIF-4 R1 precedent) and (b) a test-local producer type proving the path end-to-end, so it is not untested dead code. Was NOTIF-11 (renumbered 2026-07-29 for the jg cutover/drop split). |
25
+ | NOTIF-13 | Layer-1 transport extraction (pluggable realtime) | Extract the socket out of `NotificationsProvider` into a generic subscribe-by-message-type realtime primitive so a second domain (messaging) can ride the same socket; the catch-all that turns every non-`notification.status` message into a feed entry is replaced by explicit dispatch with unknown types ignored. Additive: `NotificationsProvider` stays the sole mount point, its context value unchanged. Full spec: [work-orders/NOTIF-13.md](work-orders/NOTIF-13.md). | 2026-07-29 | done | ucm: 7a83ee9; dcm: de77335 | Tier 2 (cockpit consumes `NotificationsProvider` at 2.12.0 in prod). Phase A, the hinge: unblocks NOTIF-15 (jg bell adoption) and is the substrate for Phase B messaging. Canonical register row: django-core-micha `WORK_ORDERS.md`. Operator decision 2026-07-29: additive only, NO standalone `RealtimeProvider` apps must mount. Scope E (envelope contract + helper + doc) also touches dcm; note that the roadmap wording "dcm multi-stream consumer" overstates it — `push_to_users`/`NotificationConsumer` are already stream-agnostic. Codex failed twice on this WO with a genuine CLI/model-version error (`gpt-5.6-terra requires a newer version of Codex`), not a transient issue — orchestrator fell back to direct Claude implementation per the Codex→Claude fallback rule. Independent `reviewer` (sec_reviewer not required per WO): found R1 (P2, real bug — decoupling the REST seed from the socket connect opened a race where a live push arriving before the initial feed loaded would be silently discarded by `refresh()`'s unconditional `replaceNotifications()`; fixed via a `seeded` state gate restoring the exact original seed-then-connect ordering, re-verified 70/70 green), R2 (P3 — the WO's "must be called out in the CHANGELOG" has no home since ucm has no CHANGELOG.md convention at all; operator decision: document via commit message + this register entry only, no new file), R3 (register housekeeping, closed by this update). ucm full suite 70/70 + `pnpm build` clean; dcm notifications suite 92/92, full dcm suite 392/392. Published: ucm 2.13.0 (npm), dcm 2.33.0 (PyPI, verified live before this record) — no app pin bumps in this WO per spec. |
26
+ | NOTIF-12 | Popup channel via the wizard renderer | Make `popup` a real delivery channel: extract the presentational dialog shell out of `OnboardingWizard`, render popup-eligible notifications through it, and record seen/dismissed on `NotificationRecipient` (design D-F7: renderer reuse only, NOT the onboarding progress store); dcm `PopupDispatcher` stops being a logging stub. Full spec: [work-orders/NOTIF-12.md](work-orders/NOTIF-12.md). | 2026-07-29 | planned | | Tier 2 (extracts a renderer cockpit+jg run in production; the roadmap label "uncritical" is about urgency, not blast radius). **HARD GATE: starts only after NOTIF-13 landed + published** — consumes its envelope contract and `subscribe()`, and edits the same ucm files (AGENTS.md overlap rule). **SHIPS WITH ZERO PRODUCERS, deliberately** (operator decision 2026-07-29, made with the evidence): no notification type declares `popup` in `eligible_channels` anywhere in dcm/jg/cockpit/hram/spesix/survey_app, so the channel is wired but inactive until an app opts a type in. WO therefore requires (a) CHANGELOG wording that does not overclaim (NOTIF-4 R1 precedent) and (b) a test-local producer type proving the path end-to-end, so it is not untested dead code. Was NOTIF-11 (renumbered 2026-07-29 for the jg cutover/drop split). **Gate satisfied 2026-07-30** (ucm 2.13.0 / dcm 2.33.0); WO re-verified against the landed NOTIF-13 code and corrected in `345631d`: NOTIF-13 shipped the envelope as a **domain**-level discriminator, so a popup payload built the same way as a chip payload would be indistinguishable from it - the WO now mandates a `channel` field inside the envelope (never a second envelope value) plus feed de-duplication by `notification_id`, without which chip+popup double-counts the unread badge. |
@@ -17,4 +17,6 @@ export const notificationsTranslations = {
17
17
  'NotificationSettings.PUSH_CONFLICT': { de: 'Dieses Gerät ist bereits mit einem anderen Konto für Push-Benachrichtigungen verbunden.', fr: 'Cet appareil est déjà associé à un autre compte pour les notifications push.', en: 'This device is already associated with another account for push notifications.', sw: 'Kifaa hiki tayari kimeunganishwa na akaunti nyingine kwa arifa za push.' },
18
18
  'NotificationSettings.PUSH_NOT_SUPPORTED': { de: 'Push-Benachrichtigungen werden von diesem Browser nicht unterstützt.', fr: 'Les notifications push ne sont pas prises en charge par ce navigateur.', en: 'Push notifications are not supported by this browser.', sw: 'Arifa za push hazitumiki na kivinjari hiki.' },
19
19
  'NotificationSettings.IOS_HINT': { de: 'Füge diese App zuerst zum Home-Bildschirm hinzu, um Push-Benachrichtigungen zu aktivieren.', fr: 'Ajoutez d’abord cette application à l’écran d’accueil pour activer les notifications push.', en: 'Add this app to your home screen first to enable push notifications.', sw: 'Ongeza programu hii kwenye skrini yako ya mwanzo kwanza ili kuwasha arifa za push.' },
20
+ 'PopupSurface.CLOSE': { de: 'Schliessen', fr: 'Fermer', en: 'Close', sw: 'Funga' },
21
+ 'PopupSurface.TITLE': { de: 'Benachrichtigung', fr: 'Notification', en: 'Notification', sw: 'Arifa' },
20
22
  };
package/dist/index.js CHANGED
@@ -35,6 +35,7 @@ export * from './notifications/api';
35
35
  export { NotificationsProvider, useNotifications } from './notifications/NotificationsProvider';
36
36
  export { useRealtime } from './notifications/realtime';
37
37
  export { NotificationBell } from './notifications/NotificationBell';
38
+ export { PopupSurface } from './notifications/PopupSurface';
38
39
  export { getNotificationFeed, getUnreadCount, markNotifications } from './notifications/feedApi';
39
40
  // --- 8. Onboarding ---
40
41
  export * from './onboarding/api';
@@ -91,6 +91,19 @@ export function NotificationsProvider({ children, wsUrlBase }) {
91
91
  return;
92
92
  }
93
93
  const pushedNotification = normalizeNotificationPush(data);
94
+ // NOTIF-12 D2: a notification routed to more than one channel (e.g. chip
95
+ // + popup) arrives as multiple WS messages sharing one notification_id.
96
+ // Fold repeats into the existing feed entry (last-write-wins on content)
97
+ // instead of appending a duplicate and double-incrementing the badge.
98
+ const existingIndex = pushedNotification.notification_id == null
99
+ ? -1
100
+ : notificationsRef.current.findIndex((notification) => notification.notification_id === pushedNotification.notification_id);
101
+ if (existingIndex !== -1) {
102
+ const nextNotifications = notificationsRef.current.map((notification, index) => (index === existingIndex
103
+ ? Object.assign(Object.assign({}, notification), { notification_type: pushedNotification.notification_type, content: pushedNotification.content }) : notification));
104
+ replaceNotifications(nextNotifications);
105
+ return;
106
+ }
94
107
  replaceNotifications([pushedNotification, ...notificationsRef.current]);
95
108
  setUnreadCount((count) => count + 1);
96
109
  }, [replaceNotifications]);
@@ -0,0 +1,84 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useRef, useState } from 'react';
3
+ import { useTranslation } from 'react-i18next';
4
+ import Button from '@mui/material/Button';
5
+ import Stack from '@mui/material/Stack';
6
+ import Typography from '@mui/material/Typography';
7
+ import { useOnboarding } from '../onboarding/OnboardingProvider';
8
+ import { WizardDialogShell } from '../onboarding/WizardDialogShell';
9
+ import { useNotifications } from './NotificationsProvider';
10
+ import { DEFAULT_ENVELOPE, useRealtime } from './realtime';
11
+ function PopupStepComponent({ onDismiss, ctx }) {
12
+ const { t } = useTranslation();
13
+ const content = (ctx === null || ctx === void 0 ? void 0 : ctx.content) || {};
14
+ return (_jsxs(Stack, { spacing: 2, children: [content.body_key && (_jsx(Typography, { variant: "body1", children: t(content.body_key, content.params || {}) })), _jsx(Button, { onClick: onDismiss, sx: { alignSelf: 'flex-end' }, children: t('PopupSurface.CLOSE') })] }));
15
+ }
16
+ const POPUP_STEP = { blocking: false, Component: PopupStepComponent };
17
+ /**
18
+ * Renders popup-eligible notifications (NOTIF-12) through the onboarding
19
+ * wizard's dialog shell, reusing only the renderer (design D-F7) — status
20
+ * lives on NotificationRecipient via the existing feed/mark path, never in
21
+ * the onboarding progress store.
22
+ *
23
+ * Deliberately session-live only: it watches the raw realtime stream for
24
+ * `channel === "popup"` messages rather than the already-seeded feed list,
25
+ * since the feed carries no per-channel discriminator (see NotificationsProvider).
26
+ */
27
+ export function PopupSurface() {
28
+ var _a;
29
+ const { t } = useTranslation();
30
+ const { markSeen, markDismissed } = useNotifications();
31
+ const { subscribe } = useRealtime();
32
+ const onboarding = useOnboarding();
33
+ const [queue, setQueue] = useState([]);
34
+ const seenIdsRef = useRef(new Set());
35
+ const handleRealtimeMessage = useCallback((data) => {
36
+ if (data.type === 'notification.status')
37
+ return;
38
+ if (data.channel !== 'popup')
39
+ return;
40
+ setQueue((previous) => {
41
+ var _a;
42
+ if (previous.some((item) => item.notification_id === data.notification_id))
43
+ return previous;
44
+ return [...previous, {
45
+ notification_id: data.notification_id,
46
+ // recipient_id is the id feed/mark/ actually resolves against
47
+ // (NotificationRecipient.pk, not Notification.pk). Fall back to
48
+ // notification_id only for an older dcm that predates it — matching
49
+ // NotificationsProvider's own normalizeNotificationPush precedent.
50
+ markId: (_a = data.recipient_id) !== null && _a !== void 0 ? _a : data.notification_id,
51
+ content: data.content || {},
52
+ }];
53
+ });
54
+ }, []);
55
+ useEffect(() => subscribe(DEFAULT_ENVELOPE, handleRealtimeMessage), [subscribe, handleRealtimeMessage]);
56
+ // Any active onboarding step — blocking or not — must win: OnboardingWizard
57
+ // renders its own dialog for as long as onboarding.activeSteps is non-empty,
58
+ // and two independent MUI Dialogs must never be mounted at once.
59
+ const onboardingActive = Boolean((_a = onboarding === null || onboarding === void 0 ? void 0 : onboarding.activeSteps) === null || _a === void 0 ? void 0 : _a.length);
60
+ const activePopup = queue[0];
61
+ useEffect(() => {
62
+ if (!activePopup || onboardingActive)
63
+ return;
64
+ if (activePopup.markId == null)
65
+ return;
66
+ if (seenIdsRef.current.has(activePopup.notification_id))
67
+ return;
68
+ seenIdsRef.current.add(activePopup.notification_id);
69
+ markSeen([activePopup.markId]);
70
+ }, [activePopup, onboardingActive, markSeen]);
71
+ const dismissActivePopup = useCallback(() => {
72
+ if (!activePopup)
73
+ return;
74
+ if (activePopup.markId != null)
75
+ markDismissed([activePopup.markId]);
76
+ setQueue((previous) => previous.filter((item) => item.notification_id !== activePopup.notification_id));
77
+ }, [activePopup, markDismissed]);
78
+ if (!activePopup || onboardingActive)
79
+ return null;
80
+ return (_jsx(WizardDialogShell, { step: POPUP_STEP, stepIndex: 0, total: 1, onComplete: dismissActivePopup, onDismiss: dismissActivePopup, ctx: { content: activePopup.content }, title: activePopup.content.title_key
81
+ ? t(activePopup.content.title_key, activePopup.content.params || {})
82
+ : t('PopupSurface.TITLE') }));
83
+ }
84
+ export default PopupSurface;
@@ -1,16 +1,9 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import React, { useContext, useEffect, useRef, useState } from 'react';
3
- import { useTranslation } from 'react-i18next';
4
- import Box from '@mui/material/Box';
5
- import Dialog from '@mui/material/Dialog';
6
- import DialogContent from '@mui/material/DialogContent';
7
- import DialogTitle from '@mui/material/DialogTitle';
8
- import LinearProgress from '@mui/material/LinearProgress';
9
- import Typography from '@mui/material/Typography';
10
3
  import { AuthContext } from '../auth/AuthContext';
11
4
  import { useOnboarding } from './OnboardingProvider';
5
+ import { WizardDialogShell } from './WizardDialogShell';
12
6
  export function OnboardingWizard() {
13
- const { t } = useTranslation();
14
7
  const onboarding = useOnboarding();
15
8
  const authContext = useContext(AuthContext);
16
9
  const [sessionDismissed, setSessionDismissed] = useState(() => new Set());
@@ -37,8 +30,6 @@ export function OnboardingWizard() {
37
30
  return null;
38
31
  const { dismissStep, ctx } = onboarding;
39
32
  const total = totalRef.current || visibleSteps.length;
40
- const progress = total > 1 ? Math.round((completed / total) * 100) : 0;
41
- const StepComponent = currentStep.Component;
42
33
  const completeCurrentStep = () => {
43
34
  var _a, _b;
44
35
  setSessionDismissed((previous) => new Set([...previous, currentStep.id]));
@@ -52,6 +43,6 @@ export function OnboardingWizard() {
52
43
  dismissStep(currentStep.id);
53
44
  completeCurrentStep();
54
45
  };
55
- return (_jsxs(Dialog, { open: true, fullWidth: true, maxWidth: "sm", disableEscapeKeyDown: currentStep.blocking, onClose: currentStep.blocking ? undefined : dismissCurrentStep, children: [_jsxs(DialogTitle, { sx: { pb: 0 }, children: [_jsx(Box, { sx: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' }, children: _jsx(Typography, { variant: "subtitle2", color: "text.secondary", children: total > 1 ? t('Onboarding.STEP_COUNTER', { current: completed + 1, total }) : t('Onboarding.SETUP') }) }), total > 1 && _jsx(LinearProgress, { variant: "determinate", value: progress, sx: { mt: 1, borderRadius: 1 } })] }), _jsx(DialogContent, { sx: { pt: 2 }, children: _jsx(StepComponent, { onComplete: completeCurrentStep, onDismiss: dismissCurrentStep, ctx: ctx }) })] }));
46
+ return (_jsx(WizardDialogShell, { step: currentStep, stepIndex: completed, total: total, onComplete: completeCurrentStep, onDismiss: dismissCurrentStep, ctx: ctx }));
56
47
  }
57
48
  export default OnboardingWizard;
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { useTranslation } from 'react-i18next';
4
+ import Box from '@mui/material/Box';
5
+ import Dialog from '@mui/material/Dialog';
6
+ import DialogContent from '@mui/material/DialogContent';
7
+ import DialogTitle from '@mui/material/DialogTitle';
8
+ import LinearProgress from '@mui/material/LinearProgress';
9
+ import Typography from '@mui/material/Typography';
10
+ /**
11
+ * Presentational dialog shell shared by OnboardingWizard and the popup
12
+ * notification surface (NOTIF-12). Owns only the dialog frame, the
13
+ * counter/progress header, and the onComplete/onDismiss/ctx child contract —
14
+ * step selection, dismissal persistence, and any progress store stay with
15
+ * the caller.
16
+ */
17
+ export function WizardDialogShell({ step, stepIndex, total, onComplete, onDismiss, ctx, title }) {
18
+ const { t } = useTranslation();
19
+ const progress = total > 1 ? Math.round((stepIndex / total) * 100) : 0;
20
+ const StepComponent = step.Component;
21
+ // `title` lets a non-onboarding caller (e.g. the popup surface) supply its
22
+ // own header instead of the onboarding-specific step counter/"Setup" copy.
23
+ // Omitted by OnboardingWizard, so its header text is unchanged.
24
+ const headerLabel = title !== null && title !== void 0 ? title : (total > 1
25
+ ? t('Onboarding.STEP_COUNTER', { current: stepIndex + 1, total })
26
+ : t('Onboarding.SETUP'));
27
+ return (_jsxs(Dialog, { open: true, fullWidth: true, maxWidth: "sm", disableEscapeKeyDown: step.blocking, onClose: step.blocking ? undefined : onDismiss, children: [_jsxs(DialogTitle, { sx: { pb: 0 }, children: [_jsx(Box, { sx: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' }, children: _jsx(Typography, { variant: "subtitle2", color: "text.secondary", children: headerLabel }) }), total > 1 && _jsx(LinearProgress, { variant: "determinate", value: progress, sx: { mt: 1, borderRadius: 1 } })] }), _jsx(DialogContent, { sx: { pt: 2 }, children: _jsx(StepComponent, { onComplete: onComplete, onDismiss: onDismiss, ctx: ctx }) })] }));
28
+ }
29
+ export default WizardDialogShell;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.13.0",
3
+ "version": "2.14.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "repository": {
@@ -17,4 +17,6 @@ export const notificationsTranslations = {
17
17
  'NotificationSettings.PUSH_CONFLICT': { de: 'Dieses Gerät ist bereits mit einem anderen Konto für Push-Benachrichtigungen verbunden.', fr: 'Cet appareil est déjà associé à un autre compte pour les notifications push.', en: 'This device is already associated with another account for push notifications.', sw: 'Kifaa hiki tayari kimeunganishwa na akaunti nyingine kwa arifa za push.' },
18
18
  'NotificationSettings.PUSH_NOT_SUPPORTED': { de: 'Push-Benachrichtigungen werden von diesem Browser nicht unterstützt.', fr: 'Les notifications push ne sont pas prises en charge par ce navigateur.', en: 'Push notifications are not supported by this browser.', sw: 'Arifa za push hazitumiki na kivinjari hiki.' },
19
19
  'NotificationSettings.IOS_HINT': { de: 'Füge diese App zuerst zum Home-Bildschirm hinzu, um Push-Benachrichtigungen zu aktivieren.', fr: 'Ajoutez d’abord cette application à l’écran d’accueil pour activer les notifications push.', en: 'Add this app to your home screen first to enable push notifications.', sw: 'Ongeza programu hii kwenye skrini yako ya mwanzo kwanza ili kuwasha arifa za push.' },
20
+ 'PopupSurface.CLOSE': { de: 'Schliessen', fr: 'Fermer', en: 'Close', sw: 'Funga' },
21
+ 'PopupSurface.TITLE': { de: 'Benachrichtigung', fr: 'Notification', en: 'Notification', sw: 'Arifa' },
20
22
  };
package/src/index.js CHANGED
@@ -48,6 +48,7 @@ export * from './notifications/api';
48
48
  export { NotificationsProvider, useNotifications } from './notifications/NotificationsProvider';
49
49
  export { useRealtime } from './notifications/realtime';
50
50
  export { NotificationBell } from './notifications/NotificationBell';
51
+ export { PopupSurface } from './notifications/PopupSurface';
51
52
  export { getNotificationFeed, getUnreadCount, markNotifications } from './notifications/feedApi';
52
53
 
53
54
  // --- 8. Onboarding ---
@@ -116,6 +116,27 @@ export function NotificationsProvider({ children, wsUrlBase }) {
116
116
  }
117
117
 
118
118
  const pushedNotification = normalizeNotificationPush(data);
119
+
120
+ // NOTIF-12 D2: a notification routed to more than one channel (e.g. chip
121
+ // + popup) arrives as multiple WS messages sharing one notification_id.
122
+ // Fold repeats into the existing feed entry (last-write-wins on content)
123
+ // instead of appending a duplicate and double-incrementing the badge.
124
+ const existingIndex = pushedNotification.notification_id == null
125
+ ? -1
126
+ : notificationsRef.current.findIndex(
127
+ (notification) => notification.notification_id === pushedNotification.notification_id,
128
+ );
129
+
130
+ if (existingIndex !== -1) {
131
+ const nextNotifications = notificationsRef.current.map((notification, index) => (
132
+ index === existingIndex
133
+ ? { ...notification, notification_type: pushedNotification.notification_type, content: pushedNotification.content }
134
+ : notification
135
+ ));
136
+ replaceNotifications(nextNotifications);
137
+ return;
138
+ }
139
+
119
140
  replaceNotifications([pushedNotification, ...notificationsRef.current]);
120
141
  setUnreadCount((count) => count + 1);
121
142
  }, [replaceNotifications]);
@@ -0,0 +1,103 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import Button from '@mui/material/Button';
4
+ import Stack from '@mui/material/Stack';
5
+ import Typography from '@mui/material/Typography';
6
+ import { useOnboarding } from '../onboarding/OnboardingProvider';
7
+ import { WizardDialogShell } from '../onboarding/WizardDialogShell';
8
+ import { useNotifications } from './NotificationsProvider';
9
+ import { DEFAULT_ENVELOPE, useRealtime } from './realtime';
10
+
11
+ function PopupStepComponent({ onDismiss, ctx }) {
12
+ const { t } = useTranslation();
13
+ const content = ctx?.content || {};
14
+
15
+ return (
16
+ <Stack spacing={2}>
17
+ {content.body_key && (
18
+ <Typography variant="body1">{t(content.body_key, content.params || {})}</Typography>
19
+ )}
20
+ <Button onClick={onDismiss} sx={{ alignSelf: 'flex-end' }}>
21
+ {t('PopupSurface.CLOSE')}
22
+ </Button>
23
+ </Stack>
24
+ );
25
+ }
26
+
27
+ const POPUP_STEP = { blocking: false, Component: PopupStepComponent };
28
+
29
+ /**
30
+ * Renders popup-eligible notifications (NOTIF-12) through the onboarding
31
+ * wizard's dialog shell, reusing only the renderer (design D-F7) — status
32
+ * lives on NotificationRecipient via the existing feed/mark path, never in
33
+ * the onboarding progress store.
34
+ *
35
+ * Deliberately session-live only: it watches the raw realtime stream for
36
+ * `channel === "popup"` messages rather than the already-seeded feed list,
37
+ * since the feed carries no per-channel discriminator (see NotificationsProvider).
38
+ */
39
+ export function PopupSurface() {
40
+ const { t } = useTranslation();
41
+ const { markSeen, markDismissed } = useNotifications();
42
+ const { subscribe } = useRealtime();
43
+ const onboarding = useOnboarding();
44
+ const [queue, setQueue] = useState([]);
45
+ const seenIdsRef = useRef(new Set());
46
+
47
+ const handleRealtimeMessage = useCallback((data) => {
48
+ if (data.type === 'notification.status') return;
49
+ if (data.channel !== 'popup') return;
50
+ setQueue((previous) => {
51
+ if (previous.some((item) => item.notification_id === data.notification_id)) return previous;
52
+ return [...previous, {
53
+ notification_id: data.notification_id,
54
+ // recipient_id is the id feed/mark/ actually resolves against
55
+ // (NotificationRecipient.pk, not Notification.pk). Fall back to
56
+ // notification_id only for an older dcm that predates it — matching
57
+ // NotificationsProvider's own normalizeNotificationPush precedent.
58
+ markId: data.recipient_id ?? data.notification_id,
59
+ content: data.content || {},
60
+ }];
61
+ });
62
+ }, []);
63
+
64
+ useEffect(() => subscribe(DEFAULT_ENVELOPE, handleRealtimeMessage), [subscribe, handleRealtimeMessage]);
65
+
66
+ // Any active onboarding step — blocking or not — must win: OnboardingWizard
67
+ // renders its own dialog for as long as onboarding.activeSteps is non-empty,
68
+ // and two independent MUI Dialogs must never be mounted at once.
69
+ const onboardingActive = Boolean(onboarding?.activeSteps?.length);
70
+ const activePopup = queue[0];
71
+
72
+ useEffect(() => {
73
+ if (!activePopup || onboardingActive) return;
74
+ if (activePopup.markId == null) return;
75
+ if (seenIdsRef.current.has(activePopup.notification_id)) return;
76
+ seenIdsRef.current.add(activePopup.notification_id);
77
+ markSeen([activePopup.markId]);
78
+ }, [activePopup, onboardingActive, markSeen]);
79
+
80
+ const dismissActivePopup = useCallback(() => {
81
+ if (!activePopup) return;
82
+ if (activePopup.markId != null) markDismissed([activePopup.markId]);
83
+ setQueue((previous) => previous.filter((item) => item.notification_id !== activePopup.notification_id));
84
+ }, [activePopup, markDismissed]);
85
+
86
+ if (!activePopup || onboardingActive) return null;
87
+
88
+ return (
89
+ <WizardDialogShell
90
+ step={POPUP_STEP}
91
+ stepIndex={0}
92
+ total={1}
93
+ onComplete={dismissActivePopup}
94
+ onDismiss={dismissActivePopup}
95
+ ctx={{ content: activePopup.content }}
96
+ title={activePopup.content.title_key
97
+ ? t(activePopup.content.title_key, activePopup.content.params || {})
98
+ : t('PopupSurface.TITLE')}
99
+ />
100
+ );
101
+ }
102
+
103
+ export default PopupSurface;
@@ -1,16 +1,9 @@
1
1
  import React, { useContext, useEffect, useRef, useState } from 'react';
2
- import { useTranslation } from 'react-i18next';
3
- import Box from '@mui/material/Box';
4
- import Dialog from '@mui/material/Dialog';
5
- import DialogContent from '@mui/material/DialogContent';
6
- import DialogTitle from '@mui/material/DialogTitle';
7
- import LinearProgress from '@mui/material/LinearProgress';
8
- import Typography from '@mui/material/Typography';
9
2
  import { AuthContext } from '../auth/AuthContext';
10
3
  import { useOnboarding } from './OnboardingProvider';
4
+ import { WizardDialogShell } from './WizardDialogShell';
11
5
 
12
6
  export function OnboardingWizard() {
13
- const { t } = useTranslation();
14
7
  const onboarding = useOnboarding();
15
8
  const authContext = useContext(AuthContext);
16
9
  const [sessionDismissed, setSessionDismissed] = useState(() => new Set());
@@ -39,8 +32,6 @@ export function OnboardingWizard() {
39
32
 
40
33
  const { dismissStep, ctx } = onboarding;
41
34
  const total = totalRef.current || visibleSteps.length;
42
- const progress = total > 1 ? Math.round((completed / total) * 100) : 0;
43
- const StepComponent = currentStep.Component;
44
35
 
45
36
  const completeCurrentStep = () => {
46
37
  setSessionDismissed((previous) => new Set([...previous, currentStep.id]));
@@ -56,19 +47,14 @@ export function OnboardingWizard() {
56
47
  };
57
48
 
58
49
  return (
59
- <Dialog open fullWidth maxWidth="sm" disableEscapeKeyDown={currentStep.blocking} onClose={currentStep.blocking ? undefined : dismissCurrentStep}>
60
- <DialogTitle sx={{ pb: 0 }}>
61
- <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
62
- <Typography variant="subtitle2" color="text.secondary">
63
- {total > 1 ? t('Onboarding.STEP_COUNTER', { current: completed + 1, total }) : t('Onboarding.SETUP')}
64
- </Typography>
65
- </Box>
66
- {total > 1 && <LinearProgress variant="determinate" value={progress} sx={{ mt: 1, borderRadius: 1 }} />}
67
- </DialogTitle>
68
- <DialogContent sx={{ pt: 2 }}>
69
- <StepComponent onComplete={completeCurrentStep} onDismiss={dismissCurrentStep} ctx={ctx} />
70
- </DialogContent>
71
- </Dialog>
50
+ <WizardDialogShell
51
+ step={currentStep}
52
+ stepIndex={completed}
53
+ total={total}
54
+ onComplete={completeCurrentStep}
55
+ onDismiss={dismissCurrentStep}
56
+ ctx={ctx}
57
+ />
72
58
  );
73
59
  }
74
60
 
@@ -0,0 +1,51 @@
1
+ import React from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import Box from '@mui/material/Box';
4
+ import Dialog from '@mui/material/Dialog';
5
+ import DialogContent from '@mui/material/DialogContent';
6
+ import DialogTitle from '@mui/material/DialogTitle';
7
+ import LinearProgress from '@mui/material/LinearProgress';
8
+ import Typography from '@mui/material/Typography';
9
+
10
+ /**
11
+ * Presentational dialog shell shared by OnboardingWizard and the popup
12
+ * notification surface (NOTIF-12). Owns only the dialog frame, the
13
+ * counter/progress header, and the onComplete/onDismiss/ctx child contract —
14
+ * step selection, dismissal persistence, and any progress store stay with
15
+ * the caller.
16
+ */
17
+ export function WizardDialogShell({ step, stepIndex, total, onComplete, onDismiss, ctx, title }) {
18
+ const { t } = useTranslation();
19
+ const progress = total > 1 ? Math.round((stepIndex / total) * 100) : 0;
20
+ const StepComponent = step.Component;
21
+ // `title` lets a non-onboarding caller (e.g. the popup surface) supply its
22
+ // own header instead of the onboarding-specific step counter/"Setup" copy.
23
+ // Omitted by OnboardingWizard, so its header text is unchanged.
24
+ const headerLabel = title ?? (total > 1
25
+ ? t('Onboarding.STEP_COUNTER', { current: stepIndex + 1, total })
26
+ : t('Onboarding.SETUP'));
27
+
28
+ return (
29
+ <Dialog
30
+ open
31
+ fullWidth
32
+ maxWidth="sm"
33
+ disableEscapeKeyDown={step.blocking}
34
+ onClose={step.blocking ? undefined : onDismiss}
35
+ >
36
+ <DialogTitle sx={{ pb: 0 }}>
37
+ <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
38
+ <Typography variant="subtitle2" color="text.secondary">
39
+ {headerLabel}
40
+ </Typography>
41
+ </Box>
42
+ {total > 1 && <LinearProgress variant="determinate" value={progress} sx={{ mt: 1, borderRadius: 1 }} />}
43
+ </DialogTitle>
44
+ <DialogContent sx={{ pt: 2 }}>
45
+ <StepComponent onComplete={onComplete} onDismiss={onDismiss} ctx={ctx} />
46
+ </DialogContent>
47
+ </Dialog>
48
+ );
49
+ }
50
+
51
+ export default WizardDialogShell;
@@ -195,4 +195,42 @@ describe('NotificationsProvider', () => {
195
195
  expect(currentNotifications()[0]).toEqual(expect.objectContaining({ done: true }));
196
196
  expect(feedApi.markNotifications).toHaveBeenCalledWith({ action: 'done', ids: [1] });
197
197
  });
198
+
199
+ it('folds two WS messages for the same notification_id into one feed entry with a single unread increment (NOTIF-12 D2)', async () => {
200
+ renderProvider();
201
+ await waitFor(() => expect(screen.getByTestId('unread-count').textContent).toBe('2'));
202
+ await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
203
+
204
+ MockWebSocket.instances[0].onmessage({ data: JSON.stringify({
205
+ type: 'payment_due',
206
+ channel: 'chip',
207
+ notification_id: 404,
208
+ content: { title_key: 'chip.title' },
209
+ }) });
210
+ await waitFor(() => expect(screen.getByTestId('unread-count').textContent).toBe('3'));
211
+
212
+ MockWebSocket.instances[0].onmessage({ data: JSON.stringify({
213
+ type: 'payment_due',
214
+ channel: 'popup',
215
+ notification_id: 404,
216
+ content: { title_key: 'popup.title' },
217
+ }) });
218
+
219
+ await waitFor(() => expect(currentNotifications().find((n) => n.notification_id === 404).content)
220
+ .toEqual({ title_key: 'popup.title' }));
221
+ expect(currentNotifications().filter((n) => n.notification_id === 404)).toHaveLength(1);
222
+ expect(screen.getByTestId('unread-count').textContent).toBe('3');
223
+ });
224
+
225
+ it('NOTIF-13 R1 guard: does not open the socket until the REST seed resolves', async () => {
226
+ let resolveFeed;
227
+ feedApi.getNotificationFeed.mockReturnValue(new Promise((resolve) => { resolveFeed = resolve; }));
228
+
229
+ renderProvider();
230
+
231
+ expect(MockWebSocket.instances).toHaveLength(0);
232
+
233
+ resolveFeed({ results: [] });
234
+ await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
235
+ });
198
236
  });
@@ -0,0 +1,143 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
5
+
6
+ vi.mock('react-i18next', () => ({
7
+ useTranslation: () => ({
8
+ t: (key, params) => (params && Object.keys(params).length ? `${key}:${JSON.stringify(params)}` : key),
9
+ }),
10
+ }));
11
+
12
+ const onboardingModule = vi.hoisted(() => ({ useOnboarding: vi.fn() }));
13
+ vi.mock('../src/onboarding/OnboardingProvider', () => onboardingModule);
14
+
15
+ import { AuthContext } from '../src/auth/AuthContext';
16
+ import { OnboardingWizard } from '../src/onboarding/OnboardingWizard';
17
+
18
+ function StepBody({ onComplete, onDismiss }) {
19
+ return (
20
+ <div>
21
+ <button onClick={onComplete}>step-complete</button>
22
+ <button onClick={onDismiss}>step-dismiss</button>
23
+ </div>
24
+ );
25
+ }
26
+
27
+ function makeStep(overrides = {}) {
28
+ return {
29
+ id: 'step-1',
30
+ blocking: false,
31
+ persistDismissed: false,
32
+ Component: StepBody,
33
+ ...overrides,
34
+ };
35
+ }
36
+
37
+ function renderWizard({ onboarding, refreshUser = vi.fn().mockResolvedValue() } = {}) {
38
+ onboardingModule.useOnboarding.mockReturnValue(onboarding);
39
+ return render(
40
+ <AuthContext.Provider value={{ refreshUser }}>
41
+ <OnboardingWizard />
42
+ </AuthContext.Provider>,
43
+ );
44
+ }
45
+
46
+ describe('OnboardingWizard regression guard (NOTIF-12 shell extraction)', () => {
47
+ afterEach(() => {
48
+ cleanup();
49
+ vi.clearAllMocks();
50
+ });
51
+
52
+ it('renders nothing when there is no active step', () => {
53
+ renderWizard({ onboarding: { activeSteps: [], markStepSeen: vi.fn(), dismissStep: vi.fn(), ctx: {} } });
54
+
55
+ expect(screen.queryByRole('dialog')).toBeNull();
56
+ });
57
+
58
+ it('shows the single-step SETUP title with no progress bar for exactly one step', () => {
59
+ renderWizard({ onboarding: {
60
+ activeSteps: [makeStep()],
61
+ markStepSeen: vi.fn(),
62
+ dismissStep: vi.fn(),
63
+ ctx: {},
64
+ } });
65
+
66
+ expect(screen.getByText('Onboarding.SETUP')).toBeTruthy();
67
+ expect(screen.queryByRole('progressbar')).toBeNull();
68
+ });
69
+
70
+ it('shows the step counter and progress bar across multiple steps, advancing on completion', () => {
71
+ renderWizard({ onboarding: {
72
+ activeSteps: [makeStep({ id: 'a' }), makeStep({ id: 'b' })],
73
+ markStepSeen: vi.fn(),
74
+ dismissStep: vi.fn(),
75
+ ctx: {},
76
+ } });
77
+
78
+ expect(screen.getByText('Onboarding.STEP_COUNTER:{"current":1,"total":2}')).toBeTruthy();
79
+ expect(screen.getByRole('progressbar')).toBeTruthy();
80
+
81
+ fireEvent.click(screen.getByRole('button', { name: 'step-complete' }));
82
+
83
+ expect(screen.getByText('Onboarding.STEP_COUNTER:{"current":2,"total":2}')).toBeTruthy();
84
+ });
85
+
86
+ it('marks the current step seen and session-dismisses it once completed, revealing the next step', () => {
87
+ const markStepSeen = vi.fn();
88
+ renderWizard({ onboarding: {
89
+ activeSteps: [makeStep({ id: 'a' }), makeStep({ id: 'b' })],
90
+ markStepSeen,
91
+ dismissStep: vi.fn(),
92
+ ctx: {},
93
+ } });
94
+
95
+ expect(markStepSeen).toHaveBeenCalledWith('a');
96
+
97
+ fireEvent.click(screen.getByRole('button', { name: 'step-complete' }));
98
+
99
+ expect(markStepSeen).toHaveBeenCalledWith('b');
100
+ });
101
+
102
+ it('disables escape-key close for a blocking step', () => {
103
+ renderWizard({ onboarding: {
104
+ activeSteps: [makeStep({ blocking: true })],
105
+ markStepSeen: vi.fn(),
106
+ dismissStep: vi.fn(),
107
+ ctx: {},
108
+ } });
109
+
110
+ const dialog = screen.getByRole('dialog');
111
+ fireEvent.keyDown(dialog, { key: 'Escape', code: 'Escape' });
112
+
113
+ expect(screen.getByRole('dialog')).toBeTruthy();
114
+ });
115
+
116
+ it('calls dismissStep only when the dismissed step is persistDismissed', () => {
117
+ const dismissStep = vi.fn();
118
+ renderWizard({ onboarding: {
119
+ activeSteps: [makeStep({ persistDismissed: true })],
120
+ markStepSeen: vi.fn(),
121
+ dismissStep,
122
+ ctx: {},
123
+ } });
124
+
125
+ fireEvent.click(screen.getByRole('button', { name: 'step-dismiss' }));
126
+
127
+ expect(dismissStep).toHaveBeenCalledWith('step-1');
128
+ });
129
+
130
+ it('does not call dismissStep for a non-persistDismissed step on dismiss', () => {
131
+ const dismissStep = vi.fn();
132
+ renderWizard({ onboarding: {
133
+ activeSteps: [makeStep({ persistDismissed: false })],
134
+ markStepSeen: vi.fn(),
135
+ dismissStep,
136
+ ctx: {},
137
+ } });
138
+
139
+ fireEvent.click(screen.getByRole('button', { name: 'step-dismiss' }));
140
+
141
+ expect(dismissStep).not.toHaveBeenCalled();
142
+ });
143
+ });
@@ -0,0 +1,142 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
5
+
6
+ vi.mock('react-i18next', () => ({
7
+ useTranslation: () => ({
8
+ t: (key, params) => (params && Object.keys(params).length ? `${key}:${JSON.stringify(params)}` : key),
9
+ }),
10
+ }));
11
+
12
+ const onboardingModule = vi.hoisted(() => ({ useOnboarding: vi.fn() }));
13
+ vi.mock('../src/onboarding/OnboardingProvider', () => onboardingModule);
14
+
15
+ const notificationsModule = vi.hoisted(() => ({ useNotifications: vi.fn() }));
16
+ vi.mock('../src/notifications/NotificationsProvider', () => notificationsModule);
17
+
18
+ const realtimeModule = vi.hoisted(() => ({ useRealtime: vi.fn(), DEFAULT_ENVELOPE: 'notification' }));
19
+ vi.mock('../src/notifications/realtime', () => realtimeModule);
20
+
21
+ import { PopupSurface } from '../src/notifications/PopupSurface';
22
+
23
+ describe('PopupSurface (NOTIF-12 scope B/C)', () => {
24
+ let handler;
25
+ let subscribeFn;
26
+ let markSeen;
27
+ let markDismissed;
28
+
29
+ beforeEach(() => {
30
+ vi.clearAllMocks();
31
+ handler = null;
32
+ subscribeFn = (envelope, fn) => {
33
+ handler = fn;
34
+ return () => { handler = null; };
35
+ };
36
+ markSeen = vi.fn();
37
+ markDismissed = vi.fn();
38
+ notificationsModule.useNotifications.mockReturnValue({ markSeen, markDismissed, markDone: vi.fn() });
39
+ onboardingModule.useOnboarding.mockReturnValue({ activeSteps: [] });
40
+ realtimeModule.useRealtime.mockImplementation(() => ({ subscribe: subscribeFn }));
41
+ });
42
+
43
+ afterEach(() => {
44
+ cleanup();
45
+ });
46
+
47
+ function push(data) {
48
+ act(() => {
49
+ handler(data);
50
+ });
51
+ }
52
+
53
+ it('renders nothing when no popup-eligible notification is present', () => {
54
+ render(<PopupSurface />);
55
+
56
+ expect(screen.queryByRole('dialog')).toBeNull();
57
+ });
58
+
59
+ it('renders a popup-eligible notification once, marking seen exactly once on display, and shows the notification title (not the onboarding header)', () => {
60
+ render(<PopupSurface />);
61
+
62
+ push({ notification_id: 1, channel: 'popup', content: { title_key: 'T', body_key: 'B', params: {} } });
63
+
64
+ expect(screen.getByRole('dialog')).toBeTruthy();
65
+ expect(screen.getByText('T')).toBeTruthy();
66
+ expect(screen.queryByText('Onboarding.SETUP')).toBeNull();
67
+ expect(markSeen).toHaveBeenCalledTimes(1);
68
+ expect(markSeen).toHaveBeenCalledWith([1]);
69
+ });
70
+
71
+ it('marks using the wire recipient_id (feed/mark/ id-space), not the notification_id, when present', () => {
72
+ render(<PopupSurface />);
73
+
74
+ push({ notification_id: 10, recipient_id: 999, channel: 'popup', content: { title_key: 'T' } });
75
+
76
+ expect(markSeen).toHaveBeenCalledWith([999]);
77
+
78
+ fireEvent.click(screen.getByRole('button', { name: 'PopupSurface.CLOSE' }));
79
+
80
+ expect(markDismissed).toHaveBeenCalledWith([999]);
81
+ });
82
+
83
+ it('fires markDismissed, never markDone, when the popup is closed', () => {
84
+ const { markDone } = notificationsModule.useNotifications();
85
+ render(<PopupSurface />);
86
+ push({ notification_id: 1, channel: 'popup', content: { title_key: 'T' } });
87
+
88
+ fireEvent.click(screen.getByRole('button', { name: 'PopupSurface.CLOSE' }));
89
+
90
+ expect(markDismissed).toHaveBeenCalledWith([1]);
91
+ expect(markDone).not.toHaveBeenCalled();
92
+ expect(screen.queryByRole('dialog')).toBeNull();
93
+ });
94
+
95
+ it('ignores a chip-channel message and a channel-less legacy message — no popup storm', () => {
96
+ render(<PopupSurface />);
97
+
98
+ push({ notification_id: 2, channel: 'chip', content: { title_key: 'T' } });
99
+ push({ notification_id: 3, content: { title_key: 'T' } });
100
+
101
+ expect(screen.queryByRole('dialog')).toBeNull();
102
+ expect(markSeen).not.toHaveBeenCalled();
103
+ });
104
+
105
+ it('ignores a notification.status message regardless of its channel field', () => {
106
+ render(<PopupSurface />);
107
+
108
+ push({ type: 'notification.status', notification_id: 4, channel: 'popup', status: {} });
109
+
110
+ expect(screen.queryByRole('dialog')).toBeNull();
111
+ });
112
+
113
+ it('coexistence: a blocking onboarding step suppresses the popup; it appears once the step clears, never stacking two dialogs', () => {
114
+ onboardingModule.useOnboarding.mockReturnValue({ activeSteps: [{ id: 'cookie_consent', blocking: true }] });
115
+ const { rerender } = render(<PopupSurface />);
116
+
117
+ push({ notification_id: 5, channel: 'popup', content: { title_key: 'T' } });
118
+ expect(screen.queryByRole('dialog')).toBeNull();
119
+ expect(markSeen).not.toHaveBeenCalled();
120
+
121
+ onboardingModule.useOnboarding.mockReturnValue({ activeSteps: [] });
122
+ rerender(<PopupSurface />);
123
+
124
+ expect(screen.getAllByRole('dialog')).toHaveLength(1);
125
+ expect(markSeen).toHaveBeenCalledWith([5]);
126
+ });
127
+
128
+ it('coexistence: a non-blocking onboarding step also suppresses the popup, never stacking two dialogs', () => {
129
+ onboardingModule.useOnboarding.mockReturnValue({ activeSteps: [{ id: 'browser_push', blocking: false }] });
130
+ const { rerender } = render(<PopupSurface />);
131
+
132
+ push({ notification_id: 6, channel: 'popup', content: { title_key: 'T' } });
133
+ expect(screen.queryByRole('dialog')).toBeNull();
134
+ expect(markSeen).not.toHaveBeenCalled();
135
+
136
+ onboardingModule.useOnboarding.mockReturnValue({ activeSteps: [] });
137
+ rerender(<PopupSurface />);
138
+
139
+ expect(screen.getAllByRole('dialog')).toHaveLength(1);
140
+ expect(markSeen).toHaveBeenCalledWith([6]);
141
+ });
142
+ });
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
2
2
 
3
3
  import {
4
4
  NotificationSettings,
5
+ PopupSurface,
5
6
  getNotificationPreferences,
6
7
  getVapidPublicKey,
7
8
  listPushSubscriptions,
@@ -26,4 +27,8 @@ describe('notification barrel exports', () => {
26
27
  notificationsTranslations,
27
28
  ].forEach((value) => expect(value).toBeDefined());
28
29
  });
30
+
31
+ it('additively exports the NOTIF-12 popup surface', () => {
32
+ expect(PopupSurface).toBeDefined();
33
+ });
29
34
  });
@@ -7,11 +7,10 @@ file. Fall back to direct Claude implementation only on Codex quota / rate-limit
7
7
 
8
8
  Feature-level WO (Expertenchat envelope). Seams verified against ucm 2.12.0 / dcm 2.32.0.
9
9
 
10
- ## HARD GATE — do not start before NOTIF-13 has landed and published
11
- NOTIF-12 consumes NOTIF-13's envelope contract and its `subscribe(type, handler)` primitive. Starting
12
- earlier means building on the catch-all in `NotificationsProvider.jsx:130-132` that NOTIF-13 removes.
13
- Both WOs also edit `ui-core-micha/src/notifications/` and `src/index.js`, so running them in parallel
14
- violates the AGENTS.md overlap rule. **Sequence: NOTIF-13 → publish → NOTIF-12.**
10
+ ## HARD GATE — SATISFIED 2026-07-30
11
+ NOTIF-13 landed and published: ucm **2.13.0** (`7a83ee9`), dcm **2.33.0** (`de77335`), both verified
12
+ live on npm/PyPI. This WO was re-verified against the landed code on 2026-07-30 and the context
13
+ package below describes the **actual shipped API**, not the pre-NOTIF-13 state. Cleared to start.
15
14
 
16
15
  ## TIER
17
16
  Tier 2 — this extracts a renderer out of `OnboardingWizard`, which **cockpit and jg run in
@@ -49,11 +48,27 @@ recorded on `NotificationRecipient` — **not** in the onboarding progress store
49
48
  **dcm `notifications/dispatch.py`:**
50
49
  - `PopupDispatcher` (`:117-121`) is a stub: logs "queued for unimplemented popup channel" and returns
51
50
  `DeliveryResult(ok=None, error="pending")`. It is registered in `_DISPATCHERS` (`:126-133`).
52
- - The model to copy is `ChipDispatcher` (`:62-74`): it calls
53
- `push_to_users([recipient.user], {"type": notification.notification_type, "content": ..., "notification_id": ...})`
54
- and returns `DeliveryResult(ok=True)`. **Note the `type` field carries the *domain* type** — this is
55
- precisely the overload NOTIF-13 replaces with a proper envelope. Emit NOTIF-13's envelope, not this
56
- legacy shape.
51
+ - The model to copy is `ChipDispatcher` (`dispatch.py:64-75`, post-NOTIF-13): it calls
52
+ `push_to_users([recipient.user], notification_envelope({"type": notification.notification_type, "content": ..., "notification_id": ...}))`
53
+ and returns `DeliveryResult(ok=True)`.
54
+
55
+ **How NOTIF-13 actually shipped — read this before writing scope D.** The envelope is a
56
+ **domain-level** discriminator, not a per-channel one:
57
+ - `delivery.py:25` `NOTIFICATION_ENVELOPE = "notification"`; `delivery.py:28-35`
58
+ `notification_envelope(payload)` merges `{"envelope": "notification"}` into any payload. **Additive**
59
+ — the pre-existing fields, including the overloaded `type`, are unchanged.
60
+ - ucm `src/notifications/realtime.jsx:68` routes purely on `data.envelope ?? DEFAULT_ENVELOPE`, then
61
+ fans to every handler subscribed to that envelope.
62
+ - **Inside** the notification envelope, `NotificationsProvider` keeps the original two-way split:
63
+ `data.type === 'notification.status'` patches status (`:98`), and **everything else is appended to
64
+ the feed with `unreadCount + 1`** (`:120`).
65
+
66
+ **Consequence — the trap this WO must avoid:** if `PopupDispatcher` simply calls
67
+ `notification_envelope({...})` like Chip does, its message is **indistinguishable from a chip
68
+ message**. The popup surface subscribing to the `"notification"` envelope would receive every chip
69
+ notification and pop up for all of them, and a notification routed to *both* chip and popup would
70
+ produce **two feed entries and `unreadCount + 2` for one notification**. Scope D and D2 below exist
71
+ to close exactly this.
57
72
  - `_render_content(content, user)` (`:44-60`) renders `title_key`/`body_key`/`params` in the
58
73
  recipient's language — reuse it rather than re-deriving text client-side if the payload needs text.
59
74
  - `prefs.py:22` already treats `popup` as a channel defaulting to True; `router.py:24` narrows to
@@ -82,25 +97,46 @@ reusable renderer (dialog frame, counter/progress header, the `onComplete`/`onDi
82
97
  contract). `OnboardingWizard` keeps its current behaviour by composing that shell with the onboarding
83
98
  store; the popup surface composes the same shell with notification state.
84
99
 
85
- **B. ucm — the popup surface.** A component that selects popup-eligible notifications from
86
- notification state, renders them through the shell, marks `seen` on display and `dismissed` on close,
87
- and subscribes to NOTIF-13's popup envelope type so a popup can appear live. Export additively from
88
- `src/index.js`.
100
+ **B. ucm — the popup surface.** A component that renders popup-eligible notifications through the
101
+ shell, marks `seen` on display and `dismissed` on close. For live popups it subscribes via
102
+ `useRealtime()` to the **`"notification"` envelope** (`DEFAULT_ENVELOPE` — there is no popup-specific
103
+ envelope, see D) and filters on `channel === "popup"`, ignoring `notification.status` messages and
104
+ channel-less legacy payloads. Export additively from `src/index.js`.
89
105
 
90
106
  **C. Coexistence rule with onboarding — specify and test it.** Both surfaces render a MUI `Dialog`, so
91
107
  two modals can be open at once. Required behaviour: **a blocking onboarding step wins**; the popup
92
108
  waits until no blocking onboarding step is active. Non-blocking coexistence must not stack two dialogs.
93
109
 
94
- **D. dcm — implement `PopupDispatcher.deliver`.** Emit NOTIF-13's envelope over `push_to_users` and
95
- return `DeliveryResult(ok=True)`. Keep the delivery-record semantics identical to `ChipDispatcher`
96
- (the surrounding `dispatch()` machinery already records pending → sent/failed; do not re-implement it).
110
+ **D. dcm — implement `PopupDispatcher.deliver`, with a channel discriminator *inside* the envelope.**
111
+ Emit through `notification_envelope()` (so Layer-1 routing stays domain-keyed and messaging can still
112
+ ride alongside) and add an explicit **`"channel": "popup"`** field to the payload. Return
113
+ `DeliveryResult(ok=True)`. Keep delivery-record semantics identical to `ChipDispatcher` — the
114
+ surrounding `dispatch()` machinery already records pending → sent/failed; do not re-implement it.
115
+
116
+ **Do NOT invent a second envelope value** (e.g. `"notification.popup"`). The envelope discriminates
117
+ *domains* so a future messaging stream can share the socket; overloading it with channels would
118
+ undo NOTIF-13's design. Channel selection belongs inside the payload.
119
+
120
+ For symmetry and so the client can tell the cases apart, `ChipDispatcher` should carry
121
+ `"channel": "chip"` as well. **Backward compatibility:** a payload with **no** `channel` field must
122
+ keep behaving exactly as today (feed entry + unread increment), because an app pinned to an older dcm
123
+ will still send channel-less payloads.
124
+
125
+ **D2. ucm — de-duplicate by `notification_id` in `NotificationsProvider`.** A notification routed to
126
+ both chip and popup now produces two WS messages. The provider must fold them into **one** feed entry
127
+ and **one** unread increment (de-dupe on `notification_id`, last-write-wins on content). Without this,
128
+ enabling popup alongside chip double-counts the badge. This is an edit to `NotificationsProvider`,
129
+ which is explicitly in scope here — it is not part of NOTIF-13's realtime core.
97
130
 
98
131
  ## DO NOT TOUCH
99
132
  - **The onboarding progress store** — `markStepSeen`, `dismissStep`, `persistDismissed` persistence,
100
133
  `OnboardingProvider`'s step-selection semantics, `stepSelection.js`.
101
134
  - The four existing step components (`CookieConsentStep`, `CompleteNameStep`, `BrowserPushStep`,
102
135
  `PwaInstallStep`) and their props.
103
- - NOTIF-13's realtime core — **consume** `subscribe()`, do not restructure it.
136
+ - **NOTIF-13's realtime core** (`src/notifications/realtime.jsx`) — consume `useRealtime()` /
137
+ `subscribe(envelope, handler)`, do not restructure it. In particular leave the `seeded` gate, the
138
+ backoff/reconnect semantics and the envelope-routing alone. (`NotificationsProvider` itself IS
139
+ editable — scope D2 requires it.)
104
140
  - `feed/*` REST contract, `NotificationBell`, `NotificationSettings`.
105
141
  - `prefs.py`, `router.py`, `resolve_channels` — popup already works there; a type opting in is an
106
142
  app-side change, not this WO's.
@@ -112,6 +148,14 @@ return `DeliveryResult(ok=True)`. Keep the delivery-record semantics identical t
112
148
  run in production. The step counter (`totalRef`/`completed`), the session-dismissed Set, and the
113
149
  `blocking` escape-key behaviour are easy to break silently.
114
150
  - **Stacked modals** if C is skipped — two dialogs, neither reachable.
151
+ - **Badge double-count** if D2 is skipped: a notification routed to both chip and popup arrives as two
152
+ WS messages and increments `unreadCount` twice for one notification. This is the single most likely
153
+ silent defect in this WO.
154
+ - **Popup storm** if B subscribes to the envelope without filtering on `channel` — every chip
155
+ notification would pop up a modal.
156
+ - **Regressing the NOTIF-13 R1 fix:** the `seeded` gate in `NotificationsProvider` exists because
157
+ decoupling the REST seed from the socket connect opened a silent-message-loss race. Do not touch it
158
+ while implementing D2.
115
159
  - **Dead code rot:** with no producer, only the tests exercise the path. Hence the mandatory test-local
116
160
  producer type.
117
161
  - **Status semantics drift:** marking a popup `done` instead of `dismissed` would pollute todo-channel
@@ -126,12 +170,20 @@ ucm (`pnpm test`, vitest):
126
170
  - **onboarding regression guard:** existing `OnboardingWizard` behaviour unchanged — step counter,
127
171
  progress bar, session-dismissed set, `blocking` disabling escape/backdrop close, `persistDismissed`
128
172
  still calling `dismissStep`;
129
- - with no popup-eligible notification present, nothing renders.
173
+ - with no popup-eligible notification present, nothing renders;
174
+ - **no popup storm:** a `channel: "chip"` message and a channel-less legacy message both leave the
175
+ popup surface silent;
176
+ - **no double-count (D2):** two WS messages for the same `notification_id` (one `chip`, one `popup`)
177
+ yield exactly one feed entry and `unreadCount + 1`;
178
+ - **NOTIF-13 R1 guard:** the `seeded` gate still holds — no socket messages are processed before the
179
+ REST seed completes.
130
180
 
131
181
  dcm (`pytest` on the notifications package):
132
182
  - register a **test-local** notification type declaring `eligible_channels=["popup"]`, call `notify()`,
133
- and assert the dispatcher delivered (`ok=True`) with NOTIF-13's envelope shape on the wire — the
134
- end-to-end proof required by the scaffolding notice;
183
+ and assert the dispatcher delivered (`ok=True`) with `envelope: "notification"` **and**
184
+ `channel: "popup"` on the wire — the end-to-end proof required by the scaffolding notice;
185
+ - a chip delivery still carries `envelope: "notification"` and now `channel: "chip"`, with every other
186
+ field unchanged (no regression for existing consumers);
135
187
  - `resolve_channels` still excludes `popup` for types that do not declare it (no accidental widening);
136
188
  - the existing `test_dispatch.py` registry assertion (`:25`) still passes.
137
189
 
@@ -157,6 +209,6 @@ exceeds ~2 min. stdout unbuffered. Exactly one final `RESULT: DONE|BLOCKED <reas
157
209
  ## MINI-HANDOVER (paste into a fresh Orchestrator session)
158
210
  ```
159
211
  Orchestrator: implement work-orders/NOTIF-12.md in ui-core-micha (main; scope D also touches
160
- django-core-micha). Check the hard gate first — NOTIF-13 must be landed and published. git pull,
161
- read the WO, then follow orchestrate-codex (Codex-first, own independent review, commit on green).
212
+ django-core-micha). The NOTIF-13 gate is satisfied (ucm 2.13.0 / dcm 2.33.0). git pull, read the WO,
213
+ then follow orchestrate-codex (Codex-first, own independent review, commit on green).
162
214
  ```