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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,136 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { act, 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
+ vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: () => () => {}, onReconnect: () => () => {} }) }));
8
+
9
+ import { AttachmentList } from '../src/messaging/AttachmentList';
10
+ import { AuthContext } from '../src/auth/AuthContext';
11
+ import { MessageBubble } from '../src/messaging/MessageBubble';
12
+ import { MessagingProvider } from '../src/messaging/MessagingProvider';
13
+
14
+ const imageAttachment = { id: 'att-1', filename: 'vacation-photo.jpg', content_type: 'image/jpeg' };
15
+ const fileAttachment = { id: 'att-2', filename: 'Report.pdf', content_type: 'application/pdf' };
16
+ const fallbackAttachment = { id: 'att-3', content_type: 'application/pdf' }; // no filename -- pre-MSG-12 dcm pin
17
+
18
+ function makeApi(overrides = {}) {
19
+ return {
20
+ listConversations: vi.fn().mockResolvedValue({ results: [] }), listMessages: vi.fn().mockResolvedValue({ results: [] }),
21
+ getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
22
+ getAttachment: vi.fn().mockResolvedValue(new Blob(['full-bytes'], { type: 'image/jpeg' })),
23
+ getAttachmentThumbnail: vi.fn().mockResolvedValue(new Blob(['thumb-bytes'], { type: 'image/jpeg' })),
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ afterEach(cleanup);
29
+
30
+ describe('MSG-6h: attachment gallery (thumbnail/lightbox/right-click download)', () => {
31
+ it('1. an image attachment renders a thumbnail; clicking it opens a lightbox showing the full image', async () => {
32
+ const api = makeApi();
33
+ render(<MessagingProvider api={api} active={false}><AttachmentList attachments={[imageAttachment]} /></MessagingProvider>);
34
+ const thumbnail = await screen.findByLabelText(/MessagingAttachments\.PREVIEW/);
35
+ await waitFor(() => expect(api.getAttachmentThumbnail).toHaveBeenCalledWith('att-1'));
36
+
37
+ fireEvent.click(thumbnail);
38
+ await waitFor(() => expect(api.getAttachment).toHaveBeenCalledWith('att-1'));
39
+ const lightboxImage = await screen.findByAltText('vacation-photo.jpg');
40
+ expect(lightboxImage.tagName).toBe('IMG');
41
+ });
42
+
43
+ it('2. right-clicking an image attachment opens a context menu with Download; selecting it downloads', async () => {
44
+ const api = makeApi();
45
+ const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
46
+ render(<MessagingProvider api={api} active={false}><AttachmentList attachments={[imageAttachment]} /></MessagingProvider>);
47
+ const thumbnail = await screen.findByLabelText(/MessagingAttachments\.PREVIEW/);
48
+
49
+ fireEvent.contextMenu(thumbnail);
50
+ const downloadItem = await screen.findByText('MessagingAttachments.DOWNLOAD_ACTION');
51
+ fireEvent.click(downloadItem);
52
+ await waitFor(() => expect(api.getAttachment).toHaveBeenCalledWith('att-1'));
53
+ expect(clickSpy).toHaveBeenCalled();
54
+ clickSpy.mockRestore();
55
+ });
56
+
57
+ it('3. a non-image attachment renders a file indicator, not a broken image; clicking it downloads directly', async () => {
58
+ const api = makeApi();
59
+ const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
60
+ render(<MessagingProvider api={api} active={false}><AttachmentList attachments={[fileAttachment]} /></MessagingProvider>);
61
+ const target = screen.getByLabelText('MessagingAttachments.DOWNLOAD:{"name":"Report.pdf"}');
62
+ expect(target.querySelector('img')).toBeNull();
63
+ expect(screen.getByText('Report.pdf')).toBeTruthy();
64
+
65
+ fireEvent.click(target);
66
+ await waitFor(() => expect(api.getAttachment).toHaveBeenCalledWith('att-2'));
67
+ expect(clickSpy).toHaveBeenCalled();
68
+ clickSpy.mockRestore();
69
+ // No lightbox for a non-image -- nothing to preview.
70
+ expect(screen.queryByRole('dialog')).toBeNull();
71
+ });
72
+
73
+ it('4. nameOf prefers attachment.filename when present, falls back to attachment.id when absent (pre-MSG-12 dcm interop)', () => {
74
+ render(<MessagingProvider api={makeApi()} active={false}><AttachmentList attachments={[fileAttachment, fallbackAttachment]} /></MessagingProvider>);
75
+ expect(screen.getByText('Report.pdf')).toBeTruthy();
76
+ expect(screen.getByText('att-3')).toBeTruthy();
77
+ });
78
+
79
+ it('6. the lightbox itself has a Download action -- reachable without a right-click, closing an accessibility regression (touch/keyboard users could not download an image otherwise)', async () => {
80
+ const api = makeApi();
81
+ const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
82
+ render(<MessagingProvider api={api} active={false}><AttachmentList attachments={[imageAttachment]} /></MessagingProvider>);
83
+ const thumbnail = await screen.findByLabelText(/MessagingAttachments\.PREVIEW/);
84
+ fireEvent.click(thumbnail);
85
+ await screen.findByAltText('vacation-photo.jpg');
86
+ api.getAttachment.mockClear();
87
+
88
+ const downloadButton = screen.getByLabelText('MessagingAttachments.DOWNLOAD_ACTION');
89
+ fireEvent.click(downloadButton);
90
+ await waitFor(() => expect(api.getAttachment).toHaveBeenCalledWith('att-1'));
91
+ expect(clickSpy).toHaveBeenCalled();
92
+ clickSpy.mockRestore();
93
+ });
94
+
95
+ it('7. the lightbox shows a loading state while the full image fetches, and an error state if the fetch fails', async () => {
96
+ let resolveFetch;
97
+ const api = makeApi({ getAttachment: vi.fn(() => new Promise((resolve) => { resolveFetch = resolve; })) });
98
+ render(<MessagingProvider api={api} active={false}><AttachmentList attachments={[imageAttachment]} /></MessagingProvider>);
99
+ const thumbnail = await screen.findByLabelText(/MessagingAttachments\.PREVIEW/);
100
+
101
+ fireEvent.click(thumbnail);
102
+ await screen.findByLabelText('MessagingAttachments.LOADING_PREVIEW');
103
+ expect(screen.queryByAltText('vacation-photo.jpg')).toBeNull();
104
+
105
+ resolveFetch(new Blob(['bytes'], { type: 'image/jpeg' }));
106
+ await screen.findByAltText('vacation-photo.jpg');
107
+ expect(screen.queryByLabelText('MessagingAttachments.LOADING_PREVIEW')).toBeNull();
108
+ });
109
+
110
+ it('8. a failed full-image fetch shows a visible error, not a silent no-op', async () => {
111
+ const api = makeApi({ getAttachment: vi.fn().mockRejectedValue(new Error('network error')) });
112
+ render(<MessagingProvider api={api} active={false}><AttachmentList attachments={[imageAttachment]} /></MessagingProvider>);
113
+ const thumbnail = await screen.findByLabelText(/MessagingAttachments\.PREVIEW/);
114
+
115
+ fireEvent.click(thumbnail);
116
+ await screen.findByRole('alert');
117
+ expect(screen.getByRole('alert').textContent).toBe('MessagingAttachments.PREVIEW_ERROR');
118
+ });
119
+
120
+ it('5. the attachment context menu does not also trigger the message-level action menu', async () => {
121
+ const api = makeApi();
122
+ render(
123
+ <AuthContext.Provider value={{ user: { id: 1 } }}>
124
+ <MessagingProvider api={api} active={false}>
125
+ <MessageBubble message={{ id: 'm1', body: 'see attached', sender: { id: 1 }, attachments: [imageAttachment] }} conversation={{ kind: 'group' }} canModerateMessages />
126
+ </MessagingProvider>
127
+ </AuthContext.Provider>,
128
+ );
129
+ const thumbnail = await screen.findByLabelText(/MessagingAttachments\.PREVIEW/);
130
+
131
+ fireEvent.contextMenu(thumbnail);
132
+ await screen.findByText('MessagingAttachments.DOWNLOAD_ACTION');
133
+ // The message-level menu's own items (e.g. Reply) must not also be open.
134
+ expect(screen.queryByText('MessagingThread.REPLY')).toBeNull();
135
+ });
136
+ });
@@ -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.