@micha.bigler/ui-core-micha 2.22.0 → 2.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/WORK_ORDERS.md CHANGED
@@ -38,4 +38,6 @@ prefixed ID; never reuse a bare flat number across workstreams.
38
38
  | MSG-6c | Three MSG-3f/MSG-6 follow-on bugs: scrollbar, DM announcement icon, direct-conversation names | Reported live on jg-ferien staging right after MSG-3f/MSG-6 shipped: (1) a horizontal scrollbar appeared under short bubbles; (2) the Composer showed a broadcast/announcement icon even in a 1:1 direct conversation; (3) direct conversations showed the generic "Unbenannte Unterhaltung" fallback instead of the counterpart's name. | 2026-08-02 | done | 50e966e | No WO file — three operator-reported bugs, fixed directly. **(1) Scrollbar**: `Thread.jsx`'s scroll `Box` had `overflowY: auto` only, leaving `overflowX` at its browser-promoted `auto` default — any few-px overflow anywhere produced a persistent horizontal scrollbar. Fixed with explicit `overflowX: hidden`, plus `MessageBubble.jsx`'s meta row got `flexWrap`/`minWidth: 0` so it can't force the shrink-to-content bubble wider than its `maxWidth` cap. `ui_reviewer` pass 1 found a real P1 regression risk in this fix: MSG-3f's overflow icon (`top: -12, right: -12`) could get clipped by the new `overflowX: hidden` for own/right-aligned bubbles in a short conversation with no vertical scrollbar (the exact scenario in the original bug screenshot) — fixed with extra right padding (moved onto the inner message `Stack`, not the outer scroll container, after `ui_reviewer` pass 2 flagged a P3 cosmetic side effect where the shared container's asymmetric padding skewed the centered "Load older" button; both re-verified live in the harness: no clipping with 8.8px margin, "Load older" back to 0px off-center). **(2) Announcement icon**: `Composer` now accepts a `conversation` prop; `canAnnounce = allowAnnouncement && conversation?.kind !== 'direct'` gates both the icon and the dialog — backward compatible (a host not passing `conversation` behaves exactly as before). **(3) Unnamed conversations**: `ConversationList.jsx`'s `titleOf` now tries a new optional `resolveDirectUserName(other_user_id)` host resolver before falling back to the generic label, consuming dcm's new `other_user_id` field (see django-core-micha `MSG-6b`) — bare id only, ucm still resolves no names itself. Independent `reviewer`: no findings (both passes). Scoped gate: 90/90 across 14 test files. Published as npm 2.21.1, verified live. jg-ferien pin bumped 2.21.0→2.21.1 and wired: `conversation` prop passed to `Composer`, `resolveDirectUserName` supplied from jg's existing DM-candidate data (best-effort — a counterpart no longer among current candidates, e.g. a lapsed shared event, falls back to the generic label; accepted gap, not chased). |
39
39
  | MSG-3f | Compact chat-bubble message layout | A one-word message currently renders as **six stacked rows** (~260px): `MessageBubble`'s vertical `Stack spacing={0.75}` gives every element its own full-width row — meta line, body, `ReactionBar`, the reply control (a full MUI `Button`, hence "Antworten" centred on its own line), the overflow `IconButton`, and `children` where the host's `ReadTicks` lands as a "Zugestellt an 0" text line. Rebuild as WhatsApp-style bubbles: own right-aligned and tinted, incoming left-aligned and neutral, shrink-to-content; **timestamp and status icons inline at the end of the final text line** (this is the line-count fix, everything else is cosmetic); status as tick icons instead of text, own messages only; reply/react/edit/delete/copy collapsed into one hover-or-long-press menu; reaction chips overlapping the bubble edge; reply quote as a tinted block inside the bubble. **Acceptance: a short message with no reactions/attachments/quote occupies at most TWO visual lines** (text plus, at most, a narrow right-aligned meta line inside the bubble) — down from six. | 2026-08-02 | done | 1cd777b | Envelope + map: [work-orders/MSG-3f.md](work-orders/MSG-3f.md). Independent `reviewer`: no findings. `ui_reviewer` pass 1 (static-only, no browser tooling that session): one P2 (U1) — the touch-tap reveal (`actionsVisible`) had no dismiss path, permanently pinning the overflow icon visible after any click that bubbled to the bubble (poll option, announcement link, reply quote), violating the "no visible affordance at rest" requirement; one P3 (U2, a consequence of U1). Fixed by the Orchestrator: a `bubbleRef` spanning the Paper + reaction-picker sibling, plus a document-level capture-phase `pointerdown` listener that dismisses `actionsVisible` on any outside interaction — live-verified in the running dev harness (reveal, dismiss-on-outside-click, and reaction-chip-click-does-not-dismiss all confirmed via DOM/computed-style inspection; screenshot tooling was unavailable this session, verification was structural/computed-style based). `ui_reviewer` pass 2 (re-review of the fix): confirmed the fix sound (event-phase ordering, per-instance isolation, cleanup all correct), but found one more gap (U3, P2) — no regression test pinned the dismiss behavior. Closed by adding two tests to `tests/messagingMessageActions.test.jsx`: outside-click dismiss + reaction-chip-does-not-dismiss, and cross-instance isolation (two bubbles, revealing one doesn't affect the other). Scoped gate: 42/42 passing across all 7 named test files. Acceptance criterion verified live: a short incoming direct-conversation message (no sender name, no reactions) renders at exactly 2 visual lines, 78px total height (down from ~260px). Published as npm 2.21.0, verified live on the registry. New `ui-core-micha-dev-harness` launch.json entry added (port 5140) for this and future ucm visual-verification WOs. | Approval Gate #1 granted by the operator 2026-08-02, who also supplied WhatsApp reference screenshots; the envelope records what those pin down beyond the prose (meta rides the LAST line of a multi-line body, not a line of its own; incoming messages carry no status icon at all; **no** action affordance visible at rest; meta visually recessed with read state carried by tick colour). **Tier 2** — shared-core, and an opinionated visual decision imposed on every future ucm messaging consumer. Acceptable now because jg-ferien is the only messaging consumer today (spesix deferred per the messaging-centralization course correction), so it lands with one adopter instead of being retrofitted — but jg vocabulary/layout assumptions must not leak in, same line MSG-3d drew for `external_key`. **Two non-negotiables:** every icon keeps the text it replaced as its accessible name (no i18n key deleted just because its text stopped rendering), and the action affordance must be keyboard-operable without hover or long-press — today's `opacity: 0` hover-only menu is unreachable on touch AND invisible to keyboard users, and reproducing that pre-existing defect is not acceptable. **Two deliberate cost-cuts by the operator (2026-08-02), both after being shown what they cost:** (1) the meta does **not** have to ride the last text line — inside the bubble and right-aligned is enough, a trailing meta line is fine; the inline pattern is listed as a risk to be *avoided*, not managed. (2) **No long-press and no swipe** — desktop uses hover plus keyboard focus (`opacity: 0` does not remove an element from the tab order, so a `:focus-within` rule closes the keyboard half cheaply), touch uses a plain tap on the bubble; React expands the existing `ReactionBar` emoji row rather than being nested inside the MUI `Menu`, which does not nest gracefully. **Trap found while scoping:** today the whole menu is gated on `canAct` (own message or moderator) because it only held Edit/Delete/Copy — once Reply lives there an **incoming** message needs a menu too, so there are two variants (any message: Reply/React/Copy; actionable message: plus Edit/Delete). Getting this wrong makes replying to anyone else impossible and will not surface in a fixture containing only own messages. Remaining risk: announcements/polls/attachments also render through this bubble and will look broken if only plain text is considered. **Read-status observation, deliberately NOT a precondition:** `ReadTicks` fetches per message and dcm's `serialize_message` carries no read state, so a thread fires one REST call per own message; tick icons make it more visible but not worse (`canShowReadTicks` already bounds it to own messages, deliberately). Putting the delivered/read aggregate on `serialize_message` — viewer-independent, exactly as `reply_count`/`last_reply_at` ride it since MSG-3d — would remove the fan-out and is a worthwhile dcm follow-up, but gating a UI fix behind a dcm release is the wrong trade. Deferred: swipe-to-reply, and the (also tall) Composer. Independent of MSG-3e — different files, but coordinate version bumps if both are in flight. |
40
40
  | MSG-6e | Messaging surface defects found by click-through: timestamps, poll cache key, read-state frames, double read-marking, plurals | Six independent defects reproduced in a running app: (A) `MessageBubble.jsx:133` formats with a bare `toLocaleString()` — runtime locale, not i18next's, and the full date instead of `HH:MM` + a day separator; (B) `createConversationPoll` (`MessagingProvider.jsx:403`) does `poll?.message \|\| poll`, but dcm's `_poll_response` has no `message` key, so a phantom entry is cached under the POLL id with no `created_at`; (C) `applyFrame` discards dcm's `read_state`/`delivered`/`thread_read_state` frames; (D) `markConversationRead` fires twice per open (Thread's useEffect + ConversationList's onClick); (E) `SHOW_REPLIES` has no plural forms. | 2026-08-03 | done | 3c7cfbd | Envelope: [work-orders/MSG-6e.md](work-orders/MSG-6e.md). **Reviewed independently: `reviewer` + `ui_reviewer` (requested per envelope for scope A), both clean after one fix round.** Fix round 1 closed a **P1** (`reviewer`): the read-state receipt fix only worked through `recipient_detail`, which dcm never returns for direct (1:1) conversations — the most common case — so `ReadTicks` silently never updated for DMs; fixed by deriving the receipt from `cache.receipts` keyed by `conversation.other_user_id` for the direct case. **Scope C landed narrower than the original envelope** (see the WO's "OPERATOR DECISIONS" section): only `read_state`/`thread_read_state` are handled — `delivered` was deliberately left out, since `django-core-micha` MSG-7 (same release) retires its only publisher (`mark_delivered`). **Not fixed, tracked as follow-ups (P2/P3, `ui_reviewer`, non-blocking):** the day-separator is a bespoke `Divider`+`Typography` pair rather than reusing `LoginForm.jsx`'s existing divider-with-label convention; the separator text has no `nowrap` guard (unlike `MessageBubble`'s timestamp); the first day in a thread never gets a separator (matches the WO's literal spec, not a deviation). Full suite 215→222 passed, 0 failed; `tsc` clean. jg's pin bump is a separate step, still NOT part of this WO — jg also needs `django-core-micha` MSG-7. **B is the one that corrupts state and the most instructive:** the `\|\| poll` fallback turns a wrong assumption about the response shape into silent data corruption instead of an error — observed as a fresh poll rendering with no timestamp and sorting to the TOP of the timeline, above messages from two days earlier, because `chronological()` sorts on `new Date(undefined)` → NaN. Correct after reload. **Explicit non-goal: do NOT touch `isOwn`/`canShowReadTicks`/`senderName`** — the own/incoming alignment, missing ticks and "Unbekannte Person" all trace to dcm never sending a `sender` object; ucm's code is already correct (`MessageBubble.jsx:100/108/109` implements the alignment in full, it just never sees `isOwn === true`). That fix is `django-core-micha` MSG-7. **Ordering caveat on C:** jg MSG-9 finding 1 (live WS push ~10 min late) is undiagnosed — handling frames that do not arrive fixes nothing, so do C last and report if finding 1 is still open. jg's pin bump is NOT part of this WO: jg also needs dcm MSG-7, and bumping one alone leaves the surface half-fixed. |
41
- | MSG-6f | `titleOf` never calls the host resolver for a broadcast; the read ticks claim a delivery that does not exist | (A) `ConversationList.jsx` `titleOf()` gates the host callback on `conversation.kind === 'managed'`, so jg-ferien MSG-10's correct broadcast branch in `resolveManagedConversationLabel` is **unreachable from the list** — the row still falls through to `MessagingList.UNTITLED`. (B) dcm MSG-7 removed `delivered_count` from `read-status`, but `ReadTicks.jsx` still renders `t('MessagingReadTicks.DELIVERED', { count: renderedStatus.delivered_count \|\| 0 })`, so `undefined \|\| 0` produces the identical "Zugestellt an 0" label as before — a claimed delivery with no data source behind it. (C) a viewer already sitting in an open thread when a new message lands is recorded as "not read" until they navigate away and back — the mount-effect only re-marks once per open. | 2026-08-03 | done | (pending push) | Envelope: [work-orders/MSG-6f.md](work-orders/MSG-6f.md); all three scopes released together per operator's critical-path directive (one release instead of two, since A and C never depended on dcm) — this also stands as the operator confirmation the envelope asked for before implementing B's vocabulary change. **A:** kind gate removed entirely from `titleOf()`; a host resolver returning `null` already means "no label", so gating duplicated a decision that belongs to the host. **B:** `DELIVERED` retired; dcm MSG-9 (published 2.40.0) added `read_count`/`recipient_count` to `read-status`, gated the same way as `recipient_detail` (non-direct, `read_receipt_detail`-eligible). `ReadTicks.jsx` now renders: counts present → a read ratio (`READ_RATIO`, e.g. "18/40"), no counts + `direct` → two-state tick (`SENT`/`ALL_READ`), no counts + non-direct → nothing (was a permanently-grey "always delivered" tick before). **C:** `Thread.jsx` now also subscribes to `MESSAGING_ENVELOPE` directly (independent of `MessagingProvider`'s own subscription — the registry supports multiple handlers per envelope) and re-marks read on a live `message` frame for the open conversation, skipped when the tab is hidden. **Reviewed independently: `reviewer` + `ui_reviewer`, both clean after one fix round.** Fix round 1 closed a **P1** (`reviewer`, self-caught during the fix, not shipped): the ratio was briefly recomputed client-side from `recipientDetail.filter(r => r.last_read_at).length` to make it "live-update" from `read_state` frames — this silently contradicts the WO's explicit instruction and is wrong, because `recipientDetail`'s `last_read_at` is never thresholded against the specific message's `created_at` the way dcm's own aggregate is (`last_read_at__gte=message.created_at`, dcm `services.py`); in a real group with history it would read as close to N/N regardless of who actually saw *this* message. Reverted to trusting dcm's `read_count`/`recipient_count` verbatim, with the trade-off documented in `ReadTicks.jsx`: the ratio no longer live-updates from a `read_state` frame, only on the next read-status fetch (unlike the DM tick and the per-person popover, both viewer/timestamp-relative facts that safely reuse `cache.receipts`). Also fixed a P3 (`ui_reviewer`): the ratio's `color="text.secondary"` diverged from the sibling meta row's `inherit` convention, changed to `inherit`. **Known, disclosed, non-blocking follow-up (`ui_reviewer` P2, not fixed here):** the DM tick's fixed `grey.500`/`primary.main` colour pair is not fully dark-mode contrast-neutral — verified by hand (WCAG-style luminance/contrast math against both themes' actual palette values, not just accepting the reviewer's claim): light-mode state separation ≈1.71:1, dark-mode ≈1.53:1 (worse, since `primary.main` lightens substantially in dark mode while `grey.500` is a fixed, non-mode-aware value). No single grey threads both themes well — the two themes need opposite-direction adjustments relative to their own inverted primary-blue lightness — and the icon shape (`Done` vs `DoneAll`) remains the primary, non-colour signal per the component's own design principle, so this is tracked rather than guessed at further without live visual verification. Test-infrastructure fix alongside C, not a behavioural change: `Thread.jsx`'s new `subscribe()` call broke 3 existing test files' single-captured-handler mock pattern (`frameHandler = handler`, overwritten per call) — converted to Set/Map-based multi-handler dispatch in `messagingChunk6.test.jsx`, `messagingMsg6.test.jsx`, `messagingThreadReceipts.test.jsx`. Full suite 234/234 (was 222), `tsc` clean. Published as npm 2.22.0 (pending). jg-ferien pin bump to `dcm 2.40.0` / `ucm 2.22.0` is a separate, subsequent step. |
41
+ | MSG-6f | `titleOf` never calls the host resolver for a broadcast; the read ticks claim a delivery that does not exist | (A) `ConversationList.jsx` `titleOf()` gates the host callback on `conversation.kind === 'managed'`, so jg-ferien MSG-10's correct broadcast branch in `resolveManagedConversationLabel` is **unreachable from the list** — the row still falls through to `MessagingList.UNTITLED`. (B) dcm MSG-7 removed `delivered_count` from `read-status`, but `ReadTicks.jsx` still renders `t('MessagingReadTicks.DELIVERED', { count: renderedStatus.delivered_count \|\| 0 })`, so `undefined \|\| 0` produces the identical "Zugestellt an 0" label as before — a claimed delivery with no data source behind it. (C) a viewer already sitting in an open thread when a new message lands is recorded as "not read" until they navigate away and back — the mount-effect only re-marks once per open. | 2026-08-03 | done | 61e13b0 | Envelope: [work-orders/MSG-6f.md](work-orders/MSG-6f.md); all three scopes released together per operator's critical-path directive (one release instead of two, since A and C never depended on dcm) — this also stands as the operator confirmation the envelope asked for before implementing B's vocabulary change. **A:** kind gate removed entirely from `titleOf()`; a host resolver returning `null` already means "no label", so gating duplicated a decision that belongs to the host. **B:** `DELIVERED` retired; dcm MSG-9 (published 2.40.0) added `read_count`/`recipient_count` to `read-status`, gated the same way as `recipient_detail` (non-direct, `read_receipt_detail`-eligible). `ReadTicks.jsx` now renders: counts present → a read ratio (`READ_RATIO`, e.g. "18/40"), no counts + `direct` → two-state tick (`SENT`/`ALL_READ`), no counts + non-direct → nothing (was a permanently-grey "always delivered" tick before). **C:** `Thread.jsx` now also subscribes to `MESSAGING_ENVELOPE` directly (independent of `MessagingProvider`'s own subscription — the registry supports multiple handlers per envelope) and re-marks read on a live `message` frame for the open conversation, skipped when the tab is hidden. **Reviewed independently: `reviewer` + `ui_reviewer`, both clean after one fix round.** Fix round 1 closed a **P1** (`reviewer`, self-caught during the fix, not shipped): the ratio was briefly recomputed client-side from `recipientDetail.filter(r => r.last_read_at).length` to make it "live-update" from `read_state` frames — this silently contradicts the WO's explicit instruction and is wrong, because `recipientDetail`'s `last_read_at` is never thresholded against the specific message's `created_at` the way dcm's own aggregate is (`last_read_at__gte=message.created_at`, dcm `services.py`); in a real group with history it would read as close to N/N regardless of who actually saw *this* message. Reverted to trusting dcm's `read_count`/`recipient_count` verbatim, with the trade-off documented in `ReadTicks.jsx`: the ratio no longer live-updates from a `read_state` frame, only on the next read-status fetch (unlike the DM tick and the per-person popover, both viewer/timestamp-relative facts that safely reuse `cache.receipts`). Also fixed a P3 (`ui_reviewer`): the ratio's `color="text.secondary"` diverged from the sibling meta row's `inherit` convention, changed to `inherit`. **Known, disclosed, non-blocking follow-up (`ui_reviewer` P2, not fixed here):** the DM tick's fixed `grey.500`/`primary.main` colour pair is not fully dark-mode contrast-neutral — verified by hand (WCAG-style luminance/contrast math against both themes' actual palette values, not just accepting the reviewer's claim): light-mode state separation ≈1.71:1, dark-mode ≈1.53:1 (worse, since `primary.main` lightens substantially in dark mode while `grey.500` is a fixed, non-mode-aware value). No single grey threads both themes well — the two themes need opposite-direction adjustments relative to their own inverted primary-blue lightness — and the icon shape (`Done` vs `DoneAll`) remains the primary, non-colour signal per the component's own design principle, so this is tracked rather than guessed at further without live visual verification. Test-infrastructure fix alongside C, not a behavioural change: `Thread.jsx`'s new `subscribe()` call broke 3 existing test files' single-captured-handler mock pattern (`frameHandler = handler`, overwritten per call) — converted to Set/Map-based multi-handler dispatch in `messagingChunk6.test.jsx`, `messagingMsg6.test.jsx`, `messagingThreadReceipts.test.jsx`. Full suite 234/234 (was 222), `tsc` clean. Published as npm 2.22.0 (confirmed live on the registry). jg-ferien pin bump to `dcm 2.40.0` / `ucm 2.22.0` done separately (MSG-14).
42
+ | MSG-6g | Poll UI: tap-to-vote with proportional result bars, no separate vote button | `PollCard.jsx` required select-then-click-"Abstimmen" two-step voting, with results as a bare `(n)` count suffix. Operator request: WhatsApp-style tap-to-vote with proportional bars. | 2026-08-03 | done | (pending push) | Envelope: [work-orders/MSG-6g.md](work-orders/MSG-6g.md). UX improvement, blocked nothing, blocked by nothing — except a real blocker found mid-implementation: the WO's own assumption that dcm's vote endpoint treats `option_ids` as a complete authoritative set per call did not hold for multi-select (`vote_poll` only ever added votes, never retracted one for an option missing from a later call — untoggling was silently a no-op). Reported per the WO's own "stop and report" contingency; operator confirmed fixing it directly in dcm rather than working around it client-side — see dcm `work-orders/MSG-11.md`, published as dcm 2.40.1. **Implemented directly by the Orchestrator** (Codex unavailable, out of credits). Single-choice now renders a real `RadioGroup` (native "different option moves the vote, same option is a no-op" semantics, matching `AuthFactorRequirementCard.jsx`'s existing radio convention); multi-select renders `Checkbox`. Each option shows a `LinearProgress` bar + count/percentage (in the accessible name, not width alone); count/percent never `NaN` at zero votes. Every tap is optimistic (`MessagingProvider.castPollVote`, patterned on `toggleReaction`'s immediate-patch/reconcile-or-revert shape) with a per-poll "latest tap wins" token so rapid re-taps race deterministically instead of a stale response clobbering a newer one; a rejected vote visibly reverts. **Independent `reviewer` + `ui_reviewer` (both mandatory per the WO), 5 findings total, all fixed** — see `work-orders/MSG-6g.md`'s as-built record for full detail: `reviewer` R1 (**P1**, real bug) — the optimistic count projection was built from the stale caller-supplied `poll` prop instead of the freshly cache-read `previousPoll`, computing a wrong transient vote count in the shared cache on a second rapid tap; fixed, and verified non-vacuous by reverting the fix and confirming the new regression test fails with the exact predicted wrong number before restoring it. R2 (P2) — no test covered non-zero-count rapid-tap math (zero-vote fixtures elsewhere masked R1 via a `Math.max(0, ...)` clamp); closed with a dedicated regression test. R3 (P3) — a possible `undefined` pushed into an option's `voters` array when `user?.id` is momentarily unset; guarded. `ui_reviewer` U1 (P2) — no visual distinction between an in-flight optimistic vote and a confirmed one; fixed by reusing `ReactionBar.jsx`'s `pending`-Set dimming convention, deliberately *not* disabling the control (the WO requires rapid re-taps to stay possible, not be blocked). U2 (P2) — bare ungrouped `Radio` controls diverged from this repo's own convention and lost native arrow-key/group semantics; fixed with `RadioGroup`, which also let a manual `Box onClick`+`stopPropagation` whole-row-tap hack be removed in favour of `FormControlLabel`'s native label-click — that same change surfaced and fixed an unreported bug (`RadioGroup`'s own `onChange` only ever returns a DOM string via `event.target.value`, silently coercing a non-string option id) caught by an existing numeric-id test fixture. Removed now-orphaned `MessagingPoll.VOTE`/`SELECT_OPTION` i18n keys; added `MessagingPoll.OPTION_RESULT`. Scoped gate: full suite 234 → 241 (7 new tests), `tsc` clean. Published as npm 2.23.0. jg-ferien pin bump is a separate, subsequent step. |
43
+ | MSG-6g | Poll UI: tap-to-vote with result bars, no separate vote button | `PollCard.jsx` renders options as `FormControlLabel` + `Checkbox`/radio labelled `"<text> (<vote_count>)"`, with a separate "Abstimmen" submit button — voting is a two-step select-then-submit, and the result is a bare parenthesised number. Operator request: one tap on the option itself casts the vote, and the distribution reads at a glance from proportional bars (WhatsApp-style). | 2026-08-03 | planned | | Envelope: [work-orders/MSG-6g.md](work-orders/MSG-6g.md). Operator request 2026-08-03. **UX improvement, not a defect — the current poll works.** Scoped separately so it never blocks the messaging defect WOs (dcm MSG-10, ucm MSG-6f); it blocks nothing and is blocked by nothing. Frontend only: if tap-to-vote appears to need a backend change, stop and report. Two things easy to break and pinned by tests: a failed vote must **visibly revert** (a silently swallowed failure leaving a bar showing an unrecorded vote is worse than the current two-step flow), and the viewer's `voted_option_ids` must survive a `poll_updated` frame that does not carry it — that is viewer-specific and REST-only by design, and the merge-trap guard was a deliberate earlier fix. Also specified: zero-vote rendering must be defined rather than letting a division produce `NaN`; the denominator is total votes cast, not participant count; the closed state must stay unmistakable now that a missing submit button no longer signals it; and the accessible name must carry count/percentage, since bar width alone does not. Explicit non-goal: no "who voted for what" disclosure — `serialize_poll`'s `voters` data is not licence to surface it, that is an untaken privacy decision. |
@@ -76,10 +76,9 @@ export const messagingTranslations = {
76
76
  'MessagingReactions.TOGGLE_other': { de: '{{emoji}} Reaktion mit {{count}} Stimmen umschalten', en: 'Toggle {{emoji}} reaction with {{count}} votes', fr: 'Basculer la réaction {{emoji}} avec {{count}} votes' },
77
77
  'MessagingReactions.ERROR': { de: 'Reaktion konnte nicht gespeichert werden: {{message}}', en: 'Reaction could not be saved: {{message}}', fr: 'Impossible d’enregistrer la réaction : {{message}}' },
78
78
  'MessagingPoll.LABEL': { de: 'Umfrage', en: 'Poll', fr: 'Sondage' },
79
- 'MessagingPoll.VOTE': { de: 'Abstimmen', en: 'Vote', fr: 'Voter' },
80
79
  'MessagingPoll.CLOSE': { de: 'Umfrage schliessen', en: 'Close poll', fr: 'Clore le sondage' },
81
80
  'MessagingPoll.CLOSED': { de: 'Geschlossen', en: 'Closed', fr: 'Clos' },
82
- 'MessagingPoll.SELECT_OPTION': { de: 'Wähle mindestens eine Option.', en: 'Select at least one option.', fr: 'Sélectionnez au moins une option.' },
81
+ 'MessagingPoll.OPTION_RESULT': { de: '{{count}} ({{percent}} %)', en: '{{count}} ({{percent}}%)', fr: '{{count}} ({{percent}} %)' },
83
82
  'MessagingPoll.VOTE_ERROR': { de: 'Stimme konnte nicht gespeichert werden: {{message}}', en: 'Vote could not be saved: {{message}}', fr: 'Impossible d’enregistrer le vote : {{message}}' },
84
83
  'MessagingPoll.CLOSE_ERROR': { de: 'Umfrage konnte nicht geschlossen werden: {{message}}', en: 'Poll could not be closed: {{message}}', fr: 'Impossible de clore le sondage : {{message}}' },
85
84
  'MessagingPoll.CREATE': { de: 'Umfrage erstellen', en: 'Create poll', fr: 'Créer un sondage' },
@@ -18,6 +18,31 @@ function idOf(item) { var _a, _b, _c; return (_c = (_b = (_a = item === null ||
18
18
  function mergeById(slice, item) { const id = idOf(item); return id == null ? slice : Object.assign(Object.assign({}, slice), { [id]: Object.assign(Object.assign({}, slice[id]), item) }); }
19
19
  function resultsOf(response) { return (response === null || response === void 0 ? void 0 : response.results) || []; }
20
20
  function clientRequestIdOf(message) { return message === null || message === void 0 ? void 0 : message.client_request_id; }
21
+ // Optimistic vote-count/voters projection for MSG-6g tap-to-vote -- only options whose
22
+ // membership in the vote actually changed get their count/voters adjusted, so an
23
+ // unrelated option's tally is never nudged by someone else's concurrent vote landing in
24
+ // cache between taps.
25
+ function applyOptimisticVote(poll, previousSelectedIds, nextSelectedIds, userId) {
26
+ const previousSet = new Set(previousSelectedIds || []);
27
+ const nextSet = new Set(nextSelectedIds || []);
28
+ const options = (poll.options || []).map((option) => {
29
+ var _a, _b, _c;
30
+ const wasSelected = previousSet.has(option.id);
31
+ const isSelected = nextSet.has(option.id);
32
+ if (wasSelected === isSelected)
33
+ return option;
34
+ const delta = isSelected ? 1 : -1;
35
+ // userId can be undefined for a brief window if AuthContext hasn't
36
+ // populated `user` yet -- never push a literal `undefined` into the
37
+ // voters array (the reconcile/revert below overwrites it regardless,
38
+ // but the optimistic window must not carry a bogus entry).
39
+ const voters = Array.isArray(option.voters) && userId != null
40
+ ? (isSelected ? [...option.voters, userId] : option.voters.filter((voter) => voter !== userId))
41
+ : option.voters;
42
+ return Object.assign(Object.assign({}, option), { vote_count: Math.max(0, ((_c = (_a = option.vote_count) !== null && _a !== void 0 ? _a : (_b = option.voters) === null || _b === void 0 ? void 0 : _b.length) !== null && _c !== void 0 ? _c : 0) + delta), voters });
43
+ });
44
+ return Object.assign(Object.assign({}, poll), { options, voted_option_ids: nextSelectedIds });
45
+ }
21
46
  /**
22
47
  * dcm's DRF validation errors aren't always {detail}: ConversationAttachmentView
23
48
  * translates a rejected upload into a field-keyed {"files": [...]} body (the
@@ -214,6 +239,10 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
214
239
  const activeIdRef = useRef(activeConversationId);
215
240
  const seenEventsRef = useRef(new Set());
216
241
  const filtersRef = useRef(filters);
242
+ // Per-poll "latest tap wins" token: rapid successive taps race, and a stale
243
+ // response (revert on failure, or reconcile on success) must not clobber a
244
+ // newer optimistic state that has already superseded it.
245
+ const pollVoteTokensRef = useRef({});
217
246
  activeIdRef.current = activeConversationId;
218
247
  filtersRef.current = filters;
219
248
  const refreshConversations = useCallback(async () => {
@@ -389,11 +418,40 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
389
418
  return poll;
390
419
  }, [api, patchMessage]);
391
420
  const castPollVote = useCallback(async (messageId, poll, optionIds) => {
392
- const result = await api.votePoll(poll.id, optionIds);
393
- const nextPoll = Object.assign(Object.assign({}, poll), ((result === null || result === void 0 ? void 0 : result.poll) || result));
394
- patchMessage(Object.assign(Object.assign({}, (cache.messages[messageId] || { id: messageId })), { poll: nextPoll }));
395
- return result;
396
- }, [api, cache.messages, patchMessage]);
421
+ // Optimistic tap-to-vote (MSG-6g), following the same immediate-patch /
422
+ // reconcile-or-revert shape `toggleReaction` already uses. The previous poll
423
+ // (pre-tap) is captured up front so a failure can revert to it exactly, not to
424
+ // whatever `cache.messages` happens to hold when the error arrives.
425
+ const message = cache.messages[messageId] || { id: messageId };
426
+ const previousPoll = message.poll || poll;
427
+ // Project from `previousPoll` (the freshest known state in the shared cache),
428
+ // never from the caller-supplied `poll` param -- `PollCard` is a "dumb",
429
+ // prop-driven component that may still be holding the pre-first-tap `poll`
430
+ // it was originally passed when a second rapid tap fires, and projecting
431
+ // counts from that stale baseline would compute a wrong transient
432
+ // vote_count/voters in the SHARED cache (visible to every consumer, not
433
+ // just this component's own render).
434
+ const optimisticPoll = applyOptimisticVote(previousPoll, previousPoll.voted_option_ids, optionIds, user === null || user === void 0 ? void 0 : user.id);
435
+ const token = {};
436
+ pollVoteTokensRef.current[poll.id] = token;
437
+ patchMessage(Object.assign(Object.assign({}, message), { poll: optimisticPoll }));
438
+ try {
439
+ const result = await api.votePoll(poll.id, optionIds);
440
+ // A newer tap already started (and will reconcile/revert for itself) --
441
+ // applying this now-stale response would stomp on it.
442
+ if (pollVoteTokensRef.current[poll.id] !== token)
443
+ return result;
444
+ const nextPoll = Object.assign(Object.assign({}, poll), ((result === null || result === void 0 ? void 0 : result.poll) || result));
445
+ patchMessage(Object.assign(Object.assign({}, (cache.messages[messageId] || { id: messageId })), { poll: nextPoll }));
446
+ return result;
447
+ }
448
+ catch (error) {
449
+ if (pollVoteTokensRef.current[poll.id] === token) {
450
+ patchMessage(Object.assign(Object.assign({}, (cache.messages[messageId] || { id: messageId })), { poll: previousPoll }));
451
+ }
452
+ throw error;
453
+ }
454
+ }, [api, cache.messages, patchMessage, user]);
397
455
  const closeConversationPoll = useCallback(async (messageId, poll) => {
398
456
  var _a;
399
457
  const result = await api.closePoll(poll.id);
@@ -1,17 +1,37 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Alert, Button, Checkbox, FormControlLabel, Paper, Stack, Typography } from '@mui/material';
3
- import { useEffect, useState } from 'react';
2
+ import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
3
+ import { Alert, Button, Checkbox, Chip, FormControlLabel, LinearProgress, Paper, Radio, RadioGroup, Stack, Typography } from '@mui/material';
4
+ import { useEffect, useRef, useState } from 'react';
4
5
  import { useTranslation } from 'react-i18next';
5
6
  import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
6
- /** Independently mountable live poll view. Server authorization remains authoritative. */
7
+ /**
8
+ * Independently mountable live poll view. Server authorization remains authoritative.
9
+ *
10
+ * Tap-to-vote (MSG-6g): each option is its own control, cast immediately on tap — no
11
+ * separate submit step. Single-choice uses a real `RadioGroup` (native "tap a different
12
+ * option moves the vote, tap the same one is a no-op" semantics, plus the group's own
13
+ * arrow-key navigation and screen-reader group announcement — a bare ungrouped `Radio`
14
+ * per option, tried first, loses both); multi-select uses `Checkbox` (each tap
15
+ * independently toggles that option). `FormControlLabel` wraps each control, matching
16
+ * this repo's existing radio convention (`AuthFactorRequirementCard.jsx`) — clicking
17
+ * anywhere in the label natively activates the control, no manual click-handling needed.
18
+ * Every tap is optimistic (see `castPollVote` in `MessagingProvider`), dims while
19
+ * in flight (matching `ReactionBar`'s `pending` convention) so an optimistic vote is
20
+ * never visually indistinguishable from a confirmed one, and visibly reverts on a
21
+ * rejected vote.
22
+ */
7
23
  export function PollCard({ message, canClose: canCloseProp }) {
8
- var _a, _b, _c, _d, _e, _f, _g;
24
+ var _a, _b, _c, _d, _e, _f, _g, _h;
9
25
  const { t } = useTranslation();
10
26
  const { castPollVote, closeConversationPoll, currentUser } = useMessaging();
11
27
  const poll = message === null || message === void 0 ? void 0 : message.poll;
12
28
  const [selected, setSelected] = useState([]);
13
29
  const [error, setError] = useState(null);
14
30
  const [busy, setBusy] = useState(false);
31
+ const [pending, setPending] = useState(() => new Set());
32
+ // Guards a rejected vote's revert against a newer tap that has since
33
+ // superseded it -- see the matching token in MessagingProvider.castPollVote.
34
+ const voteTokenRef = useRef(null);
15
35
  // voted_option_ids is a view-level enrichment dcm adds ONLY to the
16
36
  // create/vote/close poll REST responses (serialize_poll itself, every
17
37
  // message-list/thread load, and every poll_updated realtime frame never
@@ -26,22 +46,39 @@ export function PollCard({ message, canClose: canCloseProp }) {
26
46
  const closed = Boolean(poll.closed_at);
27
47
  const creatorId = (_c = (_a = poll.created_by_id) !== null && _a !== void 0 ? _a : (_b = poll.created_by) === null || _b === void 0 ? void 0 : _b.id) !== null && _c !== void 0 ? _c : poll.created_by;
28
48
  const canClose = (_g = (_e = (_d = canCloseProp !== null && canCloseProp !== void 0 ? canCloseProp : poll.can_close) !== null && _d !== void 0 ? _d : message === null || message === void 0 ? void 0 : message.can_close) !== null && _e !== void 0 ? _e : (_f = message === null || message === void 0 ? void 0 : message.permissions) === null || _f === void 0 ? void 0 : _f.can_close) !== null && _g !== void 0 ? _g : (creatorId != null && creatorId === (currentUser === null || currentUser === void 0 ? void 0 : currentUser.id));
29
- const changeSelection = (id) => setSelected((current) => poll.allow_multiple ? (current.includes(id) ? current.filter((value) => value !== id) : [...current, id]) : (current.includes(id) ? [] : [id]));
30
- const vote = async () => {
31
- if (!selected.length) {
32
- setError(t('MessagingPoll.SELECT_OPTION'));
49
+ const totalVotes = (poll.options || []).reduce((sum, option) => { var _a, _b, _c; return sum + ((_c = (_a = option.vote_count) !== null && _a !== void 0 ? _a : (_b = option.voters) === null || _b === void 0 ? void 0 : _b.length) !== null && _c !== void 0 ? _c : 0); }, 0);
50
+ const tap = async (id) => {
51
+ if (closed)
33
52
  return;
34
- }
35
- setBusy(true);
53
+ const previousSelected = selected;
54
+ const nextSelected = poll.allow_multiple
55
+ ? (selected.includes(id) ? selected.filter((value) => value !== id) : [...selected, id])
56
+ : [id];
57
+ // Belt-and-suspenders: native radios already don't fire a change event
58
+ // when re-clicking the checked one, but this guards any caller shape.
59
+ if (nextSelected.length === previousSelected.length && nextSelected.every((value) => previousSelected.includes(value)))
60
+ return;
61
+ const token = {};
62
+ voteTokenRef.current = token;
63
+ setSelected(nextSelected);
36
64
  setError(null);
65
+ // Rapid re-taps are allowed and race deliberately (last write wins via
66
+ // the token above) -- `pending` is a visual signal only, never gates
67
+ // further taps, so it must not disable the control.
68
+ setPending((current) => new Set(current).add(id));
37
69
  try {
38
- await castPollVote(message.id, poll, selected);
70
+ await castPollVote(message.id, poll, nextSelected);
39
71
  }
40
72
  catch (voteError) {
41
- setError(t('MessagingPoll.VOTE_ERROR', { message: extractApiErrorMessage(voteError) }));
73
+ // A later tap already moved the selection on; a stale failure must not
74
+ // revert past it.
75
+ if (voteTokenRef.current === token) {
76
+ setSelected(previousSelected);
77
+ setError(t('MessagingPoll.VOTE_ERROR', { message: extractApiErrorMessage(voteError) }));
78
+ }
42
79
  }
43
80
  finally {
44
- setBusy(false);
81
+ setPending((current) => { const next = new Set(current); next.delete(id); return next; });
45
82
  }
46
83
  };
47
84
  const close = async () => {
@@ -57,14 +94,37 @@ export function PollCard({ message, canClose: canCloseProp }) {
57
94
  setBusy(false);
58
95
  }
59
96
  };
60
- return _jsx(Paper, { variant: "outlined", sx: { p: 1 }, "aria-label": t('MessagingPoll.LABEL'), children: _jsxs(Stack, { spacing: 0.5, children: [_jsx(Typography, { variant: "subtitle2", children: poll.question }), (poll.options || []).map((option) => {
61
- var _a, _b, _c, _d, _e, _f, _g;
62
- // dcm's poll projection gives voter user ids only, never names — there
63
- // is no user-directory endpoint (same structural limit already
64
- // established for the DM candidate list). A count is always correct;
65
- // marking the viewer's own vote is derivable from voted_option_ids.
66
- const votedByMe = Boolean((_a = poll.voted_option_ids) === null || _a === void 0 ? void 0 : _a.includes(option.id));
67
- const voterCount = (_e = (_d = (_c = (_b = option.voters) === null || _b === void 0 ? void 0 : _b.length) !== null && _c !== void 0 ? _c : option.vote_count) !== null && _d !== void 0 ? _d : option.votes_count) !== null && _e !== void 0 ? _e : 0;
68
- return _jsx(FormControlLabel, { disabled: closed || busy, control: _jsx(Checkbox, { checked: selected.includes(option.id), onChange: () => changeSelection(option.id) }), label: `${option.text || option.label} (${(_g = (_f = option.vote_count) !== null && _f !== void 0 ? _f : voterCount) !== null && _g !== void 0 ? _g : 0})${votedByMe ? ` — ${t('MessagingPoll.YOU_VOTED')}` : ''}` }, option.id);
69
- }), error && _jsx(Alert, { severity: "error", role: "alert", children: error }), _jsxs(Stack, { direction: "row", spacing: 1, children: [_jsx(Button, { size: "small", onClick: vote, disabled: closed || busy, children: t('MessagingPoll.VOTE') }), canClose && !closed && _jsx(Button, { size: "small", color: "warning", onClick: close, disabled: busy, children: t('MessagingPoll.CLOSE') }), closed && _jsx(Typography, { variant: "caption", children: t('MessagingPoll.CLOSED') })] })] }) });
97
+ const optionLabel = (option) => {
98
+ var _a, _b, _c;
99
+ // dcm's poll projection gives voter user ids only, never names — there
100
+ // is no user-directory endpoint (same structural limit already
101
+ // established for the DM candidate list).
102
+ const voteCount = (_c = (_a = option.vote_count) !== null && _a !== void 0 ? _a : (_b = option.voters) === null || _b === void 0 ? void 0 : _b.length) !== null && _c !== void 0 ? _c : 0;
103
+ const percent = totalVotes > 0 ? Math.round((voteCount / totalVotes) * 100) : 0;
104
+ const votedByMe = selected.includes(option.id);
105
+ const resultText = t('MessagingPoll.OPTION_RESULT', { count: voteCount, percent });
106
+ // The bar is decorative; count/percent live in the accessible name (via
107
+ // aria-label on the control itself) as well as in adjacent visible text —
108
+ // width alone must never be the only signal (WO risk note).
109
+ const accessibleLabel = `${option.text || option.label}, ${resultText}${votedByMe ? `, ${t('MessagingPoll.YOU_VOTED')}` : ''}`;
110
+ const richLabel = _jsxs(Stack, { sx: { flex: 1, minWidth: 0, opacity: pending.has(option.id) ? 0.6 : 1 }, spacing: 0.25, children: [_jsxs(Stack, { direction: "row", justifyContent: "space-between", children: [_jsx(Typography, { variant: "body2", noWrap: true, children: option.text || option.label }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: resultText })] }), _jsx(LinearProgress, { variant: "determinate", value: percent, sx: { borderRadius: 1, height: 6 }, color: votedByMe ? 'primary' : 'inherit' })] });
111
+ return { accessibleLabel, richLabel };
112
+ };
113
+ const formControlSx = { width: '100%', m: 0, alignItems: 'flex-start' };
114
+ return _jsx(Paper, { variant: "outlined", sx: { p: 1 }, "aria-label": t('MessagingPoll.LABEL'), children: _jsxs(Stack, { spacing: 0.75, children: [_jsx(Typography, { variant: "subtitle2", children: poll.question }), poll.allow_multiple
115
+ ? (poll.options || []).map((option) => {
116
+ const { accessibleLabel, richLabel } = optionLabel(option);
117
+ return _jsx(FormControlLabel, { disabled: closed, sx: formControlSx, label: richLabel, control: _jsx(Checkbox, { checked: selected.includes(option.id), inputProps: { 'aria-label': accessibleLabel }, onChange: () => tap(option.id), sx: { pt: 0.25 } }) }, option.id);
118
+ })
119
+ // No RadioGroup-level onChange: it would only ever hand back
120
+ // `event.target.value`, a DOM string -- coercing a non-string option id
121
+ // (dcm's are UUID strings today, but the id type isn't a guaranteed
122
+ // contract). `value` alone still drives each Radio's `checked` via the
123
+ // group's own comparison; each Radio's own `onChange` below fires
124
+ // `tap` with the option's real, correctly-typed id, matching the
125
+ // Checkbox branch above exactly.
126
+ : _jsx(RadioGroup, { value: (_h = selected[0]) !== null && _h !== void 0 ? _h : '', name: `poll-${poll.id}-options`, children: (poll.options || []).map((option) => {
127
+ const { accessibleLabel, richLabel } = optionLabel(option);
128
+ return _jsx(FormControlLabel, { value: option.id, disabled: closed, sx: formControlSx, label: richLabel, control: _jsx(Radio, { inputProps: { 'aria-label': accessibleLabel }, onChange: () => tap(option.id), sx: { pt: 0.25 } }) }, option.id);
129
+ }) }), error && _jsx(Alert, { severity: "error", role: "alert", children: error }), _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [canClose && !closed && _jsx(Button, { size: "small", color: "warning", onClick: close, disabled: busy, children: t('MessagingPoll.CLOSE') }), closed && _jsx(Chip, { size: "small", icon: _jsx(LockOutlinedIcon, { fontSize: "small" }), label: t('MessagingPoll.CLOSED') })] })] }) });
70
130
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.22.0",
3
+ "version": "2.23.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "repository": {
@@ -76,10 +76,9 @@ export const messagingTranslations = {
76
76
  'MessagingReactions.TOGGLE_other': { de: '{{emoji}} Reaktion mit {{count}} Stimmen umschalten', en: 'Toggle {{emoji}} reaction with {{count}} votes', fr: 'Basculer la réaction {{emoji}} avec {{count}} votes' },
77
77
  'MessagingReactions.ERROR': { de: 'Reaktion konnte nicht gespeichert werden: {{message}}', en: 'Reaction could not be saved: {{message}}', fr: 'Impossible d’enregistrer la réaction : {{message}}' },
78
78
  'MessagingPoll.LABEL': { de: 'Umfrage', en: 'Poll', fr: 'Sondage' },
79
- 'MessagingPoll.VOTE': { de: 'Abstimmen', en: 'Vote', fr: 'Voter' },
80
79
  'MessagingPoll.CLOSE': { de: 'Umfrage schliessen', en: 'Close poll', fr: 'Clore le sondage' },
81
80
  'MessagingPoll.CLOSED': { de: 'Geschlossen', en: 'Closed', fr: 'Clos' },
82
- 'MessagingPoll.SELECT_OPTION': { de: 'Wähle mindestens eine Option.', en: 'Select at least one option.', fr: 'Sélectionnez au moins une option.' },
81
+ 'MessagingPoll.OPTION_RESULT': { de: '{{count}} ({{percent}} %)', en: '{{count}} ({{percent}}%)', fr: '{{count}} ({{percent}} %)' },
83
82
  'MessagingPoll.VOTE_ERROR': { de: 'Stimme konnte nicht gespeichert werden: {{message}}', en: 'Vote could not be saved: {{message}}', fr: 'Impossible d’enregistrer le vote : {{message}}' },
84
83
  'MessagingPoll.CLOSE_ERROR': { de: 'Umfrage konnte nicht geschlossen werden: {{message}}', en: 'Poll could not be closed: {{message}}', fr: 'Impossible de clore le sondage : {{message}}' },
85
84
  'MessagingPoll.CREATE': { de: 'Umfrage erstellen', en: 'Create poll', fr: 'Créer un sondage' },
@@ -47,6 +47,30 @@ function mergeById(slice, item) { const id = idOf(item); return id == null ? sli
47
47
  function resultsOf(response) { return response?.results || []; }
48
48
  function clientRequestIdOf(message) { return message?.client_request_id; }
49
49
 
50
+ // Optimistic vote-count/voters projection for MSG-6g tap-to-vote -- only options whose
51
+ // membership in the vote actually changed get their count/voters adjusted, so an
52
+ // unrelated option's tally is never nudged by someone else's concurrent vote landing in
53
+ // cache between taps.
54
+ function applyOptimisticVote(poll, previousSelectedIds, nextSelectedIds, userId) {
55
+ const previousSet = new Set(previousSelectedIds || []);
56
+ const nextSet = new Set(nextSelectedIds || []);
57
+ const options = (poll.options || []).map((option) => {
58
+ const wasSelected = previousSet.has(option.id);
59
+ const isSelected = nextSet.has(option.id);
60
+ if (wasSelected === isSelected) return option;
61
+ const delta = isSelected ? 1 : -1;
62
+ // userId can be undefined for a brief window if AuthContext hasn't
63
+ // populated `user` yet -- never push a literal `undefined` into the
64
+ // voters array (the reconcile/revert below overwrites it regardless,
65
+ // but the optimistic window must not carry a bogus entry).
66
+ const voters = Array.isArray(option.voters) && userId != null
67
+ ? (isSelected ? [...option.voters, userId] : option.voters.filter((voter) => voter !== userId))
68
+ : option.voters;
69
+ return { ...option, vote_count: Math.max(0, (option.vote_count ?? option.voters?.length ?? 0) + delta), voters };
70
+ });
71
+ return { ...poll, options, voted_option_ids: nextSelectedIds };
72
+ }
73
+
50
74
  /**
51
75
  * dcm's DRF validation errors aren't always {detail}: ConversationAttachmentView
52
76
  * translates a rejected upload into a field-keyed {"files": [...]} body (the
@@ -245,6 +269,10 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
245
269
  const activeIdRef = useRef(activeConversationId);
246
270
  const seenEventsRef = useRef(new Set());
247
271
  const filtersRef = useRef(filters);
272
+ // Per-poll "latest tap wins" token: rapid successive taps race, and a stale
273
+ // response (revert on failure, or reconcile on success) must not clobber a
274
+ // newer optimistic state that has already superseded it.
275
+ const pollVoteTokensRef = useRef({});
248
276
  activeIdRef.current = activeConversationId;
249
277
  filtersRef.current = filters;
250
278
 
@@ -421,11 +449,38 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
421
449
  return poll;
422
450
  }, [api, patchMessage]);
423
451
  const castPollVote = useCallback(async (messageId, poll, optionIds) => {
424
- const result = await api.votePoll(poll.id, optionIds);
425
- const nextPoll = { ...poll, ...(result?.poll || result) };
426
- patchMessage({ ...(cache.messages[messageId] || { id: messageId }), poll: nextPoll });
427
- return result;
428
- }, [api, cache.messages, patchMessage]);
452
+ // Optimistic tap-to-vote (MSG-6g), following the same immediate-patch /
453
+ // reconcile-or-revert shape `toggleReaction` already uses. The previous poll
454
+ // (pre-tap) is captured up front so a failure can revert to it exactly, not to
455
+ // whatever `cache.messages` happens to hold when the error arrives.
456
+ const message = cache.messages[messageId] || { id: messageId };
457
+ const previousPoll = message.poll || poll;
458
+ // Project from `previousPoll` (the freshest known state in the shared cache),
459
+ // never from the caller-supplied `poll` param -- `PollCard` is a "dumb",
460
+ // prop-driven component that may still be holding the pre-first-tap `poll`
461
+ // it was originally passed when a second rapid tap fires, and projecting
462
+ // counts from that stale baseline would compute a wrong transient
463
+ // vote_count/voters in the SHARED cache (visible to every consumer, not
464
+ // just this component's own render).
465
+ const optimisticPoll = applyOptimisticVote(previousPoll, previousPoll.voted_option_ids, optionIds, user?.id);
466
+ const token = {};
467
+ pollVoteTokensRef.current[poll.id] = token;
468
+ patchMessage({ ...message, poll: optimisticPoll });
469
+ try {
470
+ const result = await api.votePoll(poll.id, optionIds);
471
+ // A newer tap already started (and will reconcile/revert for itself) --
472
+ // applying this now-stale response would stomp on it.
473
+ if (pollVoteTokensRef.current[poll.id] !== token) return result;
474
+ const nextPoll = { ...poll, ...(result?.poll || result) };
475
+ patchMessage({ ...(cache.messages[messageId] || { id: messageId }), poll: nextPoll });
476
+ return result;
477
+ } catch (error) {
478
+ if (pollVoteTokensRef.current[poll.id] === token) {
479
+ patchMessage({ ...(cache.messages[messageId] || { id: messageId }), poll: previousPoll });
480
+ }
481
+ throw error;
482
+ }
483
+ }, [api, cache.messages, patchMessage, user]);
429
484
  const closeConversationPoll = useCallback(async (messageId, poll) => {
430
485
  const result = await api.closePoll(poll.id);
431
486
  const nextPoll = { ...poll, ...(result?.poll || result), closed_at: result?.poll?.closed_at || result?.closed_at || new Date().toISOString() };
@@ -1,10 +1,26 @@
1
- import { Alert, Button, Checkbox, FormControlLabel, Paper, Stack, Typography } from '@mui/material';
2
- import { useEffect, useState } from 'react';
1
+ import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
2
+ import { Alert, Button, Checkbox, Chip, FormControlLabel, LinearProgress, Paper, Radio, RadioGroup, Stack, Typography } from '@mui/material';
3
+ import { useEffect, useRef, useState } from 'react';
3
4
  import { useTranslation } from 'react-i18next';
4
5
 
5
6
  import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
6
7
 
7
- /** Independently mountable live poll view. Server authorization remains authoritative. */
8
+ /**
9
+ * Independently mountable live poll view. Server authorization remains authoritative.
10
+ *
11
+ * Tap-to-vote (MSG-6g): each option is its own control, cast immediately on tap — no
12
+ * separate submit step. Single-choice uses a real `RadioGroup` (native "tap a different
13
+ * option moves the vote, tap the same one is a no-op" semantics, plus the group's own
14
+ * arrow-key navigation and screen-reader group announcement — a bare ungrouped `Radio`
15
+ * per option, tried first, loses both); multi-select uses `Checkbox` (each tap
16
+ * independently toggles that option). `FormControlLabel` wraps each control, matching
17
+ * this repo's existing radio convention (`AuthFactorRequirementCard.jsx`) — clicking
18
+ * anywhere in the label natively activates the control, no manual click-handling needed.
19
+ * Every tap is optimistic (see `castPollVote` in `MessagingProvider`), dims while
20
+ * in flight (matching `ReactionBar`'s `pending` convention) so an optimistic vote is
21
+ * never visually indistinguishable from a confirmed one, and visibly reverts on a
22
+ * rejected vote.
23
+ */
8
24
  export function PollCard({ message, canClose: canCloseProp }) {
9
25
  const { t } = useTranslation();
10
26
  const { castPollVote, closeConversationPoll, currentUser } = useMessaging();
@@ -12,6 +28,10 @@ export function PollCard({ message, canClose: canCloseProp }) {
12
28
  const [selected, setSelected] = useState([]);
13
29
  const [error, setError] = useState(null);
14
30
  const [busy, setBusy] = useState(false);
31
+ const [pending, setPending] = useState(() => new Set());
32
+ // Guards a rejected vote's revert against a newer tap that has since
33
+ // superseded it -- see the matching token in MessagingProvider.castPollVote.
34
+ const voteTokenRef = useRef(null);
15
35
  // voted_option_ids is a view-level enrichment dcm adds ONLY to the
16
36
  // create/vote/close poll REST responses (serialize_poll itself, every
17
37
  // message-list/thread load, and every poll_updated realtime frame never
@@ -25,28 +45,92 @@ export function PollCard({ message, canClose: canCloseProp }) {
25
45
  const closed = Boolean(poll.closed_at);
26
46
  const creatorId = poll.created_by_id ?? poll.created_by?.id ?? poll.created_by;
27
47
  const canClose = canCloseProp ?? poll.can_close ?? message?.can_close ?? message?.permissions?.can_close ?? (creatorId != null && creatorId === currentUser?.id);
28
- const changeSelection = (id) => setSelected((current) => poll.allow_multiple ? (current.includes(id) ? current.filter((value) => value !== id) : [...current, id]) : (current.includes(id) ? [] : [id]));
29
- const vote = async () => {
30
- if (!selected.length) { setError(t('MessagingPoll.SELECT_OPTION')); return; }
31
- setBusy(true); setError(null);
32
- try { await castPollVote(message.id, poll, selected); } catch (voteError) { setError(t('MessagingPoll.VOTE_ERROR', { message: extractApiErrorMessage(voteError) })); } finally { setBusy(false); }
48
+ const totalVotes = (poll.options || []).reduce((sum, option) => sum + (option.vote_count ?? option.voters?.length ?? 0), 0);
49
+
50
+ const tap = async (id) => {
51
+ if (closed) return;
52
+ const previousSelected = selected;
53
+ const nextSelected = poll.allow_multiple
54
+ ? (selected.includes(id) ? selected.filter((value) => value !== id) : [...selected, id])
55
+ : [id];
56
+ // Belt-and-suspenders: native radios already don't fire a change event
57
+ // when re-clicking the checked one, but this guards any caller shape.
58
+ if (nextSelected.length === previousSelected.length && nextSelected.every((value) => previousSelected.includes(value))) return;
59
+ const token = {};
60
+ voteTokenRef.current = token;
61
+ setSelected(nextSelected);
62
+ setError(null);
63
+ // Rapid re-taps are allowed and race deliberately (last write wins via
64
+ // the token above) -- `pending` is a visual signal only, never gates
65
+ // further taps, so it must not disable the control.
66
+ setPending((current) => new Set(current).add(id));
67
+ try {
68
+ await castPollVote(message.id, poll, nextSelected);
69
+ } catch (voteError) {
70
+ // A later tap already moved the selection on; a stale failure must not
71
+ // revert past it.
72
+ if (voteTokenRef.current === token) {
73
+ setSelected(previousSelected);
74
+ setError(t('MessagingPoll.VOTE_ERROR', { message: extractApiErrorMessage(voteError) }));
75
+ }
76
+ } finally {
77
+ setPending((current) => { const next = new Set(current); next.delete(id); return next; });
78
+ }
33
79
  };
34
80
  const close = async () => {
35
81
  setBusy(true); setError(null);
36
82
  try { await closeConversationPoll(message.id, poll); } catch (closeError) { setError(t('MessagingPoll.CLOSE_ERROR', { message: extractApiErrorMessage(closeError) })); } finally { setBusy(false); }
37
83
  };
38
- return <Paper variant="outlined" sx={{ p: 1 }} aria-label={t('MessagingPoll.LABEL')}><Stack spacing={0.5}>
84
+
85
+ const optionLabel = (option) => {
86
+ // dcm's poll projection gives voter user ids only, never names — there
87
+ // is no user-directory endpoint (same structural limit already
88
+ // established for the DM candidate list).
89
+ const voteCount = option.vote_count ?? option.voters?.length ?? 0;
90
+ const percent = totalVotes > 0 ? Math.round((voteCount / totalVotes) * 100) : 0;
91
+ const votedByMe = selected.includes(option.id);
92
+ const resultText = t('MessagingPoll.OPTION_RESULT', { count: voteCount, percent });
93
+ // The bar is decorative; count/percent live in the accessible name (via
94
+ // aria-label on the control itself) as well as in adjacent visible text —
95
+ // width alone must never be the only signal (WO risk note).
96
+ const accessibleLabel = `${option.text || option.label}, ${resultText}${votedByMe ? `, ${t('MessagingPoll.YOU_VOTED')}` : ''}`;
97
+ const richLabel = <Stack sx={{ flex: 1, minWidth: 0, opacity: pending.has(option.id) ? 0.6 : 1 }} spacing={0.25}>
98
+ <Stack direction="row" justifyContent="space-between">
99
+ <Typography variant="body2" noWrap>{option.text || option.label}</Typography>
100
+ <Typography variant="caption" color="text.secondary">{resultText}</Typography>
101
+ </Stack>
102
+ <LinearProgress variant="determinate" value={percent} sx={{ borderRadius: 1, height: 6 }} color={votedByMe ? 'primary' : 'inherit'} />
103
+ </Stack>;
104
+ return { accessibleLabel, richLabel };
105
+ };
106
+ const formControlSx = { width: '100%', m: 0, alignItems: 'flex-start' };
107
+
108
+ return <Paper variant="outlined" sx={{ p: 1 }} aria-label={t('MessagingPoll.LABEL')}><Stack spacing={0.75}>
39
109
  <Typography variant="subtitle2">{poll.question}</Typography>
40
- {(poll.options || []).map((option) => {
41
- // dcm's poll projection gives voter user ids only, never names — there
42
- // is no user-directory endpoint (same structural limit already
43
- // established for the DM candidate list). A count is always correct;
44
- // marking the viewer's own vote is derivable from voted_option_ids.
45
- const votedByMe = Boolean(poll.voted_option_ids?.includes(option.id));
46
- const voterCount = option.voters?.length ?? option.vote_count ?? option.votes_count ?? 0;
47
- return <FormControlLabel key={option.id} disabled={closed || busy} control={<Checkbox checked={selected.includes(option.id)} onChange={() => changeSelection(option.id)} />} label={`${option.text || option.label} (${option.vote_count ?? voterCount ?? 0})${votedByMe ? ` — ${t('MessagingPoll.YOU_VOTED')}` : ''}`} />;
48
- })}
110
+ {poll.allow_multiple
111
+ ? (poll.options || []).map((option) => {
112
+ const { accessibleLabel, richLabel } = optionLabel(option);
113
+ return <FormControlLabel key={option.id} disabled={closed} sx={formControlSx} label={richLabel}
114
+ control={<Checkbox checked={selected.includes(option.id)} inputProps={{ 'aria-label': accessibleLabel }} onChange={() => tap(option.id)} sx={{ pt: 0.25 }} />} />;
115
+ })
116
+ // No RadioGroup-level onChange: it would only ever hand back
117
+ // `event.target.value`, a DOM string -- coercing a non-string option id
118
+ // (dcm's are UUID strings today, but the id type isn't a guaranteed
119
+ // contract). `value` alone still drives each Radio's `checked` via the
120
+ // group's own comparison; each Radio's own `onChange` below fires
121
+ // `tap` with the option's real, correctly-typed id, matching the
122
+ // Checkbox branch above exactly.
123
+ : <RadioGroup value={selected[0] ?? ''} name={`poll-${poll.id}-options`}>
124
+ {(poll.options || []).map((option) => {
125
+ const { accessibleLabel, richLabel } = optionLabel(option);
126
+ return <FormControlLabel key={option.id} value={option.id} disabled={closed} sx={formControlSx} label={richLabel}
127
+ control={<Radio inputProps={{ 'aria-label': accessibleLabel }} onChange={() => tap(option.id)} sx={{ pt: 0.25 }} />} />;
128
+ })}
129
+ </RadioGroup>}
49
130
  {error && <Alert severity="error" role="alert">{error}</Alert>}
50
- <Stack direction="row" spacing={1}><Button size="small" onClick={vote} disabled={closed || busy}>{t('MessagingPoll.VOTE')}</Button>{canClose && !closed && <Button size="small" color="warning" onClick={close} disabled={busy}>{t('MessagingPoll.CLOSE')}</Button>}{closed && <Typography variant="caption">{t('MessagingPoll.CLOSED')}</Typography>}</Stack>
131
+ <Stack direction="row" spacing={1} alignItems="center">
132
+ {canClose && !closed && <Button size="small" color="warning" onClick={close} disabled={busy}>{t('MessagingPoll.CLOSE')}</Button>}
133
+ {closed && <Chip size="small" icon={<LockOutlinedIcon fontSize="small" />} label={t('MessagingPoll.CLOSED')} />}
134
+ </Stack>
51
135
  </Stack></Paper>;
52
136
  }
@@ -28,12 +28,19 @@ describe('messaging reactions and polls', () => {
28
28
  fireEvent.click(screen.getByRole('button', { name: 'MessagingReactions.TOGGLE' })); await waitFor(() => expect(api.removeReaction).toHaveBeenCalledWith(7, '👍'));
29
29
  });
30
30
 
31
- it('uses single selection for a single poll and preserves selections for a multi poll', async () => {
31
+ it('taps vote immediately (no submit step): single-choice uses radios and only ever sends the last-tapped option, multi-select uses checkboxes and accumulates', async () => {
32
+ // MSG-6g: tap-to-vote, no separate "Abstimmen" button.
32
33
  const single = baseApi(); single.votePoll.mockResolvedValue({ poll: { id: 1, options: [] } });
33
34
  const { unmount } = render(<MessagingProvider api={single} active={false}><PollCard message={{ id: 7, poll: { id: 1, question: 'One', options: [{ id: 1, text: 'A' }, { id: 2, text: 'B' }], allow_multiple: false }}} /></MessagingProvider>);
34
- const choices = screen.getAllByRole('checkbox'); fireEvent.click(choices[0]); fireEvent.click(choices[1]); fireEvent.click(screen.getByRole('button', { name: 'MessagingPoll.VOTE' })); await waitFor(() => expect(single.votePoll).toHaveBeenCalledWith(1, [2])); unmount();
35
+ const radios = screen.getAllByRole('radio');
36
+ fireEvent.click(radios[0]); await waitFor(() => expect(single.votePoll).toHaveBeenCalledWith(1, [1]));
37
+ fireEvent.click(radios[1]); await waitFor(() => expect(single.votePoll).toHaveBeenCalledWith(1, [2]));
38
+ expect(single.votePoll).toHaveBeenCalledTimes(2);
39
+ unmount();
35
40
  const multiple = baseApi(); multiple.votePoll.mockResolvedValue({ poll: { id: 2, options: [] } }); render(<MessagingProvider api={multiple} active={false}><PollCard message={{ id: 8, poll: { id: 2, question: 'Many', options: [{ id: 1, text: 'A' }, { id: 2, text: 'B' }], allow_multiple: true }}} /></MessagingProvider>);
36
- const multiChoices = screen.getAllByRole('checkbox'); fireEvent.click(multiChoices[0]); fireEvent.click(multiChoices[1]); fireEvent.click(screen.getByRole('button', { name: 'MessagingPoll.VOTE' })); await waitFor(() => expect(multiple.votePoll).toHaveBeenCalledWith(2, [1, 2]));
41
+ const multiChoices = screen.getAllByRole('checkbox');
42
+ fireEvent.click(multiChoices[0]); await waitFor(() => expect(multiple.votePoll).toHaveBeenCalledWith(2, [1]));
43
+ fireEvent.click(multiChoices[1]); await waitFor(() => expect(multiple.votePoll).toHaveBeenCalledWith(2, [1, 2]));
37
44
  });
38
45
 
39
46
  it('only exposes close to the creator/capability and surfaces a forbidden close', async () => {
@@ -0,0 +1,166 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
5
+
6
+ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options && Object.keys(options).length ? `${key}:${JSON.stringify(options)}` : key }) }));
7
+
8
+ const realtimeSubscribers = new Map();
9
+ vi.mock('../src/notifications/realtime', () => ({
10
+ useRealtime: () => ({
11
+ subscribe: (envelope, handler) => { realtimeSubscribers.set(envelope, handler); return () => realtimeSubscribers.delete(envelope); },
12
+ onReconnect: () => () => {},
13
+ }),
14
+ }));
15
+ function dispatchMessagingFrame(frame) { realtimeSubscribers.get('messaging')?.(frame); }
16
+
17
+ import { PollCard } from '../src/messaging/PollCard';
18
+ import { MessagingProvider, useMessaging } from '../src/messaging/MessagingProvider';
19
+
20
+ function baseApi(overrides = {}) {
21
+ return {
22
+ listConversations: vi.fn().mockResolvedValue({ results: [] }), listMessages: vi.fn().mockResolvedValue({ results: [] }),
23
+ getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
24
+ votePoll: vi.fn(), closePoll: vi.fn(),
25
+ ...overrides,
26
+ };
27
+ }
28
+ function serverPoll(overrides = {}) {
29
+ return {
30
+ id: 'poll-1', question: 'Q', allow_multiple: false, closed_at: null, created_by_id: 'user-9',
31
+ options: [
32
+ { id: 'opt-a', text: 'A', order: 0, vote_count: 0, voters: [] },
33
+ { id: 'opt-b', text: 'B', order: 1, vote_count: 0, voters: [] },
34
+ ],
35
+ ...overrides,
36
+ };
37
+ }
38
+ function ConnectedPollCard({ messageId }) {
39
+ const { cache } = useMessaging();
40
+ return <PollCard message={cache.messages[messageId]} />;
41
+ }
42
+ function CachePollState({ messageId }) {
43
+ const { cache } = useMessaging();
44
+ return <output data-testid="cache-poll">{JSON.stringify(cache.messages[messageId]?.poll)}</output>;
45
+ }
46
+
47
+ afterEach(() => { cleanup(); realtimeSubscribers.clear(); });
48
+
49
+ describe('MSG-6g: tap-to-vote poll UI with result bars', () => {
50
+ it('1. single-choice: tapping an option votes without further interaction; tapping a second option moves the vote', async () => {
51
+ const api = baseApi();
52
+ api.votePoll.mockResolvedValueOnce({ poll: { ...serverPoll(), voted_option_ids: ['opt-a'], options: [{ id: 'opt-a', text: 'A', order: 0, vote_count: 1, voters: ['me'] }, { id: 'opt-b', text: 'B', order: 1, vote_count: 0, voters: [] }] } });
53
+ api.votePoll.mockResolvedValueOnce({ poll: { ...serverPoll(), voted_option_ids: ['opt-b'], options: [{ id: 'opt-a', text: 'A', order: 0, vote_count: 0, voters: [] }, { id: 'opt-b', text: 'B', order: 1, vote_count: 1, voters: ['me'] }] } });
54
+ render(<MessagingProvider api={api} active={false}><PollCard message={{ id: 'msg-1', poll: serverPoll() }} /></MessagingProvider>);
55
+ const radios = screen.getAllByRole('radio');
56
+
57
+ fireEvent.click(radios[0]);
58
+ await waitFor(() => expect(api.votePoll).toHaveBeenCalledWith('poll-1', ['opt-a']));
59
+ expect(radios[0].checked).toBe(true);
60
+
61
+ fireEvent.click(radios[1]);
62
+ await waitFor(() => expect(api.votePoll).toHaveBeenCalledWith('poll-1', ['opt-b']));
63
+ await waitFor(() => expect(radios[1].checked).toBe(true));
64
+ expect(radios[0].checked).toBe(false);
65
+ });
66
+
67
+ it('2. multi-select: tapping toggles one option on and off, and each tap sends the complete option-id set', async () => {
68
+ const api = baseApi();
69
+ api.votePoll.mockResolvedValue({ poll: serverPoll({ allow_multiple: true }) });
70
+ render(<MessagingProvider api={api} active={false}><PollCard message={{ id: 'msg-1', poll: serverPoll({ allow_multiple: true }) }} /></MessagingProvider>);
71
+ const checkboxes = screen.getAllByRole('checkbox');
72
+
73
+ fireEvent.click(checkboxes[0]);
74
+ await waitFor(() => expect(api.votePoll).toHaveBeenCalledWith('poll-1', ['opt-a']));
75
+ fireEvent.click(checkboxes[1]);
76
+ await waitFor(() => expect(api.votePoll).toHaveBeenCalledWith('poll-1', ['opt-a', 'opt-b']));
77
+ fireEvent.click(checkboxes[0]);
78
+ await waitFor(() => expect(api.votePoll).toHaveBeenCalledWith('poll-1', ['opt-b']));
79
+ expect(api.votePoll).toHaveBeenCalledTimes(3);
80
+ });
81
+
82
+ it('3. a rejected vote reverts the optimistic state and surfaces an error -- the reverted value, not merely an error message', async () => {
83
+ const api = baseApi();
84
+ api.votePoll.mockRejectedValue({ response: { data: { detail: 'Voting disabled' } } });
85
+ render(<MessagingProvider api={api} active={false}><PollCard message={{ id: 'msg-1', poll: serverPoll() }} /></MessagingProvider>);
86
+ const radios = screen.getAllByRole('radio');
87
+
88
+ fireEvent.click(radios[0]);
89
+ // Optimistic: briefly checked before the rejection resolves.
90
+ await waitFor(() => expect(radios[0].checked).toBe(true));
91
+ await screen.findByRole('alert');
92
+ await waitFor(() => expect(radios[0].checked).toBe(false));
93
+ expect(radios[1].checked).toBe(false);
94
+ });
95
+
96
+ it('4. bars render proportionally, including the zero-vote case, with no NaN and no division error', () => {
97
+ render(<MessagingProvider api={baseApi()} active={false}><PollCard message={{ id: 'msg-1', poll: serverPoll() }} /></MessagingProvider>);
98
+ // vote_count 0/0 for both options -> 0%, never NaN%.
99
+ const results = screen.getAllByText(/MessagingPoll\.OPTION_RESULT/);
100
+ expect(results).toHaveLength(2);
101
+ results.forEach((node) => {
102
+ expect(node.textContent).toMatch(/"count":0/);
103
+ expect(node.textContent).toMatch(/"percent":0/);
104
+ expect(node.textContent).not.toMatch(/NaN/);
105
+ });
106
+ });
107
+
108
+ it('5. the viewer\'s own selection survives a poll_updated frame that does not carry voted_option_ids (merge-trap guard)', async () => {
109
+ const api = baseApi();
110
+ render(<MessagingProvider api={api} active={false}><ConnectedPollCard messageId="msg-1" /></MessagingProvider>);
111
+ dispatchMessagingFrame({ envelope: 'messaging', type: 'message', event_id: 'seed', conversation_id: 'conv-1', message: { id: 'msg-1', conversation_id: 'conv-1', kind: 'poll', poll: serverPoll({ voted_option_ids: ['opt-a'], options: [{ id: 'opt-a', text: 'A', order: 0, vote_count: 1, voters: ['me'] }, { id: 'opt-b', text: 'B', order: 1, vote_count: 0, voters: [] }] }) } });
112
+ await waitFor(() => expect(screen.getAllByRole('radio')[0].checked).toBe(true));
113
+
114
+ // A poll_updated frame never carries voted_option_ids (dcm's serialize_poll
115
+ // is viewer-independent) -- must not wipe the REST-known selection.
116
+ dispatchMessagingFrame({
117
+ envelope: 'messaging', type: 'poll_updated', event_id: 'evt-1', conversation_id: 'conv-1', message_id: 'msg-1', poll_id: 'poll-1',
118
+ poll: serverPoll({ options: [{ id: 'opt-a', text: 'A', order: 0, vote_count: 2, voters: ['me', 'other'] }, { id: 'opt-b', text: 'B', order: 1, vote_count: 0, voters: [] }] }),
119
+ });
120
+ await waitFor(() => expect(screen.getByText(/MessagingPoll\.OPTION_RESULT.*"count":2/)).toBeTruthy());
121
+ expect(screen.getAllByRole('radio')[0].checked).toBe(true);
122
+ });
123
+
124
+ it('6. a closed poll is non-interactive: tapping an option does nothing and issues no request', () => {
125
+ const api = baseApi();
126
+ render(<MessagingProvider api={api} active={false}><PollCard message={{ id: 'msg-1', poll: serverPoll({ closed_at: '2026-08-01T00:00:00Z' }) }} /></MessagingProvider>);
127
+ const radios = screen.getAllByRole('radio');
128
+ fireEvent.click(radios[0]);
129
+ expect(api.votePoll).not.toHaveBeenCalled();
130
+ expect(radios[0].checked).toBe(false);
131
+ });
132
+
133
+ it('a rapid switch (A then B, before the first vote confirms) projects counts from the freshest cache state, not a stale prop (regression)', async () => {
134
+ // MessagingProvider.castPollVote must build its optimistic count delta from
135
+ // `previousPoll` (freshly read from the shared cache at call time), never
136
+ // from the caller-supplied `poll` argument. PollCard is deliberately given
137
+ // a STATIC message prop here (never re-rendered with a fresh one between
138
+ // taps, exactly like PollCard's own documented "dumb, prop-driven"
139
+ // contract, and exactly how a host that doesn't re-connect it to live
140
+ // cache updates would behave) -- a separate, genuinely cache-connected
141
+ // reader (CachePollState) is what must show the correct number. Starting
142
+ // counts are non-zero so a wrong baseline isn't masked by the
143
+ // Math.max(0, ...) clamp.
144
+ const api = baseApi();
145
+ api.votePoll.mockReturnValue(new Promise(() => {})); // never resolves -- isolate the optimistic phase
146
+ const seedPoll = serverPoll({
147
+ voted_option_ids: [],
148
+ options: [
149
+ { id: 'opt-a', text: 'A', order: 0, vote_count: 5, voters: ['u1', 'u2', 'u3', 'u4', 'u5'] },
150
+ { id: 'opt-b', text: 'B', order: 1, vote_count: 2, voters: ['u6', 'u7'] },
151
+ ],
152
+ });
153
+ render(<MessagingProvider api={api} active={false}><PollCard message={{ id: 'msg-1', poll: seedPoll }} /><CachePollState messageId="msg-1" /></MessagingProvider>);
154
+ const radios = screen.getAllByRole('radio');
155
+
156
+ fireEvent.click(radios[0]); // vote A: 5 -> 6
157
+ await waitFor(() => expect(JSON.parse(screen.getByTestId('cache-poll').textContent).options.find((option) => option.id === 'opt-a').vote_count).toBe(6));
158
+
159
+ fireEvent.click(radios[1]); // switch to B: A must go 6 -> 5 in the shared cache (a stale-prop baseline of 5 would wrongly compute 4)
160
+ await waitFor(() => {
161
+ const poll = JSON.parse(screen.getByTestId('cache-poll').textContent);
162
+ expect(poll.options.find((option) => option.id === 'opt-a').vote_count).toBe(5);
163
+ expect(poll.options.find((option) => option.id === 'opt-b').vote_count).toBe(3);
164
+ });
165
+ });
166
+ });
@@ -3,7 +3,7 @@ import React from 'react';
3
3
  import { afterEach, describe, expect, it, vi } from 'vitest';
4
4
  import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
5
5
 
6
- vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options?.message ? `${key}:${options.message}` : key }) }));
6
+ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options && Object.keys(options).length ? `${key}:${JSON.stringify(options)}` : key }) }));
7
7
 
8
8
  const realtimeSubscribers = new Map();
9
9
  vi.mock('../src/notifications/realtime', () => ({
@@ -55,20 +55,29 @@ function ConnectedPollCard({ messageId }) {
55
55
  afterEach(() => { cleanup(); realtimeSubscribers.clear(); });
56
56
 
57
57
  describe('poll rendering against the real server contract', () => {
58
- it('renders question, options, vote counts and per-option voters from a server-shaped payload', () => {
58
+ it('renders question, options and proportional vote counts from a server-shaped payload', () => {
59
+ // totalVotes = 2 + 1 = 3 -> Library 67%, Cafe 33%.
59
60
  render(<MessagingProvider api={baseApi()} active={false}><PollCard message={{ id: 'msg-1', poll: serverPoll() }} /></MessagingProvider>);
60
61
  expect(screen.getByText('Where should we meet?')).toBeTruthy();
61
- expect(screen.getByText(/Library \(2\)/)).toBeTruthy();
62
- expect(screen.getByText(/Cafe \(1\)/)).toBeTruthy();
62
+ expect(screen.getByText('Library')).toBeTruthy();
63
+ expect(screen.getByText(/MessagingPoll\.OPTION_RESULT.*"count":2.*"percent":67/)).toBeTruthy();
64
+ expect(screen.getByText('Cafe')).toBeTruthy();
65
+ expect(screen.getByText(/MessagingPoll\.OPTION_RESULT.*"count":1.*"percent":33/)).toBeTruthy();
63
66
  });
64
67
 
65
68
  it('derives selection from voted_option_ids (REST-only) and marks the viewer\'s own vote', () => {
69
+ // Single-choice (allow_multiple: false) renders radios, not checkboxes --
70
+ // native radio semantics match the WO's "tapping a different option moves
71
+ // the vote, tapping the same one is a no-op" design directly.
66
72
  const poll = serverPoll({ voted_option_ids: ['opt-a'] });
67
73
  render(<MessagingProvider api={baseApi()} active={false}><PollCard message={{ id: 'msg-1', poll }} /></MessagingProvider>);
68
- const checkboxes = screen.getAllByRole('checkbox');
69
- expect(checkboxes[0].checked).toBe(true);
70
- expect(checkboxes[1].checked).toBe(false);
71
- expect(screen.getByText(/Library.*MessagingPoll\.YOU_VOTED/)).toBeTruthy();
74
+ const radios = screen.getAllByRole('radio');
75
+ expect(radios[0].checked).toBe(true);
76
+ expect(radios[1].checked).toBe(false);
77
+ // Count/percent and the viewer's own vote both live in the accessible
78
+ // name, not conveyed by the bar's width alone (WO accessibility risk).
79
+ expect(radios[0].getAttribute('aria-label')).toMatch(/Library.*MessagingPoll\.OPTION_RESULT.*MessagingPoll\.YOU_VOTED/);
80
+ expect(radios[1].getAttribute('aria-label')).not.toMatch(/YOU_VOTED/);
72
81
  });
73
82
 
74
83
  it('does not clear or invert the viewer\'s own vote when a poll_updated frame carries no voted_option_ids', async () => {
@@ -80,7 +89,7 @@ describe('poll rendering against the real server contract', () => {
80
89
  // own castPollVote reconciliation would leave it — not a static prop the
81
90
  // component can never observe a later frame update against.
82
91
  dispatchMessagingFrame({ envelope: 'messaging', type: 'message', event_id: 'seed', conversation_id: 'conv-1', message: { id: 'msg-1', conversation_id: 'conv-1', kind: 'poll', poll: votedPoll } });
83
- await waitFor(() => expect(screen.getAllByRole('checkbox')[0].checked).toBe(true));
92
+ await waitFor(() => expect(screen.getAllByRole('radio')[0].checked).toBe(true));
84
93
  // The real poll_updated frame shape: payload.poll is a bare serialize_poll
85
94
  // result (verified against services.py's _poll_updated_payload) — never
86
95
  // carries voted_option_ids. A vote_count bump from another viewer must
@@ -89,10 +98,10 @@ describe('poll rendering against the real server contract', () => {
89
98
  envelope: 'messaging', type: 'poll_updated', event_id: 'evt-1', conversation_id: 'conv-1', message_id: 'msg-1', poll_id: 'poll-1',
90
99
  poll: { ...serverPoll(), options: [{ id: 'opt-a', text: 'Library', order: 0, vote_count: 3, voters: ['user-1', 'user-2', 'user-4'] }, { id: 'opt-b', text: 'Cafe', order: 1, vote_count: 1, voters: ['user-3'] }] },
91
100
  });
92
- await waitFor(() => expect(screen.getByText(/Library \(3\)/)).toBeTruthy());
101
+ await waitFor(() => expect(screen.getByText(/MessagingPoll\.OPTION_RESULT.*"count":3/)).toBeTruthy());
93
102
  // The frame carries no voted_option_ids at all — the viewer's REST-known
94
103
  // vote must be carried forward, not wiped, by this update.
95
- expect(screen.getAllByRole('checkbox')[0].checked).toBe(true);
104
+ expect(screen.getAllByRole('radio')[0].checked).toBe(true);
96
105
  });
97
106
 
98
107
  it('grants close only to the poll creator (created_by_id) or a host override, never a guess from an object shape', () => {
@@ -105,11 +114,12 @@ describe('poll rendering against the real server contract', () => {
105
114
  expect(screen.getByRole('button', { name: 'MessagingPoll.CLOSE' })).toBeTruthy();
106
115
  });
107
116
 
108
- it('disables voting once the poll is closed', () => {
117
+ it('disables voting once the poll is closed and shows a closed indicator, not merely a missing button', () => {
118
+ // With no separate submit button (MSG-6g), "closed" can no longer be
119
+ // inferred from its absence -- must be an explicit, visible indicator.
109
120
  const poll = serverPoll({ closed_at: '2026-07-31T10:00:00Z' });
110
121
  render(<MessagingProvider api={baseApi()} active={false}><PollCard message={{ id: 'msg-1', poll }} /></MessagingProvider>);
111
- screen.getAllByRole('checkbox').forEach((checkbox) => expect(checkbox.disabled).toBe(true));
112
- expect(screen.getByRole('button', { name: 'MessagingPoll.VOTE' }).disabled).toBe(true);
122
+ screen.getAllByRole('radio').forEach((radio) => expect(radio.disabled).toBe(true));
113
123
  expect(screen.getByText('MessagingPoll.CLOSED')).toBeTruthy();
114
124
  });
115
125
  });
@@ -0,0 +1,244 @@
1
+ # WORK ORDER MSG-6g (ui-core-micha) — poll UI: tap-to-vote with result bars, no separate vote button
2
+
3
+ **EXECUTION DIRECTIVE.** If you are the implementer reading this as your own spec, this section is not
4
+ addressed to you — it tells the Orchestrator how to invoke you; you ARE that invocation, do not shell
5
+ out to `codex exec`. Orchestrator: implement through `codex exec` in the background, invoked **directly
6
+ via Bash** (never the `debugger`/`*_coder` Agent wrappers) with **both** flags `--skip-git-repo-check`
7
+ and `--dangerously-bypass-approvals-and-sandbox`, prompt passed as a positional argument from this
8
+ file. Fall back to direct Claude implementation only on Codex quota / rate-limit / non-zero exit.
9
+
10
+ Feature-level WO (Expertenchat envelope). Operator request 2026-08-03: *"bei der Abstimmung wäre es
11
+ schön, wenn es ähnlicher wie WhatsApp mit dem Balken sowie ohne Abstimmungsbutton möglich wäre."*
12
+
13
+ This is a **UX improvement, not a defect** — the current poll works. It is scoped separately so it never
14
+ blocks the messaging defect WOs.
15
+
16
+ ## TIER
17
+ Tier 2 — shared-core UI consumed by every app. Independent `reviewer` + `ui_reviewer` mandatory.
18
+
19
+ ## CURRENT STATE
20
+
21
+ `PollCard.jsx` renders each option as a MUI `FormControlLabel` with a `Checkbox` (multi-select) or radio,
22
+ labelled `"<text> (<vote_count>)"`, plus a separate **"Abstimmen"** submit button and a
23
+ **"Umfrage schliessen"** action for the owner. Voting is therefore a two-step interaction — select, then
24
+ submit — and the result is a bare number in parentheses.
25
+
26
+ ## GOAL
27
+
28
+ Voting is one tap on the option itself, and the distribution is readable at a glance from proportional
29
+ bars rather than parenthesised counts.
30
+
31
+ ## SCOPE
32
+
33
+ **A. Tap-to-vote — remove the separate submit button.**
34
+
35
+ - **Single-choice poll** (`allow_multiple: false`): tapping an option casts the vote immediately.
36
+ Tapping a different option moves the vote. The "Abstimmen" button goes away entirely.
37
+ - **Multi-select poll** (`allow_multiple: true`): tapping an option toggles that option immediately —
38
+ each tap is its own vote update, not a batched submit. **Confirm dcm's vote endpoint accepts the full
39
+ option-id set per call** (it takes `option_ids`, so a toggle sends the new complete set, not a delta);
40
+ if it does not, stop and report rather than inventing a delta protocol.
41
+ - Every tap is an optimistic UI update reconciled against the server response, following the pattern
42
+ `MessagingProvider` already uses for sends. **A failed vote must visibly revert** — a silently
43
+ swallowed failure that leaves the bar showing a vote nobody recorded is worse than the current
44
+ two-step flow.
45
+ - Keep "Umfrage schliessen" — that is an owner action, not part of voting.
46
+
47
+ **B. Result bars.**
48
+
49
+ - Each option shows a horizontal proportional bar plus its count and percentage, in place of the
50
+ `(<n>)` suffix.
51
+ - The viewer's own choice(s) are visually marked. `voted_option_ids` already exists for this and is
52
+ carried in the REST vote/create/close response — note the standing constraint documented in
53
+ `MessagingProvider.jsx`: it is **viewer-specific and never present on a `poll_updated` frame**, so
54
+ frame handling must not wipe a locally-known selection. Do not regress that; it was a deliberate fix.
55
+ - Denominator: total votes cast, not participant count. A poll's turnout relative to group size is a
56
+ different question and is **not** in scope.
57
+ - Percentages must not mislead at zero votes — define and implement the zero-vote rendering explicitly
58
+ rather than letting a division produce `NaN`.
59
+
60
+ **C. Closed polls.** A closed poll shows final bars and is not interactive. Verify the closed state is
61
+ visually unmistakable — with the submit button gone, "closed" can no longer be inferred from a missing
62
+ button.
63
+
64
+ ## NON-GOALS / DO NOT TOUCH
65
+ - No backend change. If a scope-A requirement appears to need one, stop and report — that is a dcm WO.
66
+ - Do not change `voted_option_ids` handling in `applyFrame` beyond what B needs to read it (see the
67
+ merge-trap note above).
68
+ - Do not add a "who voted for what" disclosure. Per-voter visibility is a privacy decision nobody has
69
+ taken, and the existing `voters` data in `serialize_poll` is not licence to surface it.
70
+ - Do not change poll creation (`Composer`'s dialog) — this WO is the display and voting surface only.
71
+ - Do not touch `ReadTicks`, conversation titles, or anything in the MSG-6f scope.
72
+
73
+ ## RISKS
74
+ - **Tap-to-vote removes the "are you sure" step.** On a phone a mis-tap now votes. Mitigate by making
75
+ the vote reversible (tap again / tap another) and by ensuring the optimistic state is obviously
76
+ distinct from the confirmed state; do not add a confirmation dialog, which would reinstate the
77
+ two-step flow this WO removes.
78
+ - Rapid successive taps race. The last write must win deterministically — state how (request ordering,
79
+ or discard stale responses by comparing against the latest local intent).
80
+ - Accessibility: an option becomes an interactive control with a name, a pressed/checked state and
81
+ keyboard operability. Bars are decorative; the count and percentage must be in the accessible name or
82
+ adjacent text, not conveyed by width alone. `ui_reviewer` should check this specifically.
83
+ - Every host app inherits the new interaction — a visible behaviour change beyond jg-ferien.
84
+
85
+ ## REQUIRED TESTS TO WRITE
86
+ Narrow and behavioural. Do NOT run the full suite.
87
+
88
+ 1. Single-choice: tapping an option votes without any further interaction; tapping a second option
89
+ moves the vote (the first is no longer selected, the counts reflect it).
90
+ 2. Multi-select: tapping toggles one option on and off, and each tap sends the complete option-id set.
91
+ 3. A rejected vote reverts the optimistic state and surfaces an error — assert the bar returns to its
92
+ prior value, not merely that an error appeared.
93
+ 4. Bars render proportionally, including the **zero-vote** case, with no `NaN` and no division error.
94
+ 5. The viewer's own selection survives a `poll_updated` frame that does not carry `voted_option_ids`
95
+ (the documented merge trap — this must not regress).
96
+ 6. A closed poll is non-interactive: tapping an option does nothing and issues no request.
97
+
98
+ **Non-vacuity:** test 5 must fail if the merge-trap guard is removed. Test 3 must assert the reverted
99
+ value, not the presence of an error message.
100
+
101
+ ## TEST SCOPE FOR THE GATE (orchestrator)
102
+ `PollCard` and its neighbours in the messaging test files. Not the full suite.
103
+
104
+ ## TARGET REPO
105
+ `C:\Users\biglmi\Documents\webapps\ui-core-micha`. Branch `main` (or `develop` if present). Publish per
106
+ the repo's release flow; the consuming pin bump is a separate step.
107
+
108
+ ## MINI-HANDOVER (pastable)
109
+
110
+ > Repo: `C:\Users\biglmi\Documents\webapps\ui-core-micha` (branch `main`, or `develop` if present).
111
+ > Work order: `work-orders/MSG-6g.md` — read it fully, then follow the `orchestrate-codex` skill.
112
+ >
113
+ > **UX improvement, not a defect — this blocks nothing and is blocked by nothing.** Do not let it hold
114
+ > up the messaging defect WOs (dcm MSG-10, ucm MSG-6f).
115
+ >
116
+ > Frontend only: no backend change is in scope. If tap-to-vote appears to need one, stop and report.
117
+ > Two things that are easy to break and are called out in the WO: a failed vote must visibly revert
118
+ > (not fail silently), and the viewer's `voted_option_ids` must survive a `poll_updated` frame that
119
+ > does not carry it — that merge-trap guard was a deliberate earlier fix and test 5 pins it.
120
+
121
+ ## PROGRESS CONTRACT
122
+ Emit `PLAN: <steps>` up front, then a single-line `PROGRESS: [<n>/<total>] <action>` before every
123
+ relevant action and `PROGRESS: [<n>/<total>] done` on completion, spaced so no gap exceeds ~2 min,
124
+ stdout unbuffered, and exactly one final `RESULT: DONE|BLOCKED <reason>`.
125
+
126
+ ---
127
+
128
+ ## Part B — Implementation map (Orchestrator) / as-built record
129
+
130
+ **Codex was unavailable (out of credits) — implemented directly by the Orchestrator.**
131
+
132
+ ### Blocker found and resolved before implementation
133
+
134
+ Line 38-40's own contingency ("confirm dcm's vote endpoint accepts the full option-id set
135
+ per call... if it does not, stop and report") fired for real: `vote_poll` (dcm
136
+ `services.py:247-259`) only cleared prior votes for single-choice polls; multi-select only
137
+ ever `bulk_create`d, so a smaller `option_ids` set never retracted an omitted vote —
138
+ untoggling was silently a no-op. Reported to the operator, who confirmed fixing it
139
+ directly in dcm rather than working around it client-side. Fixed and published as dcm
140
+ `2.40.1` — see dcm `work-orders/MSG-11.md`. `option_ids` is now authoritative (delete
141
+ anything no longer in the set, then re-create the requested set) for both poll kinds.
142
+
143
+ ### Files changed
144
+
145
+ - `src/messaging/MessagingProvider.jsx`:
146
+ - New module-level `applyOptimisticVote(poll, previousSelectedIds, nextSelectedIds, userId)`
147
+ — adjusts only the options whose membership in the vote actually changed (count +/-1,
148
+ `voters` array +/- the acting user), leaves everything else untouched.
149
+ - New `pollVoteTokensRef` (per-poll "latest tap wins" token) alongside the existing
150
+ `activeIdRef`/`seenEventsRef`/`filtersRef`.
151
+ - `castPollVote(messageId, poll, optionIds)` rewritten: captures `previousPoll` from
152
+ `cache.messages[messageId].poll` (falling back to the `poll` param), patches the
153
+ optimistic projection into the cache immediately (same `patchMessage` shape
154
+ `toggleReaction` already uses), then on success reconciles with the server's poll, on
155
+ failure reverts to `previousPoll` — both guarded by the token so a stale response from
156
+ a since-superseded tap can't stomp a newer one.
157
+ - `src/messaging/PollCard.jsx`: full rewrite.
158
+ - Single-choice (`allow_multiple: false`) → `Radio` per option (native "tap a different
159
+ option moves the vote, tap the same one is a no-op" semantics — matches the WO's
160
+ stated design directly, no extra toggle-off logic needed).
161
+ - Multi-select → `Checkbox` per option (existing toggle semantics preserved).
162
+ - Whole-option-row tap target (`Box onClick`, `stopPropagation` on the control's own
163
+ `onClick` to avoid a double-fire) — WhatsApp-style, matches the operator's own framing.
164
+ - `LinearProgress` bar + `{{count}} ({{percent}}%)` (`MessagingPoll.OPTION_RESULT`, new
165
+ key) next to each option; `percent = totalVotes > 0 ? round(count/totalVotes*100) : 0`
166
+ — never `NaN`.
167
+ - Accessible name (`aria-label` on the control) carries option text + result text +
168
+ "you voted" — never width-only.
169
+ - Closed state: `Chip` with a lock icon + `MessagingPoll.CLOSED`, replacing the old
170
+ caption-only text (now the *only* closed signal, since the submit button is gone).
171
+ - Local `selected` state + a `voteTokenRef` mirror the provider's own token guard, since
172
+ `PollCard` is a "dumb" prop-driven component (documented in
173
+ `tests/messagingPollRendering.test.jsx`) — a rejected vote must revert visibly even
174
+ when the host never re-passes a fresh `message` prop from its own cache.
175
+ - Removed: the "Abstimmen" submit button and its `SELECT_OPTION` validation entirely.
176
+ - `src/i18n/messagingTranslations.ts`: removed now-orphaned `MessagingPoll.VOTE` and
177
+ `MessagingPoll.SELECT_OPTION`; added `MessagingPoll.OPTION_RESULT`.
178
+
179
+ ### Tests
180
+
181
+ - `tests/messagingMsg6g.test.jsx` (new) — the 6 WO-required cases verbatim.
182
+ - `tests/messagingPollRendering.test.jsx` — updated for radio (not checkbox) on
183
+ single-choice, the new label/aria-label shape, and the closed-chip instead of a
184
+ disabled button; its `t` mock upgraded to interpolate `{{count}}`/`{{percent}}` (was
185
+ bare-key-only, insufficient to assert rendered numbers).
186
+ `tests/messagingInteractions.test.jsx`'s poll test rewritten for tap-to-vote (no submit
187
+ click). `tests/messagingCompactBubble.test.jsx`'s poll smoke test needed no change
188
+ (empty `options: []`, no control interaction).
189
+
190
+ ### Verification
191
+ Full suite 234 → 240, `tsc` clean (pre-review).
192
+
193
+ ### Independent review: `reviewer` + `ui_reviewer`, both mandatory (author = Orchestrator, not Codex)
194
+
195
+ **`reviewer` — 3 findings, all fixed:**
196
+ - **R1 (P1, real bug):** `castPollVote`'s optimistic count projection was built from the
197
+ caller-supplied `poll` argument, not from `previousPoll` (the value already freshly read
198
+ from `cache.messages[messageId].poll` two lines above, and correctly used as the revert
199
+ target on failure). For a second rapid tap (e.g. switching a single-choice vote A→B)
200
+ where the caller's `poll` reference hadn't yet been refreshed — exactly how `PollCard`
201
+ is documented to behave, a "dumb" prop-driven component — the count math computed
202
+ against the stale pre-first-tap baseline, landing a silently wrong transient
203
+ `vote_count` in the **shared cache** (not just local render state) until the server's
204
+ response eventually reconciled it away. Fixed: project from `previousPoll`. Verified
205
+ non-vacuous: reverted the fix, confirmed the new regression test below fails with the
206
+ exact predicted wrong number (4 instead of 5), then restored it.
207
+ - **R2 (P2, coverage gap):** no test asserted vote-count values across a multi-tap
208
+ sequence with non-zero starting counts — the zero-vote fixtures used elsewhere
209
+ happened to mask R1 via the `Math.max(0, ...)` clamp. Closed by a new
210
+ `messagingMsg6g.test.jsx` case using non-zero seed counts (5/2) and a genuinely
211
+ cache-connected reader alongside a *statically-propped* `PollCard` (the scenario that
212
+ actually reproduces the staleness, per R1's own trace — a cache-connected `PollCard`
213
+ re-renders with a fresh `poll` prop between taps and would not have caught this).
214
+ - **R3 (P3):** `applyOptimisticVote` could push a literal `undefined` into an option's
215
+ `voters` array when `user?.id` is momentarily unset (`AuthContext` not yet populated).
216
+ Fixed with a `userId != null` guard.
217
+
218
+ **`ui_reviewer` — 2 findings, both fixed:**
219
+ - **U1 (P2):** no visual distinction between an in-flight optimistic vote and a
220
+ server-confirmed one, contradicting the WO's own risk-mitigation requirement. Fixed by
221
+ reusing `ReactionBar.jsx`'s established `pending` Set convention — dims the tapped
222
+ option's bar/text while its request is in flight, but (unlike `ReactionBar`) does
223
+ **not** disable the control, since the WO explicitly requires rapid re-taps to remain
224
+ possible and race deterministically (last-write-wins via the token guard), not be
225
+ blocked.
226
+ - **U2 (P2):** single-choice rendered bare, ungrouped `Radio` controls — no `RadioGroup`,
227
+ no shared `name`. Diverged from this repo's own established radio idiom
228
+ (`AuthFactorRequirementCard.jsx`'s `RadioGroup` + `FormControlLabel`) and lost native
229
+ arrow-key navigation between options plus the screen-reader group announcement. Fixed
230
+ by wrapping single-choice options in a real `RadioGroup`; multi-select's `Checkbox`
231
+ options are unaffected (no equivalent ARIA grouping concept applies). Switching to
232
+ `FormControlLabel` for both control types also let the manual `Box onClick` +
233
+ `stopPropagation()` whole-row-tap hack be removed entirely — `FormControlLabel`'s own
234
+ `<label>` wrapping already makes the whole row natively clickable.
235
+ - Found and fixed while addressing U2, not itself a reviewer finding: `RadioGroup`'s own
236
+ `onChange` only ever hands back `event.target.value`, a DOM string, which would coerce
237
+ a non-string option id (dcm's are UUID strings today, but the type isn't a guaranteed
238
+ contract) — caught by `tests/messagingInteractions.test.jsx`'s existing numeric-id
239
+ fixture failing after the `RadioGroup` change. Fixed by keeping each `Radio`'s own
240
+ `onChange` (closure-typed, matching the `Checkbox` branch) instead of relying on the
241
+ group-level handler.
242
+
243
+ Re-verified after all fixes: full suite 234 → **241** (7 new: 6 WO-required cases +
244
+ 1 R1/R2 regression), `tsc` clean.