@micha.bigler/ui-core-micha 2.11.0 → 2.13.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.
@@ -0,0 +1,192 @@
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, waitFor } from '@testing-library/react';
5
+
6
+ const feedApi = vi.hoisted(() => ({
7
+ getNotificationFeed: vi.fn(),
8
+ getUnreadCount: vi.fn(),
9
+ markNotifications: vi.fn(),
10
+ }));
11
+
12
+ vi.mock('../src/notifications/feedApi', () => feedApi);
13
+
14
+ import { AuthContext } from '../src/auth/AuthContext';
15
+ import { NotificationsProvider, useNotifications } from '../src/notifications/NotificationsProvider';
16
+ import { useRealtime } from '../src/notifications/realtime';
17
+
18
+ class MockWebSocket {
19
+ static instances = [];
20
+
21
+ constructor(url) {
22
+ this.url = url;
23
+ this.close = vi.fn(() => this.onclose?.());
24
+ MockWebSocket.instances.push(this);
25
+ }
26
+ }
27
+
28
+ function StreamConsumer({ envelope, testId }) {
29
+ const { subscribe } = useRealtime();
30
+ const [received, setReceived] = React.useState([]);
31
+
32
+ React.useEffect(
33
+ () => subscribe(envelope, (data) => setReceived((prev) => [...prev, data])),
34
+ [subscribe, envelope],
35
+ );
36
+
37
+ return <output data-testid={testId}>{JSON.stringify(received)}</output>;
38
+ }
39
+
40
+ function NotificationsConsumer() {
41
+ const notifications = useNotifications();
42
+ return (
43
+ <>
44
+ <output data-testid="notifications">{JSON.stringify(notifications.notifications)}</output>
45
+ <output data-testid="unread-count">{notifications.unreadCount}</output>
46
+ <output data-testid="context-keys">{JSON.stringify(Object.keys(notifications).sort())}</output>
47
+ </>
48
+ );
49
+ }
50
+
51
+ function renderWithStreams({ streams = [], user = { id: 4 } } = {}) {
52
+ return render(
53
+ <AuthContext.Provider value={{ user }}>
54
+ <NotificationsProvider>
55
+ <NotificationsConsumer />
56
+ {streams.map(({ envelope, testId }) => (
57
+ <StreamConsumer key={testId} envelope={envelope} testId={testId} />
58
+ ))}
59
+ </NotificationsProvider>
60
+ </AuthContext.Provider>,
61
+ );
62
+ }
63
+
64
+ function received(testId) {
65
+ return JSON.parse(screen.getByTestId(testId).textContent);
66
+ }
67
+
68
+ describe('realtime primitive (NOTIF-13)', () => {
69
+ afterEach(() => {
70
+ cleanup();
71
+ });
72
+
73
+ beforeEach(() => {
74
+ vi.clearAllMocks();
75
+ MockWebSocket.instances = [];
76
+ vi.stubGlobal('WebSocket', MockWebSocket);
77
+ feedApi.getNotificationFeed.mockResolvedValue({ results: [] });
78
+ feedApi.getUnreadCount.mockResolvedValue({ count: 0 });
79
+ feedApi.markNotifications.mockResolvedValue({ updated: 1 });
80
+ });
81
+
82
+ it('ignores an unknown/unsubscribed envelope type — feed and unreadCount untouched', async () => {
83
+ renderWithStreams();
84
+ await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
85
+
86
+ MockWebSocket.instances[0].onmessage({ data: JSON.stringify({
87
+ envelope: 'message',
88
+ type: 'chat.new',
89
+ body: 'hello',
90
+ }) });
91
+
92
+ expect(screen.getByTestId('unread-count').textContent).toBe('0');
93
+ expect(currentNotificationsAreEmpty()).toBe(true);
94
+ });
95
+
96
+ function currentNotificationsAreEmpty() {
97
+ return JSON.parse(screen.getByTestId('notifications').textContent).length === 0;
98
+ }
99
+
100
+ it('routes two subscribers on distinct envelope types from one socket, each seeing only their own', async () => {
101
+ renderWithStreams({ streams: [
102
+ { envelope: 'message', testId: 'message-stream' },
103
+ { envelope: 'other', testId: 'other-stream' },
104
+ ] });
105
+ await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
106
+
107
+ MockWebSocket.instances[0].onmessage({ data: JSON.stringify({ envelope: 'message', body: 'a' }) });
108
+ MockWebSocket.instances[0].onmessage({ data: JSON.stringify({ envelope: 'other', body: 'b' }) });
109
+
110
+ await waitFor(() => expect(received('message-stream')).toHaveLength(1));
111
+ expect(received('message-stream')[0]).toEqual(expect.objectContaining({ body: 'a' }));
112
+ expect(received('other-stream')).toHaveLength(1);
113
+ expect(received('other-stream')[0]).toEqual(expect.objectContaining({ body: 'b' }));
114
+ expect(MockWebSocket.instances).toHaveLength(1);
115
+ });
116
+
117
+ it('unsubscribe stops delivery for that subscriber without affecting others', async () => {
118
+ function Harness() {
119
+ const { subscribe } = useRealtime();
120
+ const [messageReceived, setMessageReceived] = React.useState([]);
121
+ const [otherReceived, setOtherReceived] = React.useState([]);
122
+ const [messageSubscribed, setMessageSubscribed] = React.useState(true);
123
+
124
+ React.useEffect(() => {
125
+ if (!messageSubscribed) return undefined;
126
+ return subscribe('message', (data) => setMessageReceived((prev) => [...prev, data]));
127
+ }, [subscribe, messageSubscribed]);
128
+
129
+ React.useEffect(
130
+ () => subscribe('other', (data) => setOtherReceived((prev) => [...prev, data])),
131
+ [subscribe],
132
+ );
133
+
134
+ return (
135
+ <>
136
+ <output data-testid="message-received">{JSON.stringify(messageReceived)}</output>
137
+ <output data-testid="other-received">{JSON.stringify(otherReceived)}</output>
138
+ <button onClick={() => setMessageSubscribed(false)}>unsubscribe-message</button>
139
+ </>
140
+ );
141
+ }
142
+
143
+ const { unmount } = render(
144
+ <AuthContext.Provider value={{ user: { id: 4 } }}>
145
+ <NotificationsProvider>
146
+ <Harness />
147
+ </NotificationsProvider>
148
+ </AuthContext.Provider>,
149
+ );
150
+ await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
151
+
152
+ fireEvent.click(screen.getByRole('button', { name: 'unsubscribe-message' }));
153
+
154
+ MockWebSocket.instances[0].onmessage({ data: JSON.stringify({ envelope: 'message', body: 'after-unsub' }) });
155
+ MockWebSocket.instances[0].onmessage({ data: JSON.stringify({ envelope: 'other', body: 'still-alive' }) });
156
+
157
+ await waitFor(() => expect(received('other-received')).toHaveLength(1));
158
+ expect(received('message-received')).toHaveLength(0);
159
+ expect(received('other-received')[0]).toEqual(expect.objectContaining({ body: 'still-alive' }));
160
+
161
+ unmount();
162
+ });
163
+
164
+ it('keeps a subscriber registered across a close/backoff/reopen cycle with no re-registration', async () => {
165
+ vi.useFakeTimers();
166
+ try {
167
+ renderWithStreams({ streams: [{ envelope: 'message', testId: 'message-stream' }] });
168
+ await vi.waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
169
+
170
+ const firstSocket = MockWebSocket.instances[0];
171
+ firstSocket.onclose();
172
+ await vi.advanceTimersByTimeAsync(1_000);
173
+
174
+ expect(MockWebSocket.instances).toHaveLength(2);
175
+ MockWebSocket.instances[1].onmessage({ data: JSON.stringify({ envelope: 'message', body: 'after-reopen' }) });
176
+
177
+ await vi.waitFor(() => expect(received('message-stream')).toHaveLength(1));
178
+ expect(received('message-stream')[0]).toEqual(expect.objectContaining({ body: 'after-reopen' }));
179
+ } finally {
180
+ vi.useRealTimers();
181
+ }
182
+ });
183
+
184
+ it("NotificationsProvider's public context value is unchanged", async () => {
185
+ renderWithStreams();
186
+ await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
187
+
188
+ expect(JSON.parse(screen.getByTestId('context-keys').textContent)).toEqual(
189
+ ['markDismissed', 'markDone', 'markSeen', 'notifications', 'refresh', 'unreadCount'].sort(),
190
+ );
191
+ });
192
+ });
@@ -0,0 +1,162 @@
1
+ # WORK ORDER NOTIF-12 (ui-core-micha + django-core-micha) — popup channel via the wizard renderer
2
+
3
+ **EXECUTION DIRECTIVE.** Implement through `codex exec` in the background — invoked **directly via
4
+ Bash** (never the `debugger`/`*_coder` Agent wrappers) with **both** flags `--skip-git-repo-check`
5
+ and `--dangerously-bypass-approvals-and-sandbox`, prompt passed as a positional argument from this
6
+ file. Fall back to direct Claude implementation only on Codex quota / rate-limit / non-zero exit.
7
+
8
+ Feature-level WO (Expertenchat envelope). Seams verified against ucm 2.12.0 / dcm 2.32.0.
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.**
15
+
16
+ ## TIER
17
+ Tier 2 — this extracts a renderer out of `OnboardingWizard`, which **cockpit and jg run in
18
+ production**. The roadmap calls the popup channel "uncritical"; that refers to business urgency, not
19
+ blast radius. Independent `reviewer` mandatory. `sec_reviewer` only on explicit operator request.
20
+
21
+ ## SCAFFOLDING NOTICE — READ FIRST
22
+ **This channel ships with zero producers, deliberately.** Verified 2026-07-29 across dcm, jg-ferien,
23
+ cockpit, hram, spesix and survey_app: **no notification type declares `popup` in `eligible_channels`**,
24
+ so `resolve_channels()` will never route anything to it until some app opts a type in. The operator
25
+ decided to build the scaffolding anyway (2026-07-29), having been shown this.
26
+
27
+ Two obligations follow, and they are part of the acceptance criteria:
28
+ 1. **Do not overclaim.** The CHANGELOG entry and any docstring must state plainly that the popup
29
+ channel is wired but inactive until a notification type declares `popup`. Precedent: NOTIF-4's
30
+ reviewer (R1) had to soften a CHANGELOG that promised retry behaviour no shipped dispatcher could
31
+ reach. Do not repeat that.
32
+ 2. **Scaffolding must be tested scaffolding.** Register a test-local notification type that declares
33
+ `popup` and prove the whole path end-to-end. Untested dead code is not an acceptable outcome.
34
+
35
+ ## GOAL
36
+ Make `popup` a real delivery channel: a notification whose type opts into `popup` is rendered to the
37
+ recipient in a modal that reuses the onboarding **wizard renderer**, with its seen/dismissed status
38
+ recorded on `NotificationRecipient` — **not** in the onboarding progress store.
39
+
40
+ ## EXPECTED OUTCOME
41
+ - dcm's `PopupDispatcher` stops being a logging stub and delivers for real.
42
+ - ucm exports a popup surface that renders popup-eligible notifications and marks them via the
43
+ existing `feed/mark/` path.
44
+ - Onboarding behaviour is bit-for-bit unchanged for existing consumers.
45
+ - With no producer registered, nothing visibly changes in any app.
46
+
47
+ ## CONTEXT PACKAGE — verified current state
48
+
49
+ **dcm `notifications/dispatch.py`:**
50
+ - `PopupDispatcher` (`:117-121`) is a stub: logs "queued for unimplemented popup channel" and returns
51
+ `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.
57
+ - `_render_content(content, user)` (`:44-60`) renders `title_key`/`body_key`/`params` in the
58
+ recipient's language — reuse it rather than re-deriving text client-side if the payload needs text.
59
+ - `prefs.py:22` already treats `popup` as a channel defaulting to True; `router.py:24` narrows to
60
+ `ntype.eligible_channels`. **No prefs or router change is needed** — only a type opting in.
61
+
62
+ **ucm `src/onboarding/OnboardingWizard.jsx` (75 LOC)** is the renderer to reuse:
63
+ - MUI `Dialog open fullWidth maxWidth="sm"` (`:59`), `DialogTitle` with a step counter +
64
+ `LinearProgress` (`:60-67`), `DialogContent` rendering
65
+ `<StepComponent onComplete={...} onDismiss={...} ctx={...} />` (`:69`).
66
+ - Step contract: `{ id, Component, blocking, persistDismissed }`. `blocking` disables escape/backdrop
67
+ close (`:59`).
68
+ - **The parts that must NOT come along** (design decision D-F7,
69
+ `django-core-micha/docs/design/notifications-platform.md:70-71`): `onboarding.markStepSeen` (`:35`),
70
+ `dismissStep` (`:54`), and the sequential progress store behind them. Popup status lives on
71
+ `NotificationRecipient`.
72
+ - Exported at `src/index.js:61`; the four existing steps at `:62-65`.
73
+
74
+ **ucm notification status API:** `useNotifications()` already returns `markSeen` / `markDismissed` /
75
+ `markDone`, backed by `feed/mark/`. Use `markSeen` when a popup is shown and `markDismissed` when the
76
+ user closes it. **Never `markDone`** — done is a todo-channel semantic.
77
+
78
+ ## SCOPE
79
+
80
+ **A. ucm — extract the dialog shell.** Lift the presentational shell out of `OnboardingWizard` into a
81
+ reusable renderer (dialog frame, counter/progress header, the `onComplete`/`onDismiss`/`ctx` child
82
+ contract). `OnboardingWizard` keeps its current behaviour by composing that shell with the onboarding
83
+ store; the popup surface composes the same shell with notification state.
84
+
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`.
89
+
90
+ **C. Coexistence rule with onboarding — specify and test it.** Both surfaces render a MUI `Dialog`, so
91
+ two modals can be open at once. Required behaviour: **a blocking onboarding step wins**; the popup
92
+ waits until no blocking onboarding step is active. Non-blocking coexistence must not stack two dialogs.
93
+
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).
97
+
98
+ ## DO NOT TOUCH
99
+ - **The onboarding progress store** — `markStepSeen`, `dismissStep`, `persistDismissed` persistence,
100
+ `OnboardingProvider`'s step-selection semantics, `stepSelection.js`.
101
+ - The four existing step components (`CookieConsentStep`, `CompleteNameStep`, `BrowserPushStep`,
102
+ `PwaInstallStep`) and their props.
103
+ - NOTIF-13's realtime core — **consume** `subscribe()`, do not restructure it.
104
+ - `feed/*` REST contract, `NotificationBell`, `NotificationSettings`.
105
+ - `prefs.py`, `router.py`, `resolve_channels` — popup already works there; a type opting in is an
106
+ app-side change, not this WO's.
107
+ - Schema, migrations, dependencies, CI.
108
+ - **Do not register a `popup` producer in any app.** This WO ships the channel only.
109
+
110
+ ## RISKS
111
+ - **Onboarding regression** is the main risk: the shell extraction touches a wizard that cockpit and jg
112
+ run in production. The step counter (`totalRef`/`completed`), the session-dismissed Set, and the
113
+ `blocking` escape-key behaviour are easy to break silently.
114
+ - **Stacked modals** if C is skipped — two dialogs, neither reachable.
115
+ - **Dead code rot:** with no producer, only the tests exercise the path. Hence the mandatory test-local
116
+ producer type.
117
+ - **Status semantics drift:** marking a popup `done` instead of `dismissed` would pollute todo-channel
118
+ semantics.
119
+
120
+ ## REQUIRED TESTS / ACCEPTANCE
121
+ ucm (`pnpm test`, vitest):
122
+ - a popup-eligible notification renders in the shell; `markSeen` fires once on display;
123
+ - closing it fires `markDismissed`, never `markDone`;
124
+ - **coexistence:** with a blocking onboarding step active, no popup dialog renders; once it clears, the
125
+ popup appears — and never two dialogs at once;
126
+ - **onboarding regression guard:** existing `OnboardingWizard` behaviour unchanged — step counter,
127
+ progress bar, session-dismissed set, `blocking` disabling escape/backdrop close, `persistDismissed`
128
+ still calling `dismissStep`;
129
+ - with no popup-eligible notification present, nothing renders.
130
+
131
+ dcm (`pytest` on the notifications package):
132
+ - 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;
135
+ - `resolve_channels` still excludes `popup` for types that do not declare it (no accidental widening);
136
+ - the existing `test_dispatch.py` registry assertion (`:25`) still passes.
137
+
138
+ Plus `pnpm build` (tsc) green.
139
+
140
+ ## RELEASE
141
+ ucm minor bump + publish. dcm minor bump + publish (publish-from-main), with a **registry live-check
142
+ before any app pin bump**. No app pin bumps in this WO. CHANGELOG in both repos must carry the
143
+ inactive-until-a-producer-opts-in wording from the scaffolding notice.
144
+
145
+ ## TARGET REPO / WORKING DIRECTORY
146
+ - Primary: `C:\Users\biglmi\Documents\webapps\ui-core-micha`
147
+ - Secondary (scope D): `C:\Users\biglmi\Documents\webapps\django-core-micha`
148
+ - Never the workspace root `…\webapps`.
149
+ Both are platform repos: commit directly to the trunk, no feature branches.
150
+
151
+ ## PROGRESS CONTRACT
152
+ Emit a `PLAN: <step1> | <step2> | …` line up front, then a single-line
153
+ `PROGRESS: [<n>/<total>] <present-tense action>` **before every relevant action** (file opened, file
154
+ edited, command/test run) and `PROGRESS: [<n>/<total>] done` on step completion, spaced so no gap
155
+ exceeds ~2 min. stdout unbuffered. Exactly one final `RESULT: DONE|BLOCKED <reason>`.
156
+
157
+ ## MINI-HANDOVER (paste into a fresh Orchestrator session)
158
+ ```
159
+ 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).
162
+ ```
@@ -0,0 +1,142 @@
1
+ # WORK ORDER NOTIF-13 (ui-core-micha + django-core-micha) — Layer-1 transport extraction
2
+
3
+ **EXECUTION DIRECTIVE.** Implement through `codex exec` in the background — invoked **directly via
4
+ Bash** (never the `debugger`/`*_coder` Agent wrappers) with **both** flags `--skip-git-repo-check`
5
+ and `--dangerously-bypass-approvals-and-sandbox`, prompt passed as a positional argument from this
6
+ file. Fall back to direct Claude implementation only on Codex quota / rate-limit / non-zero exit.
7
+
8
+ Feature-level WO (Expertenchat envelope). The seams below are **verified against the landed code**
9
+ (ucm 2.12.0, dcm 2.32.0) and are the required scope. The Orchestrator fills the exact line map.
10
+
11
+ ## TIER
12
+ Tier 2 — restructures a provider that **cockpit consumes in production**, and changes a WebSocket
13
+ message contract shared across apps. Independent `reviewer` mandatory. Per AGENTS.md Tier 2,
14
+ `sec_reviewer` is NOT auto-included; request it only if the operator asks.
15
+
16
+ ## GOAL
17
+ Turn ucm's notification-only socket into a generic **Layer-1 realtime primitive**: one socket,
18
+ subscribe-by-message-type, so a second domain (messaging) can ride it without being misread as a
19
+ notification. **Additive**: `NotificationsProvider` remains the sole mount point and its public
20
+ context value is unchanged (operator decision 2026-07-29).
21
+
22
+ ## EXPECTED OUTCOME
23
+ - ucm exports a `useRealtime()`-style subscribe primitive alongside the unchanged `useNotifications()`.
24
+ - An unknown/unsubscribed message type is **ignored** instead of being appended to the notification feed.
25
+ - No app has to change its provider tree. cockpit and jg keep working untouched.
26
+ - NOTIF-15 (jg bell adoption, retiring jg's local context) becomes implementable; Phase B messaging
27
+ gets its substrate.
28
+
29
+ ## CONTEXT PACKAGE — verified current state
30
+
31
+ **ucm `src/notifications/NotificationsProvider.jsx` (232 LOC)** owns *both* the socket and the
32
+ notifications domain state:
33
+ - socket ownership: `socketRef`, `connect()`, exponential backoff `INITIAL_BACKOFF_MS` 1s →
34
+ `MAX_BACKOFF_MS` 30s, auth-gated effect keyed on `[authenticated, refresh, replaceNotifications, wsUrlBase]` (`:87-179`).
35
+ - `handleMessage` (`:103-133`) dispatches on exactly **one** envelope type, `notification.status` (`:111`).
36
+ - **The blocker:** everything else falls through to `normalizeNotificationPush(data)` and is appended
37
+ to the feed with `unreadCount + 1` (`:130-132`). A messaging payload on this socket becomes a fake
38
+ notification today.
39
+ - **Field overload — the reason a third stream cannot simply be added:** in the status branch
40
+ `data.type` is the *envelope* type; in the new-notification branch `normalizeNotificationPush`
41
+ reads `data.type` as the *domain* notification-type key (`:25`). One field, two meanings. The
42
+ envelope contract must resolve this without breaking either reading.
43
+ - WS URL hardcoded to `/ws/notifications/` (`:36`, `:40`).
44
+ - exports in `src/index.js:48` (`NotificationsProvider`, `useNotifications`).
45
+
46
+ **dcm transport is ALREADY generic — correct the roadmap wording here.** The roadmap §4 calls for a
47
+ "dcm multi-stream consumer"; that overstates the dcm work:
48
+ - `notifications/delivery.py::push_to_users(users, payload)` (`:20-43`) fans an **arbitrary** payload
49
+ into the per-user group `notifications_user_{id}`.
50
+ - `notifications/consumers.py::NotificationConsumer.message()` (`:21-22`) is `send_json(event["payload"])` —
51
+ fully stream-agnostic. jg's messaging **already** rides this same group via `push_to_users`.
52
+ - The only dcm-side envelope producer is `notifications/views.py:235` (`"type": "notification.status"`);
53
+ the contract is documented at `views.py:3`.
54
+ - **So the dcm work is the envelope contract + a thin authoring helper + doc — NOT a consumer rewrite.**
55
+
56
+ **Reference implementation to promote:** jg's `frontend/src/context/NotificationsContext.jsx` (91 LOC,
57
+ docstring "WebSocket owner only") already does exactly this multiplexing with
58
+ `registerMessageCallback` / `registerChatCallback`. Read it for the shape. **Do not edit jg here.**
59
+
60
+ ## SCOPE
61
+
62
+ **A. ucm — extract the socket into an internal realtime core.** Single owner; preserve the existing
63
+ backoff, reconnect, auth-gating and cleanup semantics **verbatim**. Expose
64
+ `subscribe(type, handler) -> unsubscribe`.
65
+
66
+ **B. ucm — `NotificationsProvider` becomes the first subscriber.** It registers handlers for the
67
+ notification envelope types. Its returned context value stays **exactly**
68
+ `{ notifications, unreadCount, markSeen, markDismissed, markDone, refresh }`.
69
+
70
+ **C. Envelope contract.** Introduce an explicit envelope discriminator that does not collide with the
71
+ domain `type` field (see the field-overload note above). Unknown / unsubscribed envelope types are
72
+ **ignored** — the catch-all at `:130-132` dies. This is a deliberate behaviour change and must be
73
+ called out in the CHANGELOG.
74
+
75
+ **D. Backward compatibility in BOTH directions** — apps pin dcm and ucm independently, so mismatched
76
+ versions are normal:
77
+ - *new ucm + old dcm* (payload has no envelope field): must still be recognised as a new notification,
78
+ exactly as today. Without this the feed silently goes dead against an un-bumped backend.
79
+ - *old ucm + new dcm*: dcm must keep emitting the legacy fields so a ucm 2.12.0 client is unaffected
80
+ (**cockpit runs 2.12.0 in staging/prod**).
81
+
82
+ **E. dcm — formalize the contract.** Document the envelope next to `views.py:3` and add a thin
83
+ authoring helper so producers stop hand-rolling the dict. `push_to_users`'s signature stays unchanged.
84
+
85
+ ## DO NOT TOUCH
86
+ - **jg-ferien** (`NotificationsContext.jsx`, `MessagingContext.jsx`) — that is NOTIF-15.
87
+ - The messaging domain itself — Phase B (`MSG-*`).
88
+ - `NotificationBell.jsx`, `NotificationSettings.jsx`, `feedApi.js`, the `feed/*` REST contract.
89
+ - `BaseSecureConsumer` / S112 permission model, `IsAuthenticatedWs`, the group name `notifications_user_{id}`.
90
+ - **No standalone `RealtimeProvider` export that apps must mount.** Operator decision 2026-07-29:
91
+ additive only. A standalone provider is a later, separate WO if a second consumer ever needs it.
92
+ - Reconnect/backoff semantics and the single-socket-owner invariant — preserve, do not "improve".
93
+ - Schema, migrations, dependencies, CI.
94
+ - **No app pin bumps** in this WO (cockpit/jg stay where they are).
95
+
96
+ ## RISKS
97
+ - **Silent feed death** if C lands without D: an envelope-less payload from an older dcm would be
98
+ ignored instead of shown. D is not optional.
99
+ - **cockpit is a live consumer** at ucm 2.12.0 — any change to the returned context shape breaks it.
100
+ - **Subscription leaks / double delivery:** `subscribe` must return a stable unsubscribe, and handlers
101
+ must **survive reconnects** without the caller re-registering them.
102
+ - **Reconnect loop:** the socket effect currently depends on `refresh` / `replaceNotifications`;
103
+ moving the socket out must not make the socket re-create on every feed update.
104
+
105
+ ## REQUIRED TESTS / ACCEPTANCE
106
+ ucm runs `vitest` (`pnpm test`) and has **no tests under `src/notifications/` today** — this WO adds
107
+ the first ones:
108
+ - an unknown envelope type is ignored: feed and `unreadCount` both untouched (the regression that
109
+ protects messaging from being misread as a notification);
110
+ - two subscribers on distinct types each receive only their own messages, from **one** socket —
111
+ assert exactly one `WebSocket` was constructed;
112
+ - `unsubscribe` stops delivery for that subscriber and does not affect the others;
113
+ - handlers keep receiving after a close → backoff → reopen cycle, with no re-registration by the caller;
114
+ - a legacy envelope-less payload still lands in the feed (backward-compat D);
115
+ - `notification.status` handling and the `unreadCount` delta are unchanged — this guards the NOTIF-6
116
+ R1 fix (badge went stale after a cross-device mark);
117
+ - `NotificationsProvider`'s public context value is unchanged.
118
+
119
+ Plus `pnpm build` (tsc) green. If E lands in dcm, run the dcm notifications `pytest` package.
120
+
121
+ ## RELEASE
122
+ ucm minor bump + publish (publish-from-main). dcm only if E lands there — then publish-from-main with
123
+ a **registry live-check before any app pin bump**. No app pin bumps in this WO.
124
+
125
+ ## TARGET REPO / WORKING DIRECTORY
126
+ - Primary: `C:\Users\biglmi\Documents\webapps\ui-core-micha`
127
+ - Secondary (scope E only): `C:\Users\biglmi\Documents\webapps\django-core-micha`
128
+ - Never the workspace root `…\webapps`.
129
+ Both are platform repos: commit directly to the trunk, no feature branches.
130
+
131
+ ## PROGRESS CONTRACT
132
+ Emit a `PLAN: <step1> | <step2> | …` line up front, then a single-line
133
+ `PROGRESS: [<n>/<total>] <present-tense action>` **before every relevant action** (file opened, file
134
+ edited, command/test run) and `PROGRESS: [<n>/<total>] done` on step completion, spaced so no gap
135
+ exceeds ~2 min. stdout unbuffered. Exactly one final `RESULT: DONE|BLOCKED <reason>`.
136
+
137
+ ## MINI-HANDOVER (paste into a fresh Orchestrator session)
138
+ ```
139
+ Orchestrator: implement work-orders/NOTIF-13.md in ui-core-micha (main; scope E also touches
140
+ django-core-micha). git pull first, read the WO, then follow orchestrate-codex (Codex-first, own
141
+ independent review, commit on green).
142
+ ```