@micha.bigler/ui-core-micha 2.13.0 → 2.15.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.
Files changed (38) hide show
  1. package/WORK_ORDERS.md +4 -2
  2. package/dist/components/charts/BarChart.js +32 -0
  3. package/dist/components/charts/ChartFrame.js +50 -0
  4. package/dist/components/charts/LineChart.js +32 -0
  5. package/dist/components/charts/exportChart.js +67 -0
  6. package/dist/components/charts/formatters.js +23 -0
  7. package/dist/components/charts/palette.js +21 -0
  8. package/dist/i18n/chartsTranslations.js +38 -0
  9. package/dist/i18n/notificationsTranslations.js +2 -0
  10. package/dist/index.js +12 -4
  11. package/dist/notifications/NotificationsProvider.js +13 -0
  12. package/dist/notifications/PopupSurface.js +84 -0
  13. package/dist/onboarding/OnboardingWizard.js +3 -12
  14. package/dist/onboarding/WizardDialogShell.js +29 -0
  15. package/package.json +3 -1
  16. package/src/components/charts/BarChart.jsx +44 -0
  17. package/src/components/charts/ChartFrame.jsx +112 -0
  18. package/src/components/charts/LineChart.jsx +44 -0
  19. package/src/components/charts/exportChart.js +71 -0
  20. package/src/components/charts/formatters.js +39 -0
  21. package/src/components/charts/palette.js +24 -0
  22. package/src/i18n/chartsTranslations.ts +38 -0
  23. package/src/i18n/notificationsTranslations.ts +2 -0
  24. package/src/index.js +19 -4
  25. package/src/notifications/NotificationsProvider.jsx +21 -0
  26. package/src/notifications/PopupSurface.jsx +103 -0
  27. package/src/onboarding/OnboardingWizard.jsx +9 -23
  28. package/src/onboarding/WizardDialogShell.jsx +51 -0
  29. package/tests/BarChart.test.jsx +55 -0
  30. package/tests/ChartFrame.test.jsx +122 -0
  31. package/tests/LineChart.test.jsx +33 -0
  32. package/tests/NotificationsProvider.test.jsx +38 -0
  33. package/tests/OnboardingWizard.test.jsx +143 -0
  34. package/tests/PopupSurface.test.jsx +142 -0
  35. package/tests/chartsPalette.test.js +39 -0
  36. package/tests/notificationsExports.test.js +5 -0
  37. package/work-orders/CHART-1.md +285 -0
  38. package/work-orders/NOTIF-12.md +75 -23
@@ -0,0 +1,285 @@
1
+ # CHART-1 — Shared chart kit: themed `ChartFrame` + MUI X-Charts Bar/Line presets
2
+
3
+ - **Repo:** `ui-core-micha` (`@micha.bigler/ui-core-micha`), branch `main` (library, publish-from-main; no `develop`)
4
+ - **Tier:** 2 (new shared-library public API + a dependency change + governance-anchored contract)
5
+ - **Status:** done (f1b3614)
6
+ - **Workstream:** `CHART-*` (chart / data-viz kit)
7
+
8
+ > This file has two halves. **Part A (Envelope)** below is authored by the Expertenchat and is the
9
+ > authoritative WHAT/WHY — changing it is a scope change that returns to the operator. **Part B
10
+ > (Implementation map)** is filled by the Orchestrator on `git pull` from repo knowledge, refined
11
+ > against the landed code, without altering Part A.
12
+
13
+ ---
14
+
15
+ ## Part A — Envelope (authoritative WHAT/WHY)
16
+
17
+ ### Goal (Ziel)
18
+
19
+ Build the **shared chart kit** that this stack's governance already references as a not-yet-existing
20
+ dependency (`.codex/skills/frontend-engineering/SKILL.md` §"Charts / data-viz": *"use the shared
21
+ chart kit (a themed `ChartFrame` + MUI X-Charts primitives) once it exists"*; `ui_reviewer`
22
+ checklist mirrors it). Today every hram Results/Research panel re-implements the same
23
+ `Paper + title + toolbar + loading/error/empty + fixed-height box + export buttons` scaffolding
24
+ (~20 panels, ~200 lines of boilerplate each), and there is no reusable frame. This WO delivers the
25
+ reusable estate-wide primitives so charts stop being the recurring quality trap.
26
+
27
+ ### Expected outcome
28
+
29
+ A new additive, barrel-exported (`src/index.js`) chart module in `ui-core-micha`, published as an
30
+ additive **minor** version bump (`2.12.0 → 2.15.0`, publish-from-main per the dcm/ucm publish
31
+ topology), providing exactly:
32
+
33
+ 1. **`ChartFrame` — the chart-type-agnostic scaffolding.** This is the primary value; it wraps *any*
34
+ chart body via `children` (an MUI X-Charts primitive **or** a bespoke raw-`<svg>` body), so it
35
+ serves all ~20 hram panels including the SVG specialists. It owns:
36
+ - a `Paper` container (default `variant="outlined"`, matching hram's Research panels),
37
+ - `title` (required) + optional `subtitle`,
38
+ - a `toolbar` slot (`ReactNode`, rendered in the header row — for the panels' `ToggleButtonGroup`/
39
+ selectors),
40
+ - mutually-exclusive state rendering with precedence **loading → error → empty → children**:
41
+ `loading` (spinner), `error` (`Alert severity="error"`), `isEmpty` + `emptyMessage`
42
+ (`Alert severity="info"`),
43
+ - a **responsive, container-sized** chart area (governance: *"container-sized, not fixed px"*),
44
+ with an optional `minHeight`/`aspect` fallback so migrated panels don't reflow unexpectedly,
45
+ - optional **Export SVG / Export PNG** controls in the footer (consolidating the per-panel export
46
+ hram already hand-rolls), shown only when export is enabled, operating on the rendered chart
47
+ container,
48
+ - basic **a11y** (frame `title`/`aria-label`, `role="img"` or equivalent on the chart region).
49
+ All of ChartFrame's own user-facing strings (empty/error defaults, export button labels) go
50
+ through `react-i18next` — no hardcoded copy.
51
+
52
+ 2. **`BarChart` preset** — a thin wrapper over MUI X-Charts `BarChart` that bakes in the governance
53
+ chart contract so callers cannot forget it: **axis labels on both axes (with units)** required via
54
+ props, **legend auto-shown only when >1 series**, **tooltip on**, **theme-token colours** (accepts
55
+ a caller palette / per-series colours, defaults to the kit's neutral palette — never hardcoded
56
+ hex), **responsive container**. Must cover the variants hram actually uses: single-series, grouped
57
+ (multi-metric), and stacked (incl. the dual-axis case in `GenericAnalysisPlot`).
58
+
59
+ 3. **`LineChart` preset** — same contract, for multi-series time-series (covers
60
+ `ResultsTimelinePanel` / `ExpertTimelinePanel`).
61
+
62
+ 4. **Neutral palette + locale-aware formatter helpers** — a theme-aware (light **and** dark)
63
+ categorical + sequential palette and number formatters (percentage, compact, ratio) that the
64
+ presets default to and that apps may pass through. The kit provides the **neutral default and the
65
+ pass-through seam only**; domain palettes and domain labels stay in the consuming app (see
66
+ non-goals).
67
+
68
+ **Acceptance = the `ui_reviewer` chart checklist is satisfiable by construction:** any chart built
69
+ with the presets has axis labels (both axes), a legend when multi-series, a tooltip, theme-token
70
+ colours, container-sized responsiveness, i18n-able labels, and empty/loading states + basic a11y
71
+ from the frame.
72
+
73
+ ### Scope
74
+
75
+ - New chart module (e.g. `src/components/charts/`), exported from `src/index.js` (additive only).
76
+ - `ChartFrame`, `BarChart`, `LineChart`, and the neutral palette/formatter helpers.
77
+ - Add `@mui/x-charts` as a **`peerDependency`** (see Precondition — this is the one approval-gated
78
+ item). Peer, not a direct dep, so the consuming app owns the version and there is no double-bundle;
79
+ hram already depends on `@mui/x-charts` and satisfies the peer.
80
+ - Kit-owned strings wired through the existing ucm `react-i18next` setup (`src/i18n`).
81
+ - Additive minor version bump + publish (`2.15.0`).
82
+
83
+ ### Non-goals / do-not-touch (explicit)
84
+
85
+ - **No SVG chart primitives in v1.** No generic scatter/bubble, tornado, heatmap, confidence-interval,
86
+ or Pareto-frontier component. hram's bespoke SVG panels keep their own `<svg>` **body**, rendered
87
+ **inside `ChartFrame`** (so they gain the shared frame + export). This is a **deliberately surfaced
88
+ partial state**, not a silent half-solution: retiring the raw SVG by lifting these into MUI
89
+ X-Charts / kit primitives is a tracked future **CHART-2**, gated separately. (Governance's
90
+ "extend the kit, don't hand-roll" is the direction; v1 intentionally does not yet cover these.)
91
+ - **No geo/maps** — `react-leaflet` stays app-side.
92
+ - **No tables** — MUI `Table` stays app-side.
93
+ - **No domain palettes or domain labels** — hram's `dataViz` palette (coverage/dalys/cost/…) and its
94
+ `Results.*` i18n keys stay in hram; the kit ships only the neutral default + pass-through seam.
95
+ - **Do not migrate hram in this WO.** hram adoption (pin bump + panel migration) is a separate
96
+ follow-on WO in the `hram` repo, gated on `2.15.0` being published (see Precondition).
97
+ - Do not touch auth, CI, or any existing ucm export beyond additively adding the chart exports.
98
+
99
+ ### Precondition / execution-gate / ordering
100
+
101
+ 1. **Dependency-change approval (blocking):** adding `@mui/x-charts` as a `peerDependency` is a
102
+ dependency change → **explicit operator approval required before the Orchestrator adds it**
103
+ (AGENTS.md "Require explicit approval before touching: dependency changes").
104
+ 2. **Publish before adoption:** the kit lands + publishes as the **next free additive minor**. At
105
+ authoring, `2.13.0` (NOTIF-13) and `2.14.0` (NOTIF-12) are already claimed, so the target is
106
+ `2.15.0` — but the registry is the source of truth (publish-from-main; the ucm `version_check`
107
+ compares against it). The Orchestrator confirms the next free minor against the registry at
108
+ publish time and adjusts if the NOTIF workstream has advanced further; then confirms it is live
109
+ before any consumer pins it (per the dcm/ucm publish-topology note).
110
+ 3. **hram adoption is gated** on `2.15.0` published; its follow-on WO pins `ui-core-micha >= 2.15.0`.
111
+
112
+ ### Risks
113
+
114
+ - **API-fit risk (primary):** ChartFrame's props must let all ~20 hram panels — including the
115
+ SVG-bodied ones — slot in as `children` without escape hatches; if the frame is too opinionated the
116
+ migration fights it. Mitigation: frame stays deliberately thin and children-based; the presets, not
117
+ the frame, own chart specifics.
118
+ - **Responsive vs. fixed height:** governance wants container-sized; hram currently hardcodes
119
+ `height: 300`. The frame defaults responsive but must offer a `minHeight`/`aspect` fallback so the
120
+ later migration doesn't silently reflow every panel.
121
+ - **Export consolidation:** hram hand-rolls per-panel SVG/PNG export. A generic export from a
122
+ container ref must handle both an MUI X-Charts body (SVG under the hood) and a raw-`<svg>` body.
123
+ **Reuse hram's existing export mechanism / a stdlib-level approach; do NOT add a heavy new
124
+ dependency for PNG rendering without separate approval** (that would be another dependency gate).
125
+ If a clean generic export can't be done without a new dep, ship the SVG-serialize path and defer
126
+ PNG — surface the choice, don't silently drop it.
127
+ - **Theme/dark-mode:** the neutral palette must resolve correctly in both light and dark MUI themes.
128
+ - **i18n:** no hardcoded user-facing strings in the kit; all through ucm's `react-i18next`.
129
+
130
+ ### Required tests to WRITE (narrow — component-level, scoped to this change)
131
+
132
+ Written as part of implementation (the implementer writes the test code); **run only by the
133
+ Orchestrator**. Use ucm's existing frontend test setup (Vitest + React Testing Library, per prior
134
+ ucm WOs). Scoped to the new components — not the ucm full suite.
135
+
136
+ - **ChartFrame:** renders `title`/`subtitle`; `loading` shows the spinner and hides children;
137
+ `error` shows an error Alert; `isEmpty` shows `emptyMessage`; **state precedence
138
+ loading → error → empty → children** holds; the `toolbar` slot renders; export controls appear
139
+ only when export is enabled and invoke the handler / produce output; a11y `title`/`aria`/role
140
+ present on the chart region.
141
+ - **BarChart preset:** axis labels render on **both** axes; **legend appears only when >1 series**;
142
+ tooltip is enabled; colours come from theme tokens / passed palette (assert **no hardcoded hex**);
143
+ container is responsive (not a fixed px width); single, grouped, and stacked variants each render.
144
+ - **LineChart preset:** multi-series renders; both-axis labels; legend when multi-series; tooltip on.
145
+ - **Palette + formatters:** neutral palette resolves in both light and dark theme; number formatters
146
+ are locale-aware (percentage + compact) across at least two locales.
147
+ - **i18n:** the kit's own strings resolve via i18n, not hardcoded (missing-key / alternate-locale
148
+ check).
149
+
150
+ (Do **not** run the ucm full suite for this WO — that is the promotion gate's job. Run only the new
151
+ components' scoped tests, once, as the per-WO gate, alongside the single independent review on the
152
+ full assembled diff.)
153
+
154
+ ---
155
+
156
+ ## Part B — Implementation map (filled by the Orchestrator on `git pull`)
157
+
158
+ **Dependency approval:** operator explicitly approved adding `@mui/x-charts` as a `peerDependency`
159
+ (2026-07-30, in-chat). Cleared to proceed.
160
+
161
+ **Target version:** current `package.json` `version` is `2.14.0` (NOTIF-12 already published) →
162
+ next free additive minor is **`2.15.0`**, matching the Envelope's target. Confirm still free against
163
+ the npm registry immediately before publish.
164
+
165
+ ### Named files to change
166
+
167
+ - `src/components/charts/ChartFrame.jsx` — new. Frame component (Paper/title/toolbar/state
168
+ precedence/export/a11y).
169
+ - `src/components/charts/BarChart.jsx` — new. Preset over `@mui/x-charts` `BarChart`.
170
+ - `src/components/charts/LineChart.jsx` — new. Preset over `@mui/x-charts` `LineChart`.
171
+ - `src/components/charts/palette.js` — new. Neutral theme-aware categorical + sequential palette,
172
+ resolved from the active MUI theme (light/dark), NOT hardcoded hex in consumer-visible API.
173
+ - `src/components/charts/formatters.js` — new. Locale-aware number formatters (percentage, compact,
174
+ ratio) built on `Intl.NumberFormat`, driven off `i18next.language`.
175
+ - `src/components/charts/exportChart.js` — new. Generic SVG/PNG export util operating on a container
176
+ `ref`, mirroring hram's `XMLSerializer` → `Blob` → (PNG: `canvas` 2x-scale) → anchor-download
177
+ pattern (see hram `AllocationPerformancePanel.jsx` `handleExportSvg`/`handleExportPng`, no new
178
+ dependency).
179
+ - `src/index.js` — add barrel exports for `ChartFrame`, `BarChart`, `LineChart`, palette/formatter
180
+ helpers, additive only, alongside the existing "Components" export block (~lines 1-72).
181
+ - `src/i18n/chartsTranslations.ts` — new namespace, mirrors the existing
182
+ `notificationsTranslations.ts` shape (`{ 'ChartFrame.KEY': { de, fr, en, sw } }`); register it
183
+ wherever `notificationsTranslations`/`onboardingTranslations` are aggregated into the i18n bundle.
184
+ - `package.json` — add `"@mui/x-charts": "^8.0.0"` (or the loosest range compatible with hram's
185
+ pinned `8.28.2`) to `peerDependencies`; bump `"version"` to `2.15.0` (confirm free at publish time
186
+ per above); add `@mui/x-charts` to `devDependencies` too (peer deps aren't auto-installed, tests
187
+ need it present).
188
+ - `tests/ChartFrame.test.jsx`, `tests/BarChart.test.jsx`, `tests/LineChart.test.jsx`,
189
+ `tests/chartsPalette.test.js` (or similar) — new, in the existing `/tests/` root dir (not
190
+ co-located), `@vitest-environment jsdom` pragma, Vitest + RTL per `tests/NotificationSettings.test.jsx`
191
+ conventions.
192
+
193
+ ### Architecture/contract slice
194
+
195
+ - **Component style:** plain `.jsx` (no TypeScript, no PropTypes — JSDoc only), MUI `sx` prop for
196
+ styling, functional components with hooks. Mirror `src/components/ProfileComponent.jsx`.
197
+ - **i18n:** `useTranslation()` from `react-i18next`; keys namespaced `Category.KEY_NAME` (e.g.
198
+ `ChartFrame.EXPORT_SVG_LABEL`, `ChartFrame.EMPTY_DEFAULT`). Translation objects are flat
199
+ `{ key: { de, fr, en, sw } }` dictionaries per `src/i18n/notificationsTranslations.ts` — follow
200
+ that exact shape for `chartsTranslations.ts`, all four locales required.
201
+ - **Barrel export:** `src/index.js` groups exports by category with comment headers (Auth Context,
202
+ API/Services, Layouts, Pages, Components, Translations, Notifications, Onboarding) — add a
203
+ `// Charts` section, export components + `chartsTranslations` alongside the others.
204
+ - **Theme:** no centralized theme file exists in ucm today (components rely on the MUI default
205
+ theme via `sx` and `useTheme()`). The palette module must read `theme.palette.mode` via
206
+ `useTheme()` to select light/dark variants — do not assume a specific app theme shape beyond
207
+ standard MUI.
208
+ - **Peer deps:** current `peerDependencies` list (`@mui/material ^7.3.11`, `react ^19.2.6`, etc.) —
209
+ add `@mui/x-charts` alongside, do not touch existing entries.
210
+ - **hram reference (adoption target, NOT touched in this WO):** `hram/frontend/src/components/
211
+ OutputComponents/Results/ResultsTimelinePanel.jsx` and `ExpertTimelinePanel.jsx` show the
212
+ Paper→Typography(h6)→toolbar(ToggleButtonGroup)→loading/error/empty(Alert)→chart→footer skeleton
213
+ ChartFrame must replicate structurally; `AllocationPerformancePanel.jsx` (~lines 198-238) has the
214
+ reference export functions; `hram/frontend/src/.../theme.js` `dataViz` palette (~lines 20-30) is
215
+ the domain palette that stays app-side — the kit's neutral palette is a separate, generic
216
+ categorical/sequential set, not a copy of `dataViz`.
217
+
218
+ ### Key snippets (for the implementer to mirror, not copy verbatim)
219
+
220
+ hram's existing Paper/title/toolbar/state scaffolding (`ResultsTimelinePanel.jsx`/
221
+ `ExpertTimelinePanel.jsx`):
222
+ ```jsx
223
+ <Paper variant="outlined" sx={{ p: 2 }}>
224
+ <Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
225
+ <Typography variant="h6">{t("...")}</Typography>
226
+ <ToggleButtonGroup exclusive size="small" ...>...</ToggleButtonGroup>
227
+ </Box>
228
+ {loading && <CircularProgress />}
229
+ {error && <Alert severity="error">...</Alert>}
230
+ {isEmpty && <Alert severity="info">...</Alert>}
231
+ {!loading && !error && !isEmpty && children}
232
+ </Paper>
233
+ ```
234
+ hram's export pattern (`AllocationPerformancePanel.jsx`): `XMLSerializer().serializeToString(svgRef.current)`
235
+ → `Blob(['image/svg+xml'])` → anchor download for SVG; for PNG, load the SVG blob into an `Image`,
236
+ draw to a 2x-scaled `<canvas>` with a white background fill, then `canvas.toBlob()` → anchor download.
237
+ `exportChart.js` should generalize this to accept a container ref (find the first `<svg>` descendant)
238
+ so it works for both an MUI X-Charts body and a raw-`<svg>` body.
239
+
240
+ ### Invariants / do-not-touch / pitfalls
241
+
242
+ - Additive exports only — do not modify or remove any existing `src/index.js` export line.
243
+ - `@mui/x-charts` is a **peerDependency**, never a direct `dependency` (hram supplies its own
244
+ installed copy; ucm must not double-bundle it).
245
+ - No hardcoded hex/palette values in the public API surface — palette resolves from
246
+ `useTheme().palette.mode`; tests must assert this (e.g. render under both a light and dark MUI
247
+ theme, assert different resolved values).
248
+ - Container-sized/responsive by default — do not hardcode chart pixel width; offer `minHeight`/
249
+ `aspect` as an opt-in fallback only.
250
+ - Every user-facing string in the kit goes through `t('ChartFrame....')` — no literal English
251
+ strings in JSX.
252
+ - Do not touch hram, `dataViz`, or any hram i18n keys — this WO is ucm-only.
253
+ - State precedence is strict: `loading` beats `error` beats `isEmpty` beats `children` — write the
254
+ test asserting this order, not just each state individually.
255
+ - No new heavy dependency for PNG export (e.g. no `html2canvas`/`dom-to-image`) — reuse the
256
+ serialize+canvas approach; if a clean generic path is not achievable without a new dep, ship
257
+ SVG-export only and explicitly note PNG deferred in the `RESULT:` — do not silently drop it.
258
+
259
+ ### Progress contract
260
+
261
+ Codex must emit: one `PLAN: <step1> | <step2> | …` line up front; a single-line
262
+ `PROGRESS: [<n>/<total>] <present-tense action>` before every file/edit/step and `… done` on
263
+ completion, spaced so no gap exceeds ~2 min; exactly one final `RESULT: DONE|BLOCKED <reason>`.
264
+ stdout unbuffered.
265
+
266
+ ### Execution directive
267
+
268
+ Implement through `codex exec` in the background, invoked **directly via Bash** (never the
269
+ `debugger`/`*_coder` Agent wrappers), with **both** `--skip-git-repo-check` and
270
+ `--dangerously-bypass-approvals-and-sandbox`, prompt passed as a positional argument read from this
271
+ committed file (`"$(cat work-orders/CHART-1.md)"`). cwd = `ui-core-micha` repo root (this repo),
272
+ **never** the workspace root `…/webapps`. Fall back to direct Claude implementation only on Codex
273
+ quota/rate-limit/non-zero exit. Codex WRITES the required tests but MUST NOT RUN them and MUST NOT
274
+ run any review or `git add`/`commit`/`push` — leave the working tree uncommitted for the
275
+ Orchestrator's independent review, which runs after Codex finishes.
276
+
277
+ ---
278
+
279
+ ## Pastable mini-handover (for the fresh Orchestrator session)
280
+
281
+ > Orchestrator: implement `work-orders/CHART-1.md` in `ui-core-micha` (branch `main`). `git pull`
282
+ > first, read the WO, then follow the `orchestrate-codex` skill (Codex-first, fill Part B, run your
283
+ > own independent review + scoped tests, commit on green). NOTE the blocking precondition: adding
284
+ > `@mui/x-charts` as a `peerDependency` is a dependency change needing explicit operator approval
285
+ > before it is added.
@@ -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
  ```