@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 +2 -2
- package/dist/i18n/notificationsTranslations.js +2 -0
- package/dist/index.js +1 -0
- package/dist/notifications/NotificationsProvider.js +13 -0
- package/dist/notifications/PopupSurface.js +84 -0
- package/dist/onboarding/OnboardingWizard.js +3 -12
- package/dist/onboarding/WizardDialogShell.js +29 -0
- package/package.json +1 -1
- package/src/i18n/notificationsTranslations.ts +2 -0
- package/src/index.js +1 -0
- package/src/notifications/NotificationsProvider.jsx +21 -0
- package/src/notifications/PopupSurface.jsx +103 -0
- package/src/onboarding/OnboardingWizard.jsx +9 -23
- package/src/onboarding/WizardDialogShell.jsx +51 -0
- package/tests/NotificationsProvider.test.jsx +38 -0
- package/tests/OnboardingWizard.test.jsx +143 -0
- package/tests/PopupSurface.test.jsx +142 -0
- package/tests/notificationsExports.test.js +5 -0
- package/work-orders/NOTIF-12.md +75 -23
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 |
|
|
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
|
|
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 (
|
|
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
|
@@ -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
|
-
<
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
});
|
package/work-orders/NOTIF-12.md
CHANGED
|
@@ -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 —
|
|
11
|
-
NOTIF-
|
|
12
|
-
|
|
13
|
-
|
|
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` (
|
|
53
|
-
`push_to_users([recipient.user], {"type": notification.notification_type, "content": ..., "notification_id": ...})`
|
|
54
|
-
and returns `DeliveryResult(ok=True)`.
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
`
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
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 —
|
|
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
|
|
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).
|
|
161
|
-
|
|
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
|
```
|